Long files draw; notifications empty state and device rows !150
3 files changed, +94 −16
Layout: unified · split
gitbay/Views/Account/NotificationsView.swift +30 −8
| @@ -42,11 +42,20 @@ struct NotificationsView: View { | |||
| 42 | } | 42 | } |
| 43 | } | 43 | } |
| 44 | } | 44 | } |
| 45 | // An empty inbox is a row, not an overlay: the push section | ||
| 46 | // below still has content. | ||
| 47 | if case .empty(let message) = model.state { | ||
| 48 | Text(message) | ||
| 49 | .font(.gbSans(.subheadline)) | ||
| 50 | .foregroundStyle(.secondary) | ||
| 51 | } | ||
| 45 | PageFooter(list: model.list) | 52 | PageFooter(list: model.list) |
| 46 | 53 | ||
| 47 | pushSection | 54 | pushSection |
| 48 | } | 55 | } |
| 49 | .overlay { LoadStateOverlay(state: model.state) } | 56 | .overlay { |
| 57 | if case .empty = model.state {} else { LoadStateOverlay(state: model.state) } | ||
| 58 | } | ||
| 50 | .navigationTitle("Notifications") | 59 | .navigationTitle("Notifications") |
| 51 | .navigationBarTitleDisplayMode(.inline) | 60 | .navigationBarTitleDisplayMode(.inline) |
| 52 | .toolbar { | 61 | .toolbar { |
| @@ -92,7 +101,7 @@ struct NotificationsView: View { | |||
| 92 | /// install, and a prompt sprung by navigation earns a reflexive | 101 | /// install, and a prompt sprung by navigation earns a reflexive |
| 93 | /// "Don't Allow" that nothing afterwards can explain. | 102 | /// "Don't Allow" that nothing afterwards can explain. |
| 94 | private var pushSection: some View { | 103 | private var pushSection: some View { |
| 95 | Section("Push notifications") { | 104 | Section { |
| 96 | Toggle("Push notifications", isOn: Binding( | 105 | Toggle("Push notifications", isOn: Binding( |
| 97 | get: { authorization == .authorized && model.pushEnabled }, | 106 | get: { authorization == .authorized && model.pushEnabled }, |
| 98 | set: { on in Task { await setPush(on) } } | 107 | set: { on in Task { await setPush(on) } } |
| @@ -110,19 +119,32 @@ struct NotificationsView: View { | |||
| 110 | } | 119 | } |
| 111 | 120 | ||
| 112 | ForEach(model.devices) { device in | 121 | ForEach(model.devices) { device in |
| 113 | VStack(alignment: .leading, spacing: 2) { | 122 | Label { |
| 114 | Text(device.label) | 123 | VStack(alignment: .leading, spacing: 2) { |
| 115 | .font(.gbSans(.subheadline)) | 124 | Text(device.label) |
| 116 | Text(device.token) | 125 | .font(.gbSans(.subheadline)) |
| 117 | .font(.gbMono(.caption)) | 126 | Text("added \(AdminQueues.relative(device.added)) · \(device.token)") |
| 118 | .foregroundStyle(.secondary) | 127 | .font(.gbSans(.caption)) |
| 128 | .foregroundStyle(.secondary) | ||
| 129 | .lineLimit(1) | ||
| 130 | } | ||
| 131 | } icon: { | ||
| 132 | Image(systemName: "iphone").foregroundStyle(.secondary) | ||
| 119 | } | 133 | } |
| 134 | .accessibilityElement(children: .ignore) | ||
| 135 | .accessibilityLabel("Registered device, \(device.label)") | ||
| 120 | .swipeActions { | 136 | .swipeActions { |
| 121 | Button("Remove", role: .destructive) { | 137 | Button("Remove", role: .destructive) { |
| 122 | Task { await model.removeDevice(device.id) } | 138 | Task { await model.removeDevice(device.id) } |
| 123 | } | 139 | } |
| 124 | } | 140 | } |
| 125 | } | 141 | } |
| 142 | } header: { | ||
| 143 | Text("Push notifications") | ||
| 144 | } footer: { | ||
| 145 | if !model.devices.isEmpty { | ||
| 146 | Text("Devices registered for push. Swipe one to remove it.") | ||
| 147 | } | ||
| 126 | } | 148 | } |
| 127 | } | 149 | } |
| 128 | 150 | ||
gitbay/Views/Repos/FileView.swift +29 −8
| @@ -89,29 +89,50 @@ struct FileView: View { | |||
| 89 | } | 89 | } |
| 90 | 90 | ||
| 91 | /// Both-axis scrolling for code, highlighted when the language is known. | 91 | /// Both-axis scrolling for code, highlighted when the language is known. |
| 92 | /// One Text per line in a lazy stack: a single Text of a long file (a | ||
| 93 | /// 2,700-line changelog) does not draw. | ||
| 92 | private struct CodeScrollView: View { | 94 | private struct CodeScrollView: View { |
| 93 | 95 | ||
| 94 | let text: String | 96 | let text: String |
| 95 | let fileName: String | 97 | let fileName: String |
| 96 | let colorScheme: ColorScheme | 98 | let colorScheme: ColorScheme |
| 97 | 99 | ||
| 100 | @State private var lines: [AttributedString] = [] | ||
| 101 | |||
| 98 | var body: some View { | 102 | var body: some View { |
| 99 | ScrollView([.horizontal, .vertical]) { | 103 | ScrollView([.horizontal, .vertical]) { |
| 100 | code | 104 | LazyVStack(alignment: .leading, spacing: 0) { |
| 101 | .padding(12) | 105 | ForEach(lines.indices, id: \.self) { index in |
| 102 | .frame(maxWidth: .infinity, alignment: .leading) | 106 | Text(lines[index].characters.isEmpty ? " " : lines[index]) |
| 107 | .fixedSize() | ||
| 108 | } | ||
| 109 | } | ||
| 110 | .padding(12) | ||
| 103 | } | 111 | } |
| 112 | .defaultScrollAnchor(.topLeading) | ||
| 104 | .font(.gbMono(.caption)) | 113 | .font(.gbMono(.caption)) |
| 114 | .task(id: colorScheme) { lines = highlightedLines() } | ||
| 105 | } | 115 | } |
| 106 | 116 | ||
| 107 | private var code: some View { | 117 | private func highlightedLines() -> [AttributedString] { |
| 108 | let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize | 118 | let size = UIFont.preferredFont(forTextStyle: .caption1).pointSize |
| 109 | let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size) | 119 | let font = UIFont(name: "Atkinson Hyperlegible Mono", size: size) |
| 110 | ?? .monospacedSystemFont(ofSize: size, weight: .regular) | 120 | ?? .monospacedSystemFont(ofSize: size, weight: .regular) |
| 111 | let highlighter = SyntaxHighlighter(colorScheme: colorScheme) | 121 | let highlighter = SyntaxHighlighter(colorScheme: colorScheme) |
| 112 | if let highlighted = highlighter.highlight(text, fileName: fileName, font: font) { | 122 | return codeLines(highlighter.highlight(text, fileName: fileName, font: font) |
| 113 | return Text(AttributedString(highlighted)) | 123 | ?? NSAttributedString(string: text)) |
| 114 | } | 124 | } |
| 115 | return Text(text) | 125 | } |
| 126 | |||
| 127 | /// A file's lines, each keeping its highlighting. A trailing newline ends | ||
| 128 | /// the last line rather than starting an empty one. | ||
| 129 | nonisolated func codeLines(_ source: NSAttributedString) -> [AttributedString] { | ||
| 130 | var lines: [AttributedString] = [] | ||
| 131 | let string = source.string as NSString | ||
| 132 | string.enumerateSubstrings( | ||
| 133 | in: NSRange(location: 0, length: string.length), options: [.byLines, .substringNotRequired] | ||
| 134 | ) { _, range, _, _ in | ||
| 135 | lines.append(AttributedString(source.attributedSubstring(from: range))) | ||
| 116 | } | 136 | } |
| 137 | return lines | ||
| 117 | } | 138 | } |
gitbayTests/CodeLinesTests.swift added +35
| @@ -0,0 +1,35 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | import UIKit | ||
| 4 | @testable import gitbay | ||
| 5 | |||
| 6 | struct CodeLinesTests { | ||
| 7 | |||
| 8 | private func text(_ lines: [AttributedString]) -> [String] { | ||
| 9 | lines.map { String($0.characters) } | ||
| 10 | } | ||
| 11 | |||
| 12 | @Test func splitsOnNewlinesKeepingBlankLines() { | ||
| 13 | let lines = codeLines(NSAttributedString(string: "a\n\nb\r\nc")) | ||
| 14 | #expect(text(lines) == ["a", "", "b", "c"]) | ||
| 15 | } | ||
| 16 | |||
| 17 | @Test func aTrailingNewlineAddsNoLine() { | ||
| 18 | #expect(text(codeLines(NSAttributedString(string: "a\nb\n"))) == ["a", "b"]) | ||
| 19 | } | ||
| 20 | |||
| 21 | @Test func keepsAttributesPerLine() { | ||
| 22 | let source = NSMutableAttributedString(string: "red\nplain") | ||
| 23 | source.addAttribute(.foregroundColor, value: UIColor.red, range: NSRange(location: 0, length: 3)) | ||
| 24 | let lines = codeLines(source) | ||
| 25 | #expect(lines[0].runs.first?.uiKit.foregroundColor == UIColor.red) | ||
| 26 | #expect(lines[1].runs.first?.uiKit.foregroundColor == nil) | ||
| 27 | } | ||
| 28 | |||
| 29 | @Test func aLongFileKeepsEveryLine() { | ||
| 30 | let source = (1...2682).map { "line \($0)" }.joined(separator: "\n") | ||
| 31 | let lines = codeLines(NSAttributedString(string: source)) | ||
| 32 | #expect(lines.count == 2682) | ||
| 33 | #expect(text([lines.last!]) == ["line 2682"]) | ||
| 34 | } | ||
| 35 | } | ||