iOS theme and display settings !171

merged merged by cmc on 2026-10-08 07:16 UTC · krz/orgstar:ios-theme-display into main

15 files changed, +966 −222

Layout: unified · split

Sources/OrgApp/ConfigSync.swift +5 −17
@@ -110,23 +110,11 @@ public final class ConfigSync {
110110
111111 /// The theme from the file read last and its theme file.
112112 func readTheme() {
113 guard let text, let tables = try? TOML.parse(text) else { return }
114 var spec = ThemeSpec.default
115 var themeProblems: [String] = []
116 if let name = defaults.string(forKey: "themeFile"), let file = themeFile {
117 if let themeText = try? String(contentsOf: file, encoding: .utf8) {
118 do {
119 (spec, themeProblems) = ThemeSpec.reading(try TOML.parse(themeText), file: name)
120 } catch {
121 themeProblems.append("\(name): \(error)")
122 }
123 } else {
124 themeProblems.append("config.toml: theme-file \(name) can't be read")
125 }
126 }
127 let (merged, more) = ThemeSpec.reading(tables, over: spec)
128 if merged != theme { theme = merged }
129 problems = configProblems + themeProblems + more
113 guard let text, let read = UserConfig.theme(
114 config: text, themeFile: defaults.string(forKey: "themeFile"), in: url.deletingLastPathComponent()
115 ) else { return }
116 if read.theme != theme { theme = read.theme }
117 problems = configProblems + read.problems
130118 }
131119
132120 /// Writes settings that differ from the file into it.
Sources/OrgApp/OrgPreferences.swift +14
@@ -1,5 +1,6 @@
11import Foundation
22import OrgCore
3import OrgPresentation
34
45/// The Org settings `config.toml` holds, read from user defaults with the file's fallbacks, for
56/// both apps.
@@ -33,6 +34,19 @@ public enum OrgPreferences {
3334 return options
3435 }
3536
37 /// `colors` with the type settings of `[theme]`: font, size, line spacing, heading step.
38 public static func theme(_ colors: ThemeSpec, _ defaults: UserDefaults = .standard) -> ThemeSpec {
39 var spec = colors
40 spec.font = string("themeFont", defaults)
41 spec.fontSize = integer("themeFontSize", defaults)
42 spec.lineSpacing = integer("themeLineSpacing", defaults)
43 spec.headingSizeStep = integer("themeHeadingStep", defaults)
44 return spec
45 }
46
47 /// `show-markup`: link brackets and emphasis markers shown.
48 public static func showMarkup(_ defaults: UserDefaults = .standard) -> Bool { bool("showMarkup", defaults) }
49
3650 public static func agendaSpan(_ defaults: UserDefaults = .standard) -> Int { integer("agendaSpan", defaults) }
3751 public static func agendaStartOffset(_ defaults: UserDefaults = .standard) -> Int { integer("agendaStartOffset", defaults) }
3852 public static func agendaSubfolders(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSubfolders", defaults) }
Sources/OrgApp/UserConfig.swift +25
@@ -23,6 +23,31 @@ public enum UserConfig {
2323 return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".config/orgstar")
2424 }
2525
26 /// The theme `config.toml` (`text`) sets: its `[theme]` tables' colors over those of
27 /// `theme-file` (a path relative to `folder`), over the default theme; with problems in
28 /// both. Nil when `text` isn't TOML.
29 public static func theme(
30 config text: String, themeFile name: String?, in folder: URL,
31 read: (URL) -> String? = { try? String(contentsOf: $0, encoding: .utf8) }
32 ) -> (theme: ThemeSpec, problems: [String])? {
33 guard let tables = try? TOML.parse(text) else { return nil }
34 var spec = ThemeSpec.default
35 var problems: [String] = []
36 if let name, !name.isEmpty {
37 if let themeText = read(folder.appendingPathComponent(name)) {
38 do {
39 (spec, problems) = ThemeSpec.reading(try TOML.parse(themeText), file: name)
40 } catch {
41 problems.append("\(name): \(error)")
42 }
43 } else {
44 problems.append("config.toml: theme-file \(name) can't be read")
45 }
46 }
47 let (merged, more) = ThemeSpec.reading(tables, over: spec)
48 return (merged, problems + more)
49 }
50
2651 /// Where earlier versions kept the configuration files.
2752 public static var legacyDirectory: URL {
2853 FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar")
Sources/OrgEditorAppKit/OrgEditor.swift +32 −105
@@ -176,22 +176,13 @@ public final class OrgEditor: NSObject {
176176 // Files open at their start, as in Emacs; replacing the storage leaves it at the end.
177177 textView.setSelectedRange(NSRange(location: 0, length: 0))
178178 if isOrg {
179 let words = Presentation.startupOptions(document.tree).map { $0.lowercased() }
180 indented = words.contains("indent") ? true : words.contains("noindent") ? false : options.startupIndented
181 hideStars = words.contains("hidestars") ? true : words.contains("showstars") ? false : options.hideLeadingStars
182 var startup = Presentation.startupFoldState(document.tree) ?? FoldState()
183 // VISIBILITY properties and archived trees, unless everything shows.
184 if Presentation.startupFoldState(document.tree) != nil, !words.contains("showeverything") {
185 startup = Presentation.applyVisibilityProperties(startup, in: document.tree)
186 }
187 let wrappers = Presentation.startupWrappers(document.tree, hideDrawers: options.hideDrawerStartup, hideBlocks: options.hideBlockStartup)
188 startup.wrapperFolds = Set(wrappers.map(\.start))
189 if startup != FoldState() { setFolds(startup, wrappers: wrappers) }
179 let startup = Presentation.startupDisplay(document.tree, options: options)
180 indented = startup.indented
181 hideStars = startup.hideStars
182 if startup.folds != FoldState() { setFolds(startup.folds, wrappers: startup.wrappers) }
190183 shrinkAtStartup()
191 if InlineImages.startup(document.tree) ?? options.startupWithInlineImages { showsImages = true }
192 if words.contains("align") || (options.startupAlignAllTables && !words.contains("noalign")) {
193 _ = perform(AlignAllTables())
194 }
184 if startup.inlineImages { showsImages = true }
185 if startup.alignTables { _ = perform(AlignAllTables()) }
195186 if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) }
196187 }
197188 spellChecking = options.spellCheck
@@ -1047,38 +1038,22 @@ public final class OrgEditor: NSObject {
10471038 }
10481039 return
10491040 }
1050 let text = storage.string as NSString
1051 for run in Presentation.indents(document.tree, in: range) where indented {
1052 guard let clipped = clip(run.range, to: range) else { continue }
1053 guard run.firstLine == run.wrapped else {
1054 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped), range: clipped)
1055 continue
1056 }
1057 // Body lines wrap under their own text: past leading blanks, and past a list bullet
1058 // and checkbox, as adaptive-wrap does in Emacs.
1059 var location = clipped.location
1060 while location < NSMaxRange(clipped) {
1061 let line = text.lineRange(for: NSRange(location: location, length: 0))
1062 let hanging = Self.hangingColumns(text.substring(with: line))
1063 let lineRange = NSIntersectionRange(line, clipped)
1064 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped + hanging), range: lineRange)
1065 location = NSMaxRange(line)
1041 if indented {
1042 for line in Presentation.lineIndents(document.tree, in: range) {
1043 storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range))
10661044 }
10671045 }
10681046 let revealed = caretLine
10691047 var scriptExclusions: [Range<Int>] = []
10701048 for run in Presentation.styles(document.tree, in: range, highlighter: highlighter) {
10711049 guard let clipped = clip(run.range, to: range) else { continue }
1072 if run.role == .leadingStars, !indented, !hideStars { continue }
1073 if !showMarkup, hides(run.role), NSIntersectionRange(clipped, revealed).length == 0 {
1050 if Presentation.skipsLeadingStars(run.role, indented: indented, hideStars: hideStars) { continue }
1051 if !showMarkup, Presentation.hidesWithMarkup(run.role, options: options), NSIntersectionRange(clipped, revealed).length == 0 {
10741052 storage.addAttributes(theme.hidden, range: clipped)
10751053 } else {
10761054 theme.apply(run.role, to: storage, range: clipped)
10771055 }
1078 switch run.role {
1079 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkMarkup, .keyword: scriptExclusions.append(run.range)
1080 default: break
1081 }
1056 if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) }
10821057 }
10831058 if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
10841059 applyShrunk(range, in: storage)
@@ -1087,52 +1062,32 @@ public final class OrgEditor: NSObject {
10871062 }
10881063
10891064 /// `org-pretty-entities`: entities drawn as their character over the entity's last
1090 /// character (the rest hidden), sub/superscripts raised without their markers. Not on the
1091 /// caret's line, in comments or in blocks; sub/superscripts not in emphasis, links or
1092 /// keywords, as `org-raise-scripts` leaves them.
1065 /// character (the rest hidden), sub/superscripts raised without their markers.
10931066 private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) {
1094 let text = storage.string as NSString
1095 var location = range.lowerBound
1096 while location < range.upperBound {
1097 let line = text.lineRange(for: NSRange(location: location, length: 0))
1098 guard line.length > 0 else { break }
1099 location = NSMaxRange(line)
1100 if line.location == revealed.location || shaded.containing(line.location) != nil { continue }
1101 let content = text.substring(with: line)
1102 if content.range(of: "^[ \\t]*#([ +]|$)", options: .regularExpression) != nil { continue }
1103 for run in PrettyEntities.runs(in: content, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces) {
1104 switch run {
1105 case .entity(let r, let display):
1106 let start = line.location + r.lowerBound
1107 let last = line.location + r.upperBound - 1
1108 if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
1109 storage.addAttributes([.foregroundColor: NSColor.clear, .orgEntityDisplay: display], range: NSRange(location: last, length: 1))
1110 case .script(let markers, let body, let superscript):
1111 let bodyStart = line.location + body.lowerBound
1112 if scriptExclusions.contains(where: { $0.contains(bodyStart) }) || body.isEmpty { continue }
1113 for marker in markers {
1114 storage.addAttributes(theme.hidden, range: NSRange(location: line.location + marker.lowerBound, length: marker.count))
1115 }
1116 let size = theme.font.pointSize
1117 storage.addAttributes([
1118 .font: NSFont.monospacedSystemFont(ofSize: size * 0.7, weight: .regular),
1119 .baselineOffset: (superscript ? 0.3 : -0.3) * size,
1120 ], range: NSRange(location: bodyStart, length: body.count))
1067 let runs = Presentation.prettyRuns(
1068 storage.string as NSString, in: range, revealedLine: revealed.location, inBlock: { shaded.containing($0) != nil },
1069 scriptExclusions: scriptExclusions, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces
1070 )
1071 for (lineStart, run) in runs {
1072 switch run {
1073 case .entity(let r, let display):
1074 let start = lineStart + r.lowerBound
1075 let last = lineStart + r.upperBound - 1
1076 if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
1077 storage.addAttributes([.foregroundColor: NSColor.clear, .orgEntityDisplay: display], range: NSRange(location: last, length: 1))
1078 case .script(let markers, let body, let superscript):
1079 for marker in markers {
1080 storage.addAttributes(theme.hidden, range: NSRange(location: lineStart + marker.lowerBound, length: marker.count))
11211081 }
1082 let size = theme.font.pointSize
1083 storage.addAttributes([
1084 .font: NSFont.monospacedSystemFont(ofSize: size * 0.7, weight: .regular),
1085 .baselineOffset: (superscript ? 0.3 : -0.3) * size,
1086 ], range: NSRange(location: lineStart + body.lowerBound, length: body.count))
11221087 }
11231088 }
11241089 }
11251090
1126 /// Link brackets and targets are hidden as with `org-link-descriptive`; emphasis
1127 /// markers only when Emacs hides them too.
1128 private func hides(_ role: StyleRole) -> Bool {
1129 switch role {
1130 case .linkMarkup: true
1131 case .markup: options.hideEmphasisMarkers
1132 default: false
1133 }
1134 }
1135
11361091 /// Moving to another line shows its markup and hides the last one's.
11371092 private func revealCaretLine() {
11381093 guard !showMarkup, isOrg else { return }
@@ -1306,34 +1261,6 @@ public final class OrgEditor: NSObject {
13061261 onChange?()
13071262 }
13081263
1309 /// Columns a wrapped continuation of `line` should start past the line's own start.
1310 nonisolated static func hangingColumns(_ line: String) -> Int {
1311 var columns = 0
1312 var rest = Substring(line)
1313 while let c = rest.first, c == " " || c == "\t" {
1314 columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
1315 rest = rest.dropFirst()
1316 }
1317 // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
1318 var bullet = 0
1319 if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
1320 bullet = 1
1321 } else {
1322 let digits = rest.prefix { $0.isASCII && $0.isNumber }
1323 if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
1324 bullet = digits.count + 1
1325 }
1326 }
1327 guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
1328 columns += bullet + 1
1329 let afterBullet = rest.dropFirst(bullet + 1)
1330 if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
1331 afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
1332 columns += 4
1333 }
1334 return columns
1335 }
1336
13371264 /// Whether a heading line touches `start..<end` or starts right after it, where joining
13381265 /// lines could take it away.
13391266 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
Sources/OrgPresentation/Display.swift added +153
@@ -0,0 +1,153 @@
1import Foundation
2import OrgCore
3
4/// How a file shows when it opens, from the display settings and its `#+STARTUP` words.
5public struct StartupDisplay: Sendable, Equatable {
6 /// Headings, drawers and blocks folded.
7 public var folds: FoldState
8 /// The folded drawers and blocks.
9 public var wrappers: [Wrapper]
10 /// `org-indent-mode` (`indent`, `noindent`).
11 public var indented: Bool
12 /// `org-hide-leading-stars` (`hidestars`, `showstars`).
13 public var hideStars: Bool
14 /// `org-startup-with-inline-images` (`inlineimages`, `noinlineimages`).
15 public var inlineImages: Bool
16 /// `org-startup-align-all-tables` (`align`, `noalign`).
17 public var alignTables: Bool
18 /// `org-startup-truncated`.
19 public var truncated: Bool
20}
21
22/// Org's display, shared by the Mac and iOS editors.
23extension Presentation {
24 public static func startupDisplay(_ tree: OrgTree, options: EditingOptions) -> StartupDisplay {
25 let words = startupOptions(tree).map { $0.lowercased() }
26 var folds = startupFoldState(tree) ?? FoldState()
27 // VISIBILITY properties and archived trees, unless everything shows.
28 if startupFoldState(tree) != nil, !words.contains("showeverything") {
29 folds = applyVisibilityProperties(folds, in: tree)
30 }
31 let wrappers = startupWrappers(tree, hideDrawers: options.hideDrawerStartup, hideBlocks: options.hideBlockStartup)
32 folds.wrapperFolds = Set(wrappers.map(\.start))
33 return StartupDisplay(
34 folds: folds, wrappers: wrappers,
35 indented: words.contains("indent") ? true : words.contains("noindent") ? false : options.startupIndented,
36 hideStars: words.contains("hidestars") ? true : words.contains("showstars") ? false : options.hideLeadingStars,
37 inlineImages: InlineImages.startup(tree) ?? options.startupWithInlineImages,
38 alignTables: words.contains("align") || (options.startupAlignAllTables && !words.contains("noalign")),
39 truncated: options.startupTruncated
40 )
41 }
42
43 /// Whether `role` hides while markup is hidden: link brackets and targets as with
44 /// `org-link-descriptive`; emphasis markers only when Emacs hides them too.
45 public static func hidesWithMarkup(_ role: StyleRole, options: EditingOptions) -> Bool {
46 switch role {
47 case .linkMarkup: true
48 case .markup: options.hideEmphasisMarkers
49 default: false
50 }
51 }
52
53 /// Whether a style run is skipped: leading stars show as the heading's color unless the
54 /// view is indented or hides them.
55 public static func skipsLeadingStars(_ role: StyleRole, indented: Bool, hideStars: Bool) -> Bool {
56 role == .leadingStars && !indented && !hideStars
57 }
58
59 /// One line's indentation under org-indent, in columns.
60 public struct LineIndent: Sendable, Equatable {
61 public let range: Range<Int>
62 public let firstLine: Int
63 public let wrapped: Int
64 }
65
66 /// org-indent's indentation for the lines in `range`: each run of `indents`, with body lines
67 /// wrapping under their own text (past leading blanks, a list bullet and a checkbox) as
68 /// adaptive-wrap does in Emacs.
69 public static func lineIndents(_ tree: OrgTree, in range: Range<Int>) -> [LineIndent] {
70 let text = tree.text as NSString
71 var out: [LineIndent] = []
72 for run in indents(tree, in: range) {
73 let clipped = run.range.clamped(to: range)
74 guard !clipped.isEmpty else { continue }
75 guard run.firstLine == run.wrapped else {
76 out.append(LineIndent(range: clipped, firstLine: run.firstLine, wrapped: run.wrapped))
77 continue
78 }
79 var location = clipped.lowerBound
80 while location < clipped.upperBound {
81 let line = text.lineRange(for: NSRange(location: location, length: 0))
82 let hanging = hangingColumns(text.substring(with: line))
83 let lineRange = max(line.location, clipped.lowerBound)..<min(NSMaxRange(line), clipped.upperBound)
84 out.append(LineIndent(range: lineRange, firstLine: run.firstLine, wrapped: run.wrapped + hanging))
85 location = NSMaxRange(line)
86 }
87 }
88 return out
89 }
90
91 /// Columns a wrapped continuation of `line` should start past the line's own start.
92 public static func hangingColumns(_ line: String) -> Int {
93 var columns = 0
94 var rest = Substring(line)
95 while let c = rest.first, c == " " || c == "\t" {
96 columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
97 rest = rest.dropFirst()
98 }
99 // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
100 var bullet = 0
101 if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
102 bullet = 1
103 } else {
104 let digits = rest.prefix { $0.isASCII && $0.isNumber }
105 if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
106 bullet = digits.count + 1
107 }
108 }
109 guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
110 columns += bullet + 1
111 let afterBullet = rest.dropFirst(bullet + 1)
112 if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
113 afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
114 columns += 4
115 }
116 return columns
117 }
118
119 /// `org-pretty-entities` for the lines in `range`, with each run's line start: not on the
120 /// line starting at `revealedLine`, in comments or in blocks; sub/superscripts not in
121 /// `scriptExclusions` (emphasis, links, keywords), as `org-raise-scripts` leaves them.
122 public static func prettyRuns(
123 _ text: NSString, in range: Range<Int>, revealedLine: Int?, inBlock: (Int) -> Bool,
124 scriptExclusions: [Range<Int>], subSuperscriptsNeedBraces: Bool
125 ) -> [(lineStart: Int, run: PrettyEntities.Run)] {
126 var out: [(lineStart: Int, run: PrettyEntities.Run)] = []
127 var location = range.lowerBound
128 while location < range.upperBound {
129 let line = text.lineRange(for: NSRange(location: location, length: 0))
130 guard line.length > 0 else { break }
131 location = NSMaxRange(line)
132 if line.location == revealedLine || inBlock(line.location) { continue }
133 let content = text.substring(with: line)
134 if content.range(of: "^[ \\t]*#([ +]|$)", options: .regularExpression) != nil { continue }
135 for run in PrettyEntities.runs(in: content, subSuperscriptsNeedBraces: subSuperscriptsNeedBraces) {
136 if case .script(_, let body, _) = run {
137 let bodyStart = line.location + body.lowerBound
138 if body.isEmpty || scriptExclusions.contains(where: { $0.contains(bodyStart) }) { continue }
139 }
140 out.append((line.location, run))
141 }
142 }
143 return out
144 }
145
146 /// Roles whose text keeps sub/superscript markers as written.
147 public static func excludesScripts(_ role: StyleRole) -> Bool {
148 switch role {
149 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkMarkup, .keyword: true
150 default: false
151 }
152 }
153}
Sources/OrgstarMobile/EditorScreen.swift +15 −3
@@ -67,6 +67,9 @@ struct EditorScreen: View {
6767 Menu("More", systemImage: "ellipsis.circle") {
6868 Button("Commands", systemImage: "command") { sheet = .palette }
6969 Button("Clock In", systemImage: "stopwatch") { editor?.run("app.clock.in") }
70 Toggle(isOn: Binding(get: { model.showMarkup }, set: { model.showMarkup = $0 })) {
71 Label("Show Markup", systemImage: "chevron.left.forwardslash.chevron.right")
72 }
7073 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) }
7174 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
7275 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
@@ -118,6 +121,11 @@ struct EditorScreen: View {
118121 .onChange(of: session.revealRequest?.id) {
119122 if let offset = session.revealRequest?.offset, session.path == path { editor?.reveal(offset) }
120123 }
124 .onChange(of: model.settingsVersion) {
125 editor?.options = session.options
126 editor?.themeSpec = model.theme
127 }
128 .onChange(of: model.showMarkup) { editor?.showMarkup = model.showMarkup }
121129 .onDisappear {
122130 // Typing the reader shows next, and a save, don't wait for the autosave delay.
123131 if session.dirtyPaths.contains(path) { _ = session.save(path) }
@@ -146,8 +154,10 @@ struct EditorScreen: View {
146154 private func start() {
147155 if session.path != path { session.open(path) }
148156 if editor == nil, let document = session.document {
149 let editor = MobileEditor(document: document, editable: document.isEditable)
150 editor.options = session.options
157 let editor = MobileEditor(
158 document: document, editable: document.isEditable, options: session.options, theme: model.theme,
159 showMarkup: model.showMarkup, baseDirectory: URL(fileURLWithPath: path).deletingLastPathComponent()
160 )
151161 editor.knownTags = { [index = workspace.index] in (try? index.tags()) ?? [] }
152162 editor.onChange = { [weak session] in session?.bufferChanged(path) }
153163 editor.onMessage = { message = $0 }
@@ -171,6 +181,7 @@ struct EditorScreen: View {
171181 case "app.clock.cancel": message = clock.clockOut(session, cancel: true)
172182 case "app.clock.goto":
173183 if let location = clock.location(session) { model.openRequest = .offset(location.path, location.offset) } else { message = "No active clock" }
184 case "app.toggle-markup": model.showMarkup.toggle()
174185 default: message = "Not available on iOS yet"
175186 }
176187 }
@@ -401,7 +412,8 @@ struct CommandPalette: View {
401412 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"),
402413 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
403414 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"),
404 ("app.clock.goto", "Go to Clocked Entry"),
415 ("app.clock.goto", "Go to Clocked Entry"), ("app.toggle-markup", "Show or Hide Markup"),
416 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"),
405417 ]
406418
407419 var body: some View {
Sources/OrgstarMobile/MobileEditor.swift +397 −30
@@ -17,12 +17,56 @@ final class LockedRanges: @unchecked Sendable {
1717 func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } }
1818}
1919
20/// What layout fragments draw beyond the text, read by TextKit's delegate callbacks.
21final class FragmentStyle: @unchecked Sendable {
22 private let lock = NSLock()
23 private var band: (color: UIColor, width: CGFloat) = (.clear, 0)
24
25 func set(blockBackground color: UIColor, width: CGFloat) { lock.withLock { band = (color, width) } }
26 var blockBand: (color: UIColor, width: CGFloat) { lock.withLock { band } }
27}
28
2029/// A text view that offers hardware-keyboard presses to the editor's keymap first.
2130final class KeyedTextView: UITextView {
2231 var keyHandler: ((UIKey) -> Bool)?
2332 /// Keys the editor took, whose release the text system doesn't see. Optional: made by
2433 /// UIKit's `init(usingTextLayoutManager:)`, the view skips Swift's default values.
2534 private var handled: Set<UIKeyboardHIDUsage>?
35 /// Called after each layout pass, as the view's size settles.
36 var onLayout: (() -> Void)?
37 /// The text container's width while lines are truncated.
38 static let truncatedWidth: CGFloat = 100_000
39
40 /// Long lines run off the right edge and the view scrolls sideways.
41 var truncates: Bool {
42 get { !textContainer.widthTracksTextView }
43 set {
44 textContainer.widthTracksTextView = !newValue
45 if newValue {
46 textContainer.size = CGSize(width: Self.truncatedWidth, height: CGFloat.greatestFiniteMagnitude)
47 } else {
48 textContainer.size = CGSize(width: bounds.width - textContainerInset.left - textContainerInset.right, height: CGFloat.greatestFiniteMagnitude)
49 contentOffset.x = 0
50 }
51 showsHorizontalScrollIndicator = newValue
52 setNeedsLayout()
53 }
54 }
55
56 override func layoutSubviews() {
57 // The text view sets its container's width to its own when it lays out.
58 if truncates, textContainer.size.width != Self.truncatedWidth {
59 textContainer.size = CGSize(width: Self.truncatedWidth, height: CGFloat.greatestFiniteMagnitude)
60 }
61 super.layoutSubviews()
62 // The text view sizes its content to its own width; truncated lines need the widest
63 // laid-out line's.
64 if truncates, let manager = textLayoutManager {
65 let width = ceil(manager.usageBoundsForTextContainer.maxX + textContainerInset.left + textContainerInset.right)
66 if contentSize.width < width { contentSize.width = width }
67 }
68 onLayout?()
69 }
2670
2771 override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
2872 var rest: Set<UIPress> = []
@@ -55,7 +99,29 @@ final class MobileEditor: NSObject, DocumentBuffer {
5599 private(set) var document: DocumentState
56100 let textView: UITextView
57101 var options: EditingOptions = .org {
58 didSet { textView.spellCheckingType = options.spellCheck ? .yes : .no }
102 didSet {
103 textView.spellCheckingType = options.spellCheck ? .yes : .no
104 if !showMarkup, options.hideEmphasisMarkers != oldValue.hideEmphasisMarkers || options.prettyEntities != oldValue.prettyEntities {
105 restyleAll()
106 }
107 }
108 }
109 /// Colors and type; a change, or a new Dynamic Type size, restyles the file.
110 var themeSpec: ThemeSpec {
111 get { theme.spec }
112 set {
113 let new = MobileTheme(newValue)
114 guard new.spec != theme.spec || new.font != theme.font else { return }
115 theme = new
116 paragraphStyles = [:]
117 applyThemeToView()
118 restyleAll()
119 }
120 }
121 /// Link brackets and targets, and emphasis markers when `org-hide-emphasis-markers` hides
122 /// them, shown. Hidden markup shows on the caret's line, so it can be edited.
123 var showMarkup = true {
124 didSet { if showMarkup != oldValue { restyleAll() } }
59125 }
60126 var keymap = KeymapPreset.emacs.keymap
61127 var onChange: (() -> Void)?
@@ -79,19 +145,44 @@ final class MobileEditor: NSObject, DocumentBuffer {
79145 /// Tags used across the workspace, for completion.
80146 var knownTags: @Sendable () -> [String] = { [] }
81147
82 private let theme = MobileTheme()
148 private var theme: MobileTheme
83149 private(set) var fold = FoldState()
84150 private var globalVisibility: GlobalVisibility?
85151 let hidden = LockedRanges()
152 /// Blocks drawn on a band.
153 let shaded = LockedRanges()
154 let fragmentStyle = FragmentStyle()
155 /// `org-indent-mode` and `org-hide-leading-stars`, from the settings and `#+STARTUP`.
156 private var indented = true
157 private var hideStars = false
158 /// One paragraph style per indent pair.
159 private var paragraphStyles: [[Int]: NSParagraphStyle] = [:]
160 /// The line last revealed while markup is hidden, to hide again when the caret leaves.
161 private var revealedLine: NSRange?
162 /// `org-toggle-inline-images`: lines that are only an image link show the image.
163 private(set) var showsImages = false
164 /// The view width images were last sized for.
165 private var imageWidth: CGFloat = 0
166 /// The file's folder, for relative image paths.
167 let baseDirectory: URL?
168 private static let imageCache = NSCache<NSString, UIImage>()
86169 private var loading = false
87170 private var restyleScheduled: Range<Int>?
88171
89 init(document: DocumentState, editable: Bool) {
172 init(
173 document: DocumentState, editable: Bool, options: EditingOptions = .org, theme spec: ThemeSpec = .default,
174 showMarkup: Bool = true, baseDirectory: URL? = nil
175 ) {
90176 self.document = document
177 self.options = options
178 self.showMarkup = showMarkup
179 self.baseDirectory = baseDirectory
180 theme = MobileTheme(spec)
91181 let keyed = KeyedTextView(usingTextLayoutManager: true)
92182 textView = keyed
93183 super.init()
94184 keyed.keyHandler = { [weak self] key in self?.handleKey(key) ?? false }
185 keyed.onLayout = { [weak self] in self?.viewResized() }
95186 textView.isEditable = editable
96187 textView.autocorrectionType = .no
97188 textView.autocapitalizationType = .none
@@ -106,11 +197,15 @@ final class MobileEditor: NSObject, DocumentBuffer {
106197 textView.textStorage.delegate = self
107198 textView.textLayoutManager?.delegate = self
108199 (textView.textLayoutManager?.textContentManager as? NSTextContentStorage)?.delegate = self
200 let startup = Presentation.startupDisplay(document.tree, options: options)
201 indented = startup.indented
202 hideStars = startup.hideStars
203 showsImages = startup.inlineImages
204 applyThemeToView()
109205 load(document.text)
110 let tree = document.tree
111 var state = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
112 state.wrapperFolds = Presentation.startupWrapperFolds(tree)
113 setFolds(state)
206 setFolds(startup.folds)
207 if startup.alignTables { _ = perform(AlignAllTables()) }
208 truncatesLines = startup.truncated
114209 textView.selectedRange = NSRange(location: 0, length: 0)
115210 }
116211
@@ -215,6 +310,10 @@ final class MobileEditor: NSObject, DocumentBuffer {
215310 if !cycleAtCaret() { onMessage?("Not on a heading, drawer or block") }
216311 case "org.cycle-global": cycleGlobal()
217312 case "org.link.open": openLinkAtCaret()
313 case "org.toggle-inline-images": toggleImages()
314 case "editor.toggle-truncate-lines":
315 truncatesLines.toggle()
316 onMessage?(truncatesLines ? "Truncate long lines enabled" : "Truncate long lines disabled")
218317 case "edit.undo": textView.undoManager?.undo()
219318 case "edit.redo": textView.undoManager?.redo()
220319 case _ where id.hasPrefix("app."):
@@ -233,8 +332,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
233332 switch id {
234333 case "org.cycle":
235334 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
236 case "org.cycle-global", "edit.undo", "edit.redo": return true
237 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto": return onAppCommand != nil
335 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true
336 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.toggle-markup": return onAppCommand != nil
238337 case "org.link.open": return OpenLinkAtPoint().applies(in: context)
239338 default: return false
240339 }
@@ -382,10 +481,38 @@ final class MobileEditor: NSObject, DocumentBuffer {
382481 loading = true
383482 textView.textStorage.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
384483 loading = false
484 shaded.set(Presentation.shadedBlocks(document.tree), folded: [])
385485 restyle(0..<(text as NSString).length)
386486 textView.typingAttributes = theme.base
387487 }
388488
489 /// The text view's own colors, and the block band's.
490 private func applyThemeToView() {
491 textView.backgroundColor = theme.color("background") ?? .systemBackground
492 textView.tintColor = theme.color("cursor")
493 textView.typingAttributes = theme.base
494 fragmentStyle.set(blockBackground: theme.color("block-background") ?? .clear, width: textView.bounds.width)
495 }
496
497 /// The band behind blocks spans the view's width, or the widest line's when truncated;
498 /// images are as wide as the view allows.
499 private func viewResized() {
500 let band = fragmentStyle.blockBand
501 let width = max(textView.bounds.width, truncatesLines ? textView.contentSize.width : 0)
502 let resized = imageWidth != textView.bounds.width
503 guard band.width != width || resized else { return }
504 fragmentStyle.set(blockBackground: band.color, width: width)
505 imageWidth = textView.bounds.width
506 if showsImages, resized { restyle(0..<textView.textStorage.length) }
507 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
508 }
509
510 /// Styles the whole file again.
511 private func restyleAll() {
512 restyle(0..<textView.textStorage.length)
513 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
514 }
515
389516 /// Styles are set after the edit has been processed, as TextKit asks.
390517 private func scheduleRestyle(_ range: Range<Int>) {
391518 if let pending = restyleScheduled {
@@ -396,6 +523,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
396523 DispatchQueue.main.async { [weak self] in
397524 guard let self, let range = self.restyleScheduled else { return }
398525 self.restyleScheduled = nil
526 self.shaded.set(Presentation.shadedBlocks(self.document.tree), folded: [])
399527 // Small files are restyled whole: a heading or block line changes what follows it.
400528 let length = self.textView.textStorage.length
401529 self.restyle(length < 60_000 ? 0..<length : range)
@@ -404,21 +532,171 @@ final class MobileEditor: NSObject, DocumentBuffer {
404532 }
405533 }
406534
535 private var caretLine: NSRange {
536 let text = textView.textStorage.string as NSString
537 return text.lineRange(for: NSRange(location: min(textView.selectedRange.location, text.length), length: 0))
538 }
539
540 /// Sets attributes for the whole lines in `range`, as the Mac editor does.
407541 private func restyle(_ range: Range<Int>) {
408542 let storage = textView.textStorage
409543 let ns = storage.string as NSString
410544 guard ns.length > 0 else { return }
411545 let lower = min(range.lowerBound, ns.length)
412546 let lines = ns.lineRange(for: NSRange(location: lower, length: min(range.count, ns.length - lower)))
547 let whole = lines.location..<NSMaxRange(lines)
413548 storage.beginEditing()
414549 storage.setAttributes(theme.base, range: lines)
415 for run in Presentation.styles(document.tree, in: lines.location..<NSMaxRange(lines)) {
416 let clipped = NSIntersectionRange(NSRange(location: run.range.lowerBound, length: run.range.count), lines)
417 if clipped.length > 0 { theme.apply(run.role, to: storage, range: clipped) }
550 if indented {
551 for line in Presentation.lineIndents(document.tree, in: whole) {
552 storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range))
553 }
418554 }
555 let revealed = caretLine
556 var scriptExclusions: [Range<Int>] = []
557 for run in Presentation.styles(document.tree, in: whole) {
558 let clipped = NSIntersectionRange(NSRange(run.range), lines)
559 guard clipped.length > 0 else { continue }
560 if Presentation.skipsLeadingStars(run.role, indented: indented, hideStars: hideStars) { continue }
561 if !showMarkup, Presentation.hidesWithMarkup(run.role, options: options), NSIntersectionRange(clipped, revealed).length == 0 {
562 storage.addAttributes(theme.hidden, range: clipped)
563 } else {
564 theme.apply(run.role, to: storage, range: clipped)
565 }
566 if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) }
567 }
568 if !showMarkup, options.prettyEntities { prettify(whole, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
569 applyImages(whole, in: storage, revealed: revealed)
419570 storage.endEditing()
420571 }
421572
573 private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
574 if let style = paragraphStyles[[firstLine, wrapped]] { return style }
575 let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
576 paragraphStyles[[firstLine, wrapped]] = style
577 return style
578 }
579
580 /// `org-pretty-entities`: entities drawn as their character over the entity's last
581 /// character (the rest hidden), sub/superscripts raised without their markers.
582 private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) {
583 let runs = Presentation.prettyRuns(
584 storage.string as NSString, in: range, revealedLine: revealed.location, inBlock: { shaded.containing($0) != nil },
585 scriptExclusions: scriptExclusions, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces
586 )
587 for (lineStart, run) in runs {
588 switch run {
589 case .entity(let r, let display):
590 let start = lineStart + r.lowerBound
591 let last = lineStart + r.upperBound - 1
592 let color = storage.attribute(.foregroundColor, at: last, effectiveRange: nil) as? UIColor ?? theme.foreground
593 if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
594 storage.addAttributes([.foregroundColor: UIColor.clear, .orgEntityDisplay: display, .orgEntityColor: color], range: NSRange(location: last, length: 1))
595 case .script(let markers, let body, let superscript):
596 for marker in markers {
597 storage.addAttributes(theme.hidden, range: NSRange(location: lineStart + marker.lowerBound, length: marker.count))
598 }
599 let size = theme.font.pointSize
600 storage.addAttributes([
601 .font: theme.font.withSize(size * 0.7),
602 .baselineOffset: (superscript ? 0.3 : -0.3) * size,
603 ], range: NSRange(location: lineStart + body.lowerBound, length: body.count))
604 }
605 }
606 }
607
608 /// Moving to another line shows its markup and hides the last one's.
609 private func revealCaretLine() {
610 guard !showMarkup else { return }
611 let line = caretLine
612 guard line != revealedLine else { return }
613 let text = textView.textStorage.string as NSString
614 // The old line may have moved with edits since; restyle the line now at its start.
615 let old = revealedLine.flatMap { $0.location <= text.length ? text.lineRange(for: NSRange(location: $0.location, length: 0)) : nil }
616 revealedLine = line
617 for range in [old, line].compactMap({ $0 }) {
618 restyle(range.location..<NSMaxRange(range))
619 }
620 }
621
622 // MARK: - Inline images
623
624 private func toggleImages() {
625 showsImages.toggle()
626 restyleAll()
627 if showsImages {
628 let ns = textView.textStorage.string as NSString
629 var count = 0
630 var offset = 0
631 while offset < ns.length {
632 let line = ns.lineRange(for: NSRange(location: offset, length: 0))
633 if let image = InlineImages.image(atLine: line.location, in: ns), loadImage(image.path) != nil { count += 1 }
634 offset = NSMaxRange(line)
635 }
636 onMessage?("\(count) images displayed inline")
637 } else {
638 onMessage?("Inline image display turned off")
639 }
640 }
641
642 private func loadImage(_ path: String) -> UIImage? {
643 let expanded = (path as NSString).expandingTildeInPath
644 let url: URL
645 if expanded.hasPrefix("/") {
646 url = URL(fileURLWithPath: expanded)
647 } else if let baseDirectory {
648 url = baseDirectory.appendingPathComponent(expanded)
649 } else {
650 return nil
651 }
652 let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate?.timeIntervalSince1970 ?? 0
653 let key = "\(url.path)|\(modified)" as NSString
654 if let cached = Self.imageCache.object(forKey: key) { return cached }
655 guard let image = UIImage(contentsOfFile: url.path) else { return nil }
656 Self.imageCache.setObject(image, forKey: key)
657 return image
658 }
659
660 /// Image lines in `range` show the image over the link, which is hidden unless the caret is
661 /// on the line.
662 private func applyImages(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange) {
663 guard showsImages else { return }
664 let text = storage.string as NSString
665 let inset = textView.textContainerInset
666 let maxWidth = max(80, textView.bounds.width - inset.left - inset.right - 40)
667 var location = range.lowerBound
668 while location < range.upperBound, location < text.length {
669 let line = text.lineRange(for: NSRange(location: location, length: 0))
670 location = NSMaxRange(line)
671 guard let image = InlineImages.image(atLine: line.location, in: text), let picture = loadImage(image.path) else { continue }
672 let width = min(image.width.map(CGFloat.init) ?? picture.size.width, maxWidth)
673 let height = picture.size.width > 0 ? width * picture.size.height / picture.size.width : 0
674 let content = NSRange(location: line.location, length: line.length - (text.substring(with: line).hasSuffix("\n") ? 1 : 0))
675 // Invisible, keeping the line's height.
676 if line.location != revealed.location, content.length > 0 {
677 storage.addAttributes([.foregroundColor: UIColor.clear, .underlineStyle: 0], range: content)
678 }
679 storage.addAttribute(.orgImage, value: InlineImageBox(image: picture, size: CGSize(width: width, height: height)), range: NSRange(location: line.location, length: max(1, content.length)))
680 let style = (storage.attribute(.paragraphStyle, at: line.location, effectiveRange: nil) as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
681 // The image sits where the line is; the spacing makes room for the rest of it.
682 style.paragraphSpacing = max(0, height + 4 - theme.font.lineHeight)
683 storage.addAttribute(.paragraphStyle, value: style, range: line)
684 }
685 }
686
687 // MARK: - Truncation
688
689 /// `toggle-truncate-lines`: long lines run off the right edge, scrolling sideways, instead
690 /// of wrapping.
691 var truncatesLines: Bool {
692 get { (textView as? KeyedTextView)?.truncates ?? false }
693 set {
694 guard newValue != truncatesLines, let view = textView as? KeyedTextView else { return }
695 view.truncates = newValue
696 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) }
697 }
698 }
699
422700 // MARK: - Folding
423701
424702 func setFolds(_ state: FoldState) {
@@ -478,6 +756,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
478756 fileprivate func selectionChanged() {
479757 let caret = textView.selectedRange.location
480758 if hidden.containing(caret) != nil { unfold(around: caret) }
759 revealCaretLine()
481760 }
482761}
483762
@@ -530,36 +809,124 @@ extension MobileEditor: NSTextContentStorageDelegate {
530809}
531810
532811extension MobileEditor: NSTextLayoutManagerDelegate {
533 /// Folded headings, drawers and blocks end with `…`.
812 /// Folded headings, drawers and blocks end with `…`; blocks are on a band; image lines
813 /// show their image; pretty entities draw their character.
534814 nonisolated func textLayoutManager(
535815 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
536816 ) -> NSTextLayoutFragment {
537817 let range = textElement.elementRange
538 if let range, let manager = textLayoutManager.textContentManager {
539 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
540 if hidden.isFolded(offset) { return FoldedLineFragment(textElement: textElement, range: range) }
541 }
542 return NSTextLayoutFragment(textElement: textElement, range: range)
818 guard let range, let manager = textLayoutManager.textContentManager else { return NSTextLayoutFragment(textElement: textElement, range: range) }
819 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
820 let folded = hidden.isFolded(offset)
821 let band = shaded.containing(offset) != nil ? fragmentStyle.blockBand : nil
822 let string = (textElement as? NSTextParagraph)?.attributedString
823 let image = string.flatMap(OrgLineFragment.image)
824 let entities = string.map(OrgLineFragment.hasEntities) ?? false
825 guard folded || band != nil || image != nil || entities else { return NSTextLayoutFragment(textElement: textElement, range: range) }
826 let fragment = OrgLineFragment(textElement: textElement, range: range)
827 fragment.folded = folded
828 fragment.band = band
829 fragment.image = image
830 return fragment
543831 }
544832}
545833
546/// A folded line drawn with an ellipsis after it, like org's folded headings.
547final class FoldedLineFragment: NSTextLayoutFragment, @unchecked Sendable {
834extension NSAttributedString.Key {
835 /// The character an entity shows as, on the entity's last character.
836 static let orgEntityDisplay = NSAttributedString.Key("OrgEntityDisplay")
837 /// The color the entity's character is drawn in.
838 static let orgEntityColor = NSAttributedString.Key("OrgEntityColor")
839 /// The image an image link's line shows under it.
840 static let orgImage = NSAttributedString.Key("OrgImage")
841}
842
843final class InlineImageBox: NSObject, @unchecked Sendable {
844 let image: UIImage
845 let size: CGSize
846 init(image: UIImage, size: CGSize) {
847 self.image = image
848 self.size = size
849 }
850}
851
852/// A line drawn with more than its text, as the Mac editor's fragments draw them: a folded
853/// line's ellipsis, a block's band across the view, an image under an image link, and pretty
854/// entities' characters where the entities' last (invisible) characters sit.
855final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
856 var folded = false
857 var band: (color: UIColor, width: CGFloat)?
858 var image: InlineImageBox?
859
860 static func image(_ string: NSAttributedString) -> InlineImageBox? {
861 var found: InlineImageBox?
862 string.enumerateAttribute(.orgImage, in: NSRange(location: 0, length: string.length)) { value, _, stop in
863 if let box = value as? InlineImageBox { found = box; stop.pointee = true }
864 }
865 return found
866 }
867
868 static func hasEntities(_ string: NSAttributedString) -> Bool {
869 var found = false
870 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, _, stop in
871 if value != nil { found = true; stop.pointee = true }
872 }
873 return found
874 }
875
876 private var bandRect: CGRect? {
877 guard let band else { return nil }
878 // Not under the empty line TextKit adds after a final newline.
879 let height = textLineFragments.last { $0.characterRange.length > 0 }?.typographicBounds.maxY ?? layoutFragmentFrame.height
880 return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: max(band.width, layoutFragmentFrame.maxX), height: height)
881 }
882
883 private var imageRect: CGRect? {
884 guard let image, let line = textLineFragments.first else { return nil }
885 return CGRect(x: line.typographicBounds.minX, y: line.typographicBounds.minY + 2, width: image.size.width, height: image.size.height)
886 }
887
888 override var renderingSurfaceBounds: CGRect {
889 var bounds = super.renderingSurfaceBounds
890 if folded { bounds = bounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0) }
891 for rect in [bandRect, imageRect].compactMap({ $0 }) { bounds = bounds.union(rect) }
892 return bounds
893 }
894
548895 override func draw(at point: CGPoint, in context: CGContext) {
896 if let band, let rect = bandRect {
897 context.saveGState()
898 context.setFillColor(band.color.cgColor)
899 context.fill(rect.offsetBy(dx: point.x, dy: point.y))
900 context.restoreGState()
901 }
549902 super.draw(at: point, in: context)
550 guard let line = textLineFragments.first(where: { $0.characterRange.length > 0 }) ?? textLineFragments.first else { return }
551 let bounds = line.typographicBounds
552 let ellipsis = NSAttributedString(string: " …", attributes: [
553 .font: UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular),
554 .foregroundColor: UIColor.secondaryLabel,
555 ])
556903 UIGraphicsPushContext(context)
557 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
558 UIGraphicsPopContext()
904 defer { UIGraphicsPopContext() }
905 drawEntities(at: point)
906 if let image, let rect = imageRect { image.image.draw(in: rect.offsetBy(dx: point.x, dy: point.y)) }
907 if folded, let line = textLineFragments.first(where: { $0.characterRange.length > 0 }) ?? textLineFragments.first {
908 let bounds = line.typographicBounds
909 let ellipsis = NSAttributedString(string: " …", attributes: [
910 .font: UIFont.monospacedSystemFont(ofSize: UIFont.systemFontSize, weight: .regular),
911 .foregroundColor: UIColor.secondaryLabel,
912 ])
913 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
914 }
559915 }
560916
561 override var renderingSurfaceBounds: CGRect {
562 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0)
917 private func drawEntities(at point: CGPoint) {
918 guard let string = (textElement as? NSTextParagraph)?.attributedString else { return }
919 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in
920 guard let display = value as? String,
921 let line = textLineFragments.first(where: { NSLocationInRange(range.location, $0.characterRange) }) else { return }
922 var attributes = string.attributes(at: range.location, effectiveRange: nil)
923 attributes[.foregroundColor] = attributes[.orgEntityColor] ?? UIColor.label
924 attributes[.orgEntityDisplay] = nil
925 attributes[.orgEntityColor] = nil
926 let x = line.locationForCharacter(at: range.location).x
927 let bounds = line.typographicBounds
928 NSAttributedString(string: display, attributes: attributes).draw(at: CGPoint(x: point.x + bounds.minX + x, y: point.y + bounds.minY))
929 }
563930 }
564931}
565932#endif
Sources/OrgstarMobile/MobileModel.swift +41 −11
@@ -4,7 +4,9 @@ import Observation
44import OrgApp
55import OrgCore
66import OrgIndex
7import OrgPresentation
78import OrgWorkspace
9import UIKit
810
911/// The app's models, and the settings from the configuration folder: a folder you choose
1012/// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`.
@@ -36,7 +38,13 @@ final class MobileModel {
3638 }
3739 /// Bumped when settings apply, so views that read them update.
3840 private(set) var settingsVersion = 0
41 /// The theme from `config.toml` and its theme file, with the type settings.
42 private(set) var theme = ThemeSpec.default
43 /// `show-markup`, toggled in the editor; the folder's value applies when it changes.
44 var showMarkup = false
45 @ObservationIgnored private var configShowMarkup: Bool?
3946 @ObservationIgnored private var presenter: FolderPresenter?
47 @ObservationIgnored private var sizeObserver: NSObjectProtocol?
4048
4149 private static let bookmarkKey = "configFolderBookmark"
4250
@@ -44,7 +52,7 @@ final class MobileModel {
4452 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder.
4553 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) }
4654 UserConfig.directoryOverride = folder
47 let problems = Self.readConfig(folder)
55 let (problems, colors) = Self.readConfig(folder)
4856 let settings = OrgPreferences.orgSettings()
4957 do {
5058 let workspace = try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: settings))
@@ -58,9 +66,13 @@ final class MobileModel {
5866 }
5967 configFolder = folder
6068 configProblems = problems
61 applySettings()
69 applySettings(colors)
6270 shareTemplates()
6371 watchConfig()
72 // A new Dynamic Type size restyles editors and the reader.
73 sizeObserver = NotificationCenter.default.addObserver(forName: UIContentSizeCategory.didChangeNotification, object: nil, queue: .main) { [weak self] _ in
74 MainActor.assumeIsolated { self?.settingsVersion += 1 }
75 }
6476 }
6577
6678 /// Chooses the configuration folder, or with nil stops using one; its settings apply now.
@@ -80,12 +92,19 @@ final class MobileModel {
8092
8193 func reloadConfig() {
8294 UserConfig.directoryOverride = configFolder
83 configProblems = Self.readConfig(configFolder)
84 applySettings()
95 let (problems, colors) = Self.readConfig(configFolder)
96 configProblems = problems
97 applySettings(colors)
8598 shareTemplates()
8699 }
87100
88 private func applySettings() {
101 private func applySettings(_ colors: ThemeSpec) {
102 theme = OrgPreferences.theme(colors)
103 let markup = OrgPreferences.showMarkup()
104 if markup != configShowMarkup {
105 configShowMarkup = markup
106 showMarkup = markup
107 }
89108 let settings = OrgPreferences.orgSettings()
90109 session.defaults = settings
91110 session.options = OrgPreferences.options()
@@ -124,17 +143,28 @@ final class MobileModel {
124143 return url
125144 }
126145
127 /// Applies the folder's `config.toml` to the settings; returns its problems.
128 private static func readConfig(_ folder: URL?) -> [String] {
129 guard let folder else { return [] }
130 let file = folder.appendingPathComponent("config.toml")
146 /// Applies the folder's `config.toml` to the settings; returns its problems and the theme's
147 /// colors.
148 private static func readConfig(_ folder: URL?) -> ([String], ThemeSpec) {
149 guard let folder else { return ([], .default) }
150 guard let text = read(folder.appendingPathComponent("config.toml")) else {
151 return (["No config.toml in \(folder.lastPathComponent); the defaults apply."], .default)
152 }
153 let problems = UserConfig.apply(text, to: .standard)
154 guard let theme = UserConfig.theme(config: text, themeFile: UserDefaults.standard.string(forKey: "themeFile"), in: folder, read: read) else {
155 return (problems, .default)
156 }
157 return (problems + theme.problems, theme.theme)
158 }
159
160 /// A file of the folder, read in coordination with the app that syncs it.
161 private static func read(_ file: URL) -> String? {
131162 var text: String?
132163 var error: NSError?
133164 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: file, options: [], error: &error) { url in
134165 text = try? String(contentsOf: url, encoding: .utf8)
135166 }
136 guard let text else { return ["No config.toml in \(folder.lastPathComponent); the defaults apply."] }
137 return UserConfig.apply(text, to: .standard)
167 return text
138168 }
139169
140170 /// Notifications for the next week's timed entries, at most 64, as iOS allows.
Sources/OrgstarMobile/MobileTheme.swift +54 −15
@@ -1,5 +1,6 @@
11#if os(iOS)
22import OrgPresentation
3import SwiftUI
34import UIKit
45
56/// Attributes for each style role, from a `ThemeSpec`, as the Mac editor's theme sets them.
@@ -10,12 +11,17 @@ struct MobileTheme {
1011
1112 init(_ spec: ThemeSpec = .default) {
1213 self.spec = spec
13 // Dynamic Type, monospaced as on the Mac.
14 font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .monospacedSystemFont(ofSize: 15, weight: .regular))
14 let size = CGFloat(max(6, spec.fontSize))
15 // The theme's family when the device has it, else the monospaced system font; at the
16 // theme's size for the default Dynamic Type size, scaled for others.
17 let chosen = spec.font.isEmpty || UIFont.fontNames(forFamilyName: spec.font).isEmpty ? nil
18 : UIFont(descriptor: UIFontDescriptor(fontAttributes: [.family: spec.font]), size: size)
19 font = UIFontMetrics(forTextStyle: .body).scaledFont(for: chosen ?? .monospacedSystemFont(ofSize: size, weight: .regular))
1520 }
1621
1722 /// A color that follows the appearance, or nil when the theme has none for `key`.
1823 func color(_ key: String) -> UIColor? {
24 // As on the Mac: a key the theme has for one appearance only takes the default for the other.
1925 let light = spec.light[key] ?? ThemeSpec.default.light[key] ?? spec.dark[key]
2026 let dark = spec.dark[key] ?? ThemeSpec.default.dark[key] ?? spec.light[key]
2127 guard let light, let dark else { return nil }
@@ -29,10 +35,45 @@ struct MobileTheme {
2935 }
3036 }
3137
38 var foreground: UIColor { color("foreground") ?? .label }
39
40 /// A TODO keyword's color: its own from `[theme.todo]`, else the theme's TODO or DONE.
41 func keywordColor(_ keyword: String, done: Bool) -> UIColor? {
42 if let custom = spec.todo[keyword] { return Self.dynamic(custom, custom) }
43 return color(done ? "done" : "todo")
44 }
45
46 /// A heading's font: larger by the heading step for levels 1 to 3, bold.
47 func headingFont(_ level: Int) -> UIFont {
48 let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level))
49 return Self.adding(.traitBold, to: font.withSize(size))
50 }
51
52 static func adding(_ traits: UIFontDescriptor.SymbolicTraits, to font: UIFont) -> UIFont {
53 guard let descriptor = font.fontDescriptor.withSymbolicTraits(font.fontDescriptor.symbolicTraits.union(traits)) else { return font }
54 return UIFont(descriptor: descriptor, size: font.pointSize)
55 }
56
3257 var base: [NSAttributedString.Key: Any] {
58 [.font: font, .foregroundColor: foreground, .paragraphStyle: paragraphStyle(firstLine: 0, wrapped: 0)]
59 }
60
61 /// Markup hidden from view: no width, no color.
62 var hidden: [NSAttributedString.Key: Any] {
63 [.font: UIFont.monospacedSystemFont(ofSize: 0.01, weight: .regular), .foregroundColor: UIColor.clear]
64 }
65
66 /// Width of one column of the font.
67 var column: CGFloat {
68 ("0" as NSString).size(withAttributes: [.font: font]).width
69 }
70
71 func paragraphStyle(firstLine: Int, wrapped: Int) -> NSParagraphStyle {
3372 let style = NSMutableParagraphStyle()
73 style.firstLineHeadIndent = CGFloat(firstLine) * column
74 style.headIndent = CGFloat(wrapped) * column
3475 style.lineSpacing = CGFloat(max(0, spec.lineSpacing))
35 return [.font: font, .foregroundColor: color("foreground") ?? .label, .paragraphStyle: style]
76 return style
3677 }
3778
3879 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
@@ -41,25 +82,18 @@ struct MobileTheme {
4182 }
4283 func traits(_ add: UIFontDescriptor.SymbolicTraits) {
4384 let current = storage.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont ?? font
44 if let descriptor = current.fontDescriptor.withSymbolicTraits(current.fontDescriptor.symbolicTraits.union(add)) {
45 storage.addAttribute(.font, value: UIFont(descriptor: descriptor, size: current.pointSize), range: range)
46 }
85 storage.addAttribute(.font, value: Self.adding(add, to: current), range: range)
4786 }
4887 switch role {
4988 case .heading(let level):
50 let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level))
51 storage.addAttribute(.font, value: font.withSize(size), range: range)
52 traits(.traitBold)
89 storage.addAttribute(.font, value: headingFont(level), range: range)
5390 fg("heading-\(min(max(level, 1), 8))")
54 case .leadingStars: fg("markup")
91 case .leadingStars:
92 storage.addAttribute(.foregroundColor, value: UIColor.clear, range: range)
5593 case .todo, .done:
5694 traits(.traitBold)
5795 let keyword = (storage.string as NSString).substring(with: range)
58 if let custom = spec.todo[keyword] {
59 storage.addAttribute(.foregroundColor, value: Self.dynamic(custom, custom), range: range)
60 } else {
61 fg(role == .todo ? "todo" : "done")
62 }
96 if let color = keywordColor(keyword, done: role == .done) { storage.addAttribute(.foregroundColor, value: color, range: range) }
6397 case .priority: fg("priority")
6498 case .tags: fg("tags")
6599 case .comment: fg("comment")
@@ -84,5 +118,10 @@ struct MobileTheme {
84118 case .syntax: break
85119 }
86120 }
121
122 // MARK: - SwiftUI
123
124 /// A theme color for SwiftUI views.
125 func swiftColor(_ key: String) -> Color? { color(key).map { Color(uiColor: $0) } }
87126}
88127#endif
Sources/OrgstarMobile/ReaderScreen.swift +62 −31
@@ -4,6 +4,7 @@ import OrgCore
44import OrgIndex
55import OrgPresentation
66import SwiftUI
7import UIKit
78
89/// What to read: a file, and where in it.
910enum ReaderRoute: Hashable {
@@ -31,23 +32,27 @@ struct ReaderScreen: View {
3132 @State private var showingOutline = false
3233 @State private var message: String?
3334 @Environment(\.openURL) private var openURL
35 @Environment(MobileModel.self) private var model
3436
3537 private var outline: [OutlineEntry] { session.outline }
3638
3739 var body: some View {
3840 let _ = session.editVersion
41 // Read again when settings or the Dynamic Type size change.
42 let _ = model.settingsVersion
43 let theme = MobileTheme(model.theme)
3944 ScrollViewReader { proxy in
4045 ScrollView {
4146 LazyVStack(alignment: .leading, spacing: 8) {
4247 if let tree = session.document?.tree {
4348 let preambleEnd = outline.first?.start ?? (tree.text as NSString).length
44 if preambleEnd > 0 { textView(tree, 0..<preambleEnd) }
49 if preambleEnd > 0 { textView(tree, 0..<preambleEnd, theme) }
4550 ForEach(Array(outline.enumerated()), id: \.element.start) { index, entry in
4651 if isShown(index) {
47 heading(tree, entry).id(entry.start)
52 heading(tree, entry, theme).id(entry.start)
4853 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) {
4954 let body = bodyRange(index, tree)
50 if !body.isEmpty { textView(tree, body).padding(.leading, indent(entry)) }
55 if !body.isEmpty { textView(tree, body, theme).padding(.leading, indent(entry)) }
5156 }
5257 }
5358 }
@@ -56,6 +61,7 @@ struct ReaderScreen: View {
5661 .padding()
5762 .frame(maxWidth: .infinity, alignment: .leading)
5863 }
64 .background(theme.swiftColor("background") ?? Color(uiColor: .systemBackground))
5965 .onChange(of: session.revealRequest?.id) {
6066 guard let offset = session.revealRequest?.offset else { return }
6167 reveal(offset, proxy)
@@ -108,19 +114,20 @@ struct ReaderScreen: View {
108114 return min(lineEnd, firstChild)..<firstChild
109115 }
110116
111 private func heading(_ tree: OrgTree, _ entry: OutlineEntry) -> some View {
117 private func heading(_ tree: OrgTree, _ entry: OutlineEntry, _ theme: MobileTheme) -> some View {
112118 let ns = tree.text as NSString
113119 var line = ns.lineRange(for: NSRange(location: entry.start, length: 0))
114120 let title = min(entry.start + entry.level + 1, NSMaxRange(line))
115121 line.length = NSMaxRange(line) - title
116 let text = attributed(ReadingText.segments(tree, in: title..<(title + line.length)), heading: entry.level)
122 let text = attributed(ReadingText.segments(tree, in: title..<(title + line.length)), heading: entry.level, theme)
117123 let hasContent = entry.end > NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
118124 let foldedMark = fold.folds.contains(entry.start) && hasContent
119125 return HStack(alignment: .firstTextBaseline, spacing: 6) {
120126 Image(systemName: foldedMark ? "chevron.right" : "chevron.down")
121 .font(.caption.weight(.semibold)).foregroundStyle(.tertiary)
127 .font(.caption.weight(.semibold)).foregroundStyle(theme.swiftColor("markup") ?? Color(uiColor: .tertiaryLabel))
122128 .opacity(hasContent ? 1 : 0)
123 Text(text.trimmingTrailingNewlines()).frame(maxWidth: .infinity, alignment: .leading)
129 Text(text.trimmingTrailingNewlines()).lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
130 .frame(maxWidth: .infinity, alignment: .leading)
124131 }
125132 .padding(.leading, indent(entry))
126133 .padding(.top, entry.level == 1 ? 6 : 0)
@@ -130,50 +137,72 @@ struct ReaderScreen: View {
130137 .accessibilityHint(foldedMark ? "Unfolds the heading" : "Folds the heading")
131138 }
132139
133 private func textView(_ tree: OrgTree, _ range: Range<Int>) -> some View {
134 Text(attributed(ReadingText.segments(tree, in: range, folded: folded), heading: nil).trimmingTrailingNewlines())
140 private func textView(_ tree: OrgTree, _ range: Range<Int>, _ theme: MobileTheme) -> some View {
141 Text(attributed(ReadingText.segments(tree, in: range, folded: folded), heading: nil, theme).trimmingTrailingNewlines())
142 .lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
135143 .textSelection(.enabled)
136144 .frame(maxWidth: .infinity, alignment: .leading)
137145 }
138146
139 /// Segments as styled text; links get `orgstar-link:` URLs to follow.
140 private func attributed(_ segments: [ReadingText.Segment], heading level: Int?) -> AttributedString {
147 /// Segments as styled text in the theme's font and colors, as the editor styles them;
148 /// links get `orgstar-link:` URLs to follow.
149 private func attributed(_ segments: [ReadingText.Segment], heading level: Int?, _ theme: MobileTheme) -> AttributedString {
141150 var result = AttributedString()
142151 for segment in segments {
143152 var part = AttributedString(segment.text)
144 if let level {
145 part.font = level == 1 ? .title3.weight(.semibold) : level == 2 ? .headline : .body.weight(.semibold)
146 }
147 for role in segment.roles {
153 var font = level.map(theme.headingFont) ?? theme.font
154 var color = level.flatMap { theme.color("heading-\(min(max($0, 1), 8))") } ?? theme.foreground
155 func fg(_ key: String) { if let c = theme.color(key) { color = c } }
156 // Outer roles first, so inner ones win.
157 for role in segment.roles.sorted(by: { Self.depth($0) < Self.depth($1) }) {
148158 switch role {
149 case .todo: part.foregroundColor = .red; part.font = (part.font ?? .body).weight(.semibold)
150 case .done: part.foregroundColor = .green
151 case .priority: part.foregroundColor = .orange
152 case .tags: part.foregroundColor = .secondary; part.font = .caption
153 case .bold: part.inlinePresentationIntent = .stronglyEmphasized
154 case .italic: part.inlinePresentationIntent = .emphasized
159 case .todo, .done:
160 font = MobileTheme.adding(.traitBold, to: font)
161 if let c = theme.keywordColor(segment.text, done: role == .done) { color = c }
162 case .priority: fg("priority")
163 case .tags: fg("tags")
164 case .bold: font = MobileTheme.adding(.traitBold, to: font)
165 case .italic: font = MobileTheme.adding(.traitItalic, to: font)
155166 case .underline: part.underlineStyle = .single
156167 case .strikeThrough: part.strikethroughStyle = .single
157 case .verbatim, .code, .inlineSource: part.font = .body.monospaced()
158 case .markup, .leadingStars: part.foregroundColor = .secondary
159 case .timestamp: part.foregroundColor = .purple
160 case .metadata, .keyword, .comment, .blockDelimiter:
161 part.foregroundColor = .secondary
162 part.font = .footnote.monospaced()
163 case .block, .table, .syntax: part.font = .callout.monospaced()
164 default: break
168 case .verbatim, .code, .inlineSource:
169 if let background = theme.color("inline-background") { part.backgroundColor = Color(uiColor: background) }
170 fg(role == .verbatim ? "verbatim" : "code")
171 case .markup, .linkMarkup, .leadingStars: fg("markup")
172 case .timestamp: fg("timestamp")
173 case .comment: fg("comment")
174 case .keyword: fg("keyword")
175 case .metadata: fg("metadata")
176 case .blockDelimiter: fg("block-delimiter")
177 case .footnote, .cookie, .target, .macro, .latex: fg("special")
178 case .table, .rule: fg("table")
179 case .link:
180 part.underlineStyle = .single
181 fg("link")
182 case .heading, .block, .syntax: break
165183 }
166184 }
167185 if segment.link != nil {
168186 // Found again by position when followed.
169187 part.link = URL(string: "orgstar-link:\(segment.offset)")
170 part.foregroundColor = .accentColor
171188 }
189 part.font = Font(font as CTFont)
190 part.foregroundColor = Color(uiColor: color)
172191 result += part
173192 }
174193 return result
175194 }
176195
196 /// How deep a role's text usually nests: elements, then objects, then their parts.
197 private static func depth(_ role: StyleRole) -> Int {
198 switch role {
199 case .heading: 0
200 case .metadata, .keyword, .comment, .block, .table, .rule: 1
201 case .blockDelimiter, .link, .bold, .italic, .underline, .strikeThrough, .timestamp, .footnote, .target, .macro, .latex: 2
202 default: 3
203 }
204 }
205
177206 // MARK: - Actions
178207
179208 private func open() {
@@ -190,7 +219,9 @@ struct ReaderScreen: View {
190219 /// The file's startup visibility, kept while the same file is open.
191220 private func loadFolds() {
192221 guard let tree = session.document?.tree else { return }
193 folded = Presentation.startupWrappers(tree).map(\.hidden)
222 folded = Presentation.startupWrappers(
223 tree, hideDrawers: session.options.hideDrawerStartup, hideBlocks: session.options.hideBlockStartup
224 ).map(\.hidden)
194225 guard loadedPath != session.path else { return }
195226 loadedPath = session.path
196227 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
Sources/OrgstarMobile/SettingsScreen.swift +11
@@ -2,6 +2,7 @@
22import OrgApp
33import OrgCore
44import SwiftUI
5import UIKit
56
67/// Where settings come from: a folder with the Mac's `config.toml` and `capture.toml`, kept in
78/// sync by iCloud Drive or another app.
@@ -29,6 +30,8 @@ struct SettingsScreen: View {
2930 LabeledContent("Agenda", value: agenda)
3031 LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off")
3132 LabeledContent("Capture templates", value: CaptureTemplates.load().templates.map(\.key).joined(separator: " "))
33 LabeledContent("Theme", value: UserDefaults.standard.string(forKey: "themeFile").flatMap { $0.isEmpty ? nil : $0 } ?? "Default")
34 LabeledContent("Font", value: font)
3235 }
3336 }
3437 .navigationTitle("Settings")
@@ -45,6 +48,14 @@ struct SettingsScreen: View {
4548 return "\(days(OrgPreferences.agendaSpan())), starting \(start)"
4649 }
4750
51 /// The theme's family, or the system's monospaced font when it's unset or not on the device.
52 private var font: String {
53 let spec = model.theme
54 let family = spec.font.isEmpty || UIFont.fontNames(forFamilyName: spec.font).isEmpty ? "System monospaced" : spec.font
55 let missing = !spec.font.isEmpty && family != spec.font ? " (\(spec.font) isn't installed)" : ""
56 return "\(family), \(spec.fontSize) pt\(missing)"
57 }
58
4859 private var todoKeywords: String {
4960 OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") }
5061 .joined(separator: "; ")
Tests/OrgAppTests/OrgPreferencesTests.swift +15
@@ -1,5 +1,6 @@
11import Foundation
22import OrgCore
3import OrgPresentation
34import Testing
45@testable import OrgApp
56
@@ -36,4 +37,18 @@ struct OrgPreferencesTests {
3637 #expect(OrgPreferences.agendaSpan(defaults) == 7 && OrgPreferences.agendaStartOffset(defaults) == -1)
3738 #expect(!OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 30)
3839 }
40
41 @Test func themeTypeAndShowMarkup() throws {
42 let defaults = try #require(UserDefaults(suiteName: "orgstar-preferences-\(UUID().uuidString)"))
43 var colors = ThemeSpec.default
44 colors.light["link"] = RGBA(hex: "#000001")
45 let fallback = OrgPreferences.theme(colors, defaults)
46 #expect(fallback.font == "" && fallback.fontSize == 13 && fallback.lineSpacing == 2 && fallback.headingSizeStep == 1)
47 #expect(fallback.light["link"]?.hex == "#000001")
48 #expect(!OrgPreferences.showMarkup(defaults))
49 _ = UserConfig.apply("[theme]\nfont = \"Menlo\"\nfont-size = 16\nline-spacing = 4\nheading-size-step = 2\n[orgstar]\nshow-markup = true\n", to: defaults)
50 let spec = OrgPreferences.theme(colors, defaults)
51 #expect(spec.font == "Menlo" && spec.fontSize == 16 && spec.lineSpacing == 4 && spec.headingSizeStep == 2)
52 #expect(OrgPreferences.showMarkup(defaults))
53 }
3954}
Tests/OrgAppTests/UserConfigTests.swift +36
@@ -151,3 +151,39 @@ struct ThemeConfigTests {
151151 #expect(!sync.isThemeFile(folder.appendingPathComponent("x.toml")))
152152 }
153153}
154
155struct ThemeReadingTests {
156 let folder = URL(fileURLWithPath: "/config")
157
158 @Test func configColorsOverThemeFileOverDefault() throws {
159 var read: [String] = []
160 let config = "[theme]\ntheme-file = \"themes/mine.toml\"\nlink = \"#000003\"\n[theme.dark]\nbackground = \"#000004\"\n"
161 let result = try #require(UserConfig.theme(config: config, themeFile: "themes/mine.toml", in: folder) { url in
162 read.append(url.path)
163 return "[theme.light]\nlink = \"#000001\"\ntodo = \"#000002\"\n[theme.todo]\nWAIT = \"#000005\"\n"
164 })
165 #expect(read == ["/config/themes/mine.toml"])
166 #expect(result.problems.isEmpty, "\(result.problems)")
167 #expect(result.theme.light["link"]?.hex == "#000003" && result.theme.dark["link"]?.hex == "#000003")
168 #expect(result.theme.light["todo"]?.hex == "#000002")
169 #expect(result.theme.dark["todo"] == ThemeSpec.default.dark["todo"])
170 #expect(result.theme.dark["background"]?.hex == "#000004" && result.theme.light["background"] == ThemeSpec.default.light["background"])
171 #expect(result.theme.todo["WAIT"]?.hex == "#000005")
172 }
173
174 @Test func noThemeFileIsTheDefaultWithConfigColors() throws {
175 let result = try #require(UserConfig.theme(config: "[theme]\nfont-size = 15\n", themeFile: "", in: folder) { _ in
176 Issue.record("read a theme file")
177 return nil
178 })
179 #expect(result.theme == ThemeSpec.default && result.problems.isEmpty)
180 }
181
182 @Test func problems() throws {
183 let missing = try #require(UserConfig.theme(config: "", themeFile: "gone.toml", in: folder) { _ in nil })
184 #expect(missing.problems == ["config.toml: theme-file gone.toml can't be read"])
185 let bad = try #require(UserConfig.theme(config: "[theme]\nlink = \"blue\"\n", themeFile: "t.toml", in: folder) { _ in "[theme]\nnope = \"#000000\"\n" })
186 #expect(bad.problems == ["t.toml: unknown theme color nope", "config.toml: theme.link must be a color such as \"#1f2328\""])
187 #expect(UserConfig.theme(config: "[theme", themeFile: nil, in: folder)?.theme == nil)
188 }
189}
Tests/OrgEditorAppKitTests/EditorTests.swift −10
@@ -431,16 +431,6 @@ struct InitialStylingTests {
431431 }
432432}
433433
434struct HangingIndentTests {
435 @Test(arguments: [
436 ("plain", 0), (" indented", 2), ("- item", 2), (" - [X] done", 8), ("12. step", 4),
437 ("* heading-like", 0), (" * starred item", 4), ("-not a bullet", 0), ("\t- tab", 10),
438 ])
439 func columns(line: String, expected: Int) {
440 #expect(OrgEditor.hangingColumns(line) == expected)
441 }
442}
443
444434@MainActor
445435struct CommandTests {
446436 @Test func performEditsThroughTheTextView() {
Tests/OrgPresentationTests/DisplayTests.swift added +106
@@ -0,0 +1,106 @@
1import Foundation
2import OrgCore
3import Testing
4@testable import OrgPresentation
5
6struct StartupDisplayTests {
7 @Test func settingsApplyWithoutStartupWords() {
8 let tree = OrgParser.parse("* A\n:LOGBOOK:\nx\n:END:\n#+begin_src sh\necho\n#+end_src\n")
9 var options = EditingOptions.org
10 options.startupIndented = false
11 options.hideLeadingStars = true
12 options.startupWithInlineImages = true
13 options.startupAlignAllTables = true
14 options.startupTruncated = true
15 options.hideBlockStartup = true
16 let display = Presentation.startupDisplay(tree, options: options)
17 #expect(!display.indented)
18 #expect(display.hideStars)
19 #expect(display.inlineImages)
20 #expect(display.alignTables)
21 #expect(display.truncated)
22 #expect(display.wrappers.map(\.kind) == [.drawer, .block])
23 #expect(display.folds.wrapperFolds == Set(display.wrappers.map(\.start)))
24 }
25
26 @Test func startupWordsOverrideSettings() {
27 let tree = OrgParser.parse("#+STARTUP: noindent showstars noinlineimages noalign nohidedrawers overview\n* A\n:LOGBOOK:\nx\n:END:\n** B\n")
28 var options = EditingOptions.org
29 options.hideLeadingStars = true
30 options.startupWithInlineImages = true
31 options.startupAlignAllTables = true
32 let display = Presentation.startupDisplay(tree, options: options)
33 #expect(!display.indented)
34 #expect(!display.hideStars)
35 #expect(!display.inlineImages)
36 #expect(!display.alignTables)
37 #expect(display.wrappers.isEmpty)
38 #expect(display.folds.folds == [tree.text.utf16.count - "* A\n:LOGBOOK:\nx\n:END:\n** B\n".utf16.count])
39 }
40
41 @Test func startupWordsTurnSettingsOn() {
42 let tree = OrgParser.parse("#+STARTUP: indent hidestars inlineimages align hideblocks\n#+begin_quote\nq\n#+end_quote\n")
43 var options = EditingOptions.org
44 options.startupIndented = false
45 let display = Presentation.startupDisplay(tree, options: options)
46 #expect(display.indented)
47 #expect(display.hideStars)
48 #expect(display.inlineImages)
49 #expect(display.alignTables)
50 #expect(display.wrappers.map(\.kind) == [.block])
51 }
52
53 @Test func visibilityPropertiesOnlyWithAStartupVisibility() {
54 let text = "* A\n:PROPERTIES:\n:VISIBILITY: folded\n:END:\nbody\n"
55 #expect(Presentation.startupDisplay(OrgParser.parse(text), options: .org).folds.folds.isEmpty)
56 #expect(Presentation.startupDisplay(OrgParser.parse("#+STARTUP: showall\n" + text), options: .org).folds.folds.count == 1)
57 }
58}
59
60struct HiddenMarkupTests {
61 @Test func linkMarkupAlwaysEmphasisMarkersBySetting() {
62 var options = EditingOptions.org
63 #expect(Presentation.hidesWithMarkup(.linkMarkup, options: options))
64 #expect(!Presentation.hidesWithMarkup(.markup, options: options))
65 options.hideEmphasisMarkers = true
66 #expect(Presentation.hidesWithMarkup(.markup, options: options))
67 #expect(!Presentation.hidesWithMarkup(.bold, options: options))
68 }
69
70 @Test func leadingStarsShowUnlessIndentedOrHidden() {
71 #expect(Presentation.skipsLeadingStars(.leadingStars, indented: false, hideStars: false))
72 #expect(!Presentation.skipsLeadingStars(.leadingStars, indented: true, hideStars: false))
73 #expect(!Presentation.skipsLeadingStars(.leadingStars, indented: false, hideStars: true))
74 #expect(!Presentation.skipsLeadingStars(.heading(level: 1), indented: false, hideStars: false))
75 }
76
77 @Test func prettyRunsSkipTheRevealedLineCommentsAndBlocks() {
78 let text = "\\alpha one\n# \\beta\n\\gamma\nx_{1}\n" as NSString
79 let lines = [0, 11, 19, 26]
80 let all = Presentation.prettyRuns(text, in: 0..<text.length, revealedLine: nil, inBlock: { _ in false }, scriptExclusions: [], subSuperscriptsNeedBraces: true)
81 #expect(all.map(\.lineStart) == [lines[0], lines[2], lines[3]])
82 let revealed = Presentation.prettyRuns(text, in: 0..<text.length, revealedLine: lines[2], inBlock: { $0 == lines[0] }, scriptExclusions: [], subSuperscriptsNeedBraces: true)
83 #expect(revealed.map(\.lineStart) == [lines[3]])
84 let excluded = Presentation.prettyRuns(text, in: 0..<text.length, revealedLine: nil, inBlock: { _ in false }, scriptExclusions: [lines[3]..<lines[3] + 5], subSuperscriptsNeedBraces: true)
85 #expect(excluded.map(\.lineStart) == [lines[0], lines[2]])
86 }
87}
88
89struct LineIndentTests {
90 @Test(arguments: [
91 ("plain", 0), (" indented", 2), ("- item", 2), (" - [X] done", 8), ("12. step", 4),
92 ("* heading-like", 0), (" * starred item", 4), ("-not a bullet", 0), ("\t- tab", 10),
93 ])
94 func hangingColumns(line: String, expected: Int) {
95 #expect(Presentation.hangingColumns(line) == expected)
96 }
97
98 @Test func bodyLinesHangUnderTheirText() {
99 let text = "** H\n- item\nplain\n"
100 let tree = OrgParser.parse(text)
101 let indents = Presentation.lineIndents(tree, in: 0..<text.utf16.count)
102 #expect(indents.map(\.range) == [0..<5, 5..<12, 12..<18])
103 #expect(indents.map(\.firstLine) == [0, 3, 3])
104 #expect(indents.map(\.wrapped) == [3, 5, 3])
105 }
106}