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 {
110 110
111 /// The theme from the file read last and its theme file. 111 /// The theme from the file read last and its theme file.
112 func readTheme() { 112 func readTheme() {
113 guard let text, let tables = try? TOML.parse(text) else { return } 113 guard let text, let read = UserConfig.theme(
114 var spec = ThemeSpec.default 114 config: text, themeFile: defaults.string(forKey: "themeFile"), in: url.deletingLastPathComponent()
115 var themeProblems: [String] = [] 115 ) else { return }
116 if let name = defaults.string(forKey: "themeFile"), let file = themeFile { 116 if read.theme != theme { theme = read.theme }
117 if let themeText = try? String(contentsOf: file, encoding: .utf8) { 117 problems = configProblems + read.problems
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
130 } 118 }
131 119
132 /// Writes settings that differ from the file into it. 120 /// Writes settings that differ from the file into it.
Sources/OrgApp/OrgPreferences.swift +14
@@ -1,5 +1,6 @@
1import Foundation 1import Foundation
2import OrgCore 2import OrgCore
3import OrgPresentation
3 4
4/// The Org settings `config.toml` holds, read from user defaults with the file's fallbacks, for 5/// The Org settings `config.toml` holds, read from user defaults with the file's fallbacks, for
5/// both apps. 6/// both apps.
@@ -33,6 +34,19 @@ public enum OrgPreferences {
33 return options 34 return options
34 } 35 }
35 36
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
36 public static func agendaSpan(_ defaults: UserDefaults = .standard) -> Int { integer("agendaSpan", defaults) } 50 public static func agendaSpan(_ defaults: UserDefaults = .standard) -> Int { integer("agendaSpan", defaults) }
37 public static func agendaStartOffset(_ defaults: UserDefaults = .standard) -> Int { integer("agendaStartOffset", defaults) } 51 public static func agendaStartOffset(_ defaults: UserDefaults = .standard) -> Int { integer("agendaStartOffset", defaults) }
38 public static func agendaSubfolders(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSubfolders", defaults) } 52 public static func agendaSubfolders(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSubfolders", defaults) }
Sources/OrgApp/UserConfig.swift +25
@@ -23,6 +23,31 @@ public enum UserConfig {
23 return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".config/orgstar") 23 return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".config/orgstar")
24 } 24 }
25 25
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
26 /// Where earlier versions kept the configuration files. 51 /// Where earlier versions kept the configuration files.
27 public static var legacyDirectory: URL { 52 public static var legacyDirectory: URL {
28 FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar") 53 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 {
176 // Files open at their start, as in Emacs; replacing the storage leaves it at the end. 176 // Files open at their start, as in Emacs; replacing the storage leaves it at the end.
177 textView.setSelectedRange(NSRange(location: 0, length: 0)) 177 textView.setSelectedRange(NSRange(location: 0, length: 0))
178 if isOrg { 178 if isOrg {
179 let words = Presentation.startupOptions(document.tree).map { $0.lowercased() } 179 let startup = Presentation.startupDisplay(document.tree, options: options)
180 indented = words.contains("indent") ? true : words.contains("noindent") ? false : options.startupIndented 180 indented = startup.indented
181 hideStars = words.contains("hidestars") ? true : words.contains("showstars") ? false : options.hideLeadingStars 181 hideStars = startup.hideStars
182 var startup = Presentation.startupFoldState(document.tree) ?? FoldState() 182 if startup.folds != FoldState() { setFolds(startup.folds, wrappers: startup.wrappers) }
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) }
190 shrinkAtStartup() 183 shrinkAtStartup()
191 if InlineImages.startup(document.tree) ?? options.startupWithInlineImages { showsImages = true } 184 if startup.inlineImages { showsImages = true }
192 if words.contains("align") || (options.startupAlignAllTables && !words.contains("noalign")) { 185 if startup.alignTables { _ = perform(AlignAllTables()) }
193 _ = perform(AlignAllTables())
194 }
195 if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) } 186 if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) }
196 } 187 }
197 spellChecking = options.spellCheck 188 spellChecking = options.spellCheck
@@ -1047,38 +1038,22 @@ public final class OrgEditor: NSObject {
1047 } 1038 }
1048 return 1039 return
1049 } 1040 }
1050 let text = storage.string as NSString 1041 if indented {
1051 for run in Presentation.indents(document.tree, in: range) where indented { 1042 for line in Presentation.lineIndents(document.tree, in: range) {
1052 guard let clipped = clip(run.range, to: range) else { continue } 1043 storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range))
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)
1066 } 1044 }
1067 } 1045 }
1068 let revealed = caretLine 1046 let revealed = caretLine
1069 var scriptExclusions: [Range<Int>] = [] 1047 var scriptExclusions: [Range<Int>] = []
1070 for run in Presentation.styles(document.tree, in: range, highlighter: highlighter) { 1048 for run in Presentation.styles(document.tree, in: range, highlighter: highlighter) {
1071 guard let clipped = clip(run.range, to: range) else { continue } 1049 guard let clipped = clip(run.range, to: range) else { continue }
1072 if run.role == .leadingStars, !indented, !hideStars { continue } 1050 if Presentation.skipsLeadingStars(run.role, indented: indented, hideStars: hideStars) { continue }
1073 if !showMarkup, hides(run.role), NSIntersectionRange(clipped, revealed).length == 0 { 1051 if !showMarkup, Presentation.hidesWithMarkup(run.role, options: options), NSIntersectionRange(clipped, revealed).length == 0 {
1074 storage.addAttributes(theme.hidden, range: clipped) 1052 storage.addAttributes(theme.hidden, range: clipped)
1075 } else { 1053 } else {
1076 theme.apply(run.role, to: storage, range: clipped) 1054 theme.apply(run.role, to: storage, range: clipped)
1077 } 1055 }
1078 switch run.role { 1056 if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) }
1079 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkMarkup, .keyword: scriptExclusions.append(run.range)
1080 default: break
1081 }
1082 } 1057 }
1083 if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) } 1058 if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
1084 applyShrunk(range, in: storage) 1059 applyShrunk(range, in: storage)
@@ -1087,52 +1062,32 @@ public final class OrgEditor: NSObject {
1087 } 1062 }
1088 1063
1089 /// `org-pretty-entities`: entities drawn as their character over the entity's last 1064 /// `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 1065 /// character (the rest hidden), sub/superscripts raised without their markers.
1091 /// caret's line, in comments or in blocks; sub/superscripts not in emphasis, links or
1092 /// keywords, as `org-raise-scripts` leaves them.
1093 private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) { 1066 private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) {
1094 let text = storage.string as NSString 1067 let runs = Presentation.prettyRuns(
1095 var location = range.lowerBound 1068 storage.string as NSString, in: range, revealedLine: revealed.location, inBlock: { shaded.containing($0) != nil },
1096 while location < range.upperBound { 1069 scriptExclusions: scriptExclusions, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces
1097 let line = text.lineRange(for: NSRange(location: location, length: 0)) 1070 )
1098 guard line.length > 0 else { break } 1071 for (lineStart, run) in runs {
1099 location = NSMaxRange(line) 1072 switch run {
1100 if line.location == revealed.location || shaded.containing(line.location) != nil { continue } 1073 case .entity(let r, let display):
1101 let content = text.substring(with: line) 1074 let start = lineStart + r.lowerBound
1102 if content.range(of: "^[ \\t]*#([ +]|$)", options: .regularExpression) != nil { continue } 1075 let last = lineStart + r.upperBound - 1
1103 for run in PrettyEntities.runs(in: content, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces) { 1076 if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
1104 switch run { 1077 storage.addAttributes([.foregroundColor: NSColor.clear, .orgEntityDisplay: display], range: NSRange(location: last, length: 1))
1105 case .entity(let r, let display): 1078 case .script(let markers, let body, let superscript):
1106 let start = line.location + r.lowerBound 1079 for marker in markers {
1107 let last = line.location + r.upperBound - 1 1080 storage.addAttributes(theme.hidden, range: NSRange(location: lineStart + marker.lowerBound, length: marker.count))
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))
1121 } 1081 }
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))
1122 } 1087 }
1123 } 1088 }
1124 } 1089 }
1125 1090
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
1136 /// Moving to another line shows its markup and hides the last one's. 1091 /// Moving to another line shows its markup and hides the last one's.
1137 private func revealCaretLine() { 1092 private func revealCaretLine() {
1138 guard !showMarkup, isOrg else { return } 1093 guard !showMarkup, isOrg else { return }
@@ -1306,34 +1261,6 @@ public final class OrgEditor: NSObject {
1306 onChange?() 1261 onChange?()
1307 } 1262 }
1308 1263
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
1337 /// Whether a heading line touches `start..<end` or starts right after it, where joining 1264 /// Whether a heading line touches `start..<end` or starts right after it, where joining
1338 /// lines could take it away. 1265 /// lines could take it away.
1339 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool { 1266 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 {
67 Menu("More", systemImage: "ellipsis.circle") { 67 Menu("More", systemImage: "ellipsis.circle") {
68 Button("Commands", systemImage: "command") { sheet = .palette } 68 Button("Commands", systemImage: "command") { sheet = .palette }
69 Button("Clock In", systemImage: "stopwatch") { editor?.run("app.clock.in") } 69 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 }
70 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) } 73 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) }
71 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies } 74 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
72 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery } 75 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
@@ -118,6 +121,11 @@ struct EditorScreen: View {
118 .onChange(of: session.revealRequest?.id) { 121 .onChange(of: session.revealRequest?.id) {
119 if let offset = session.revealRequest?.offset, session.path == path { editor?.reveal(offset) } 122 if let offset = session.revealRequest?.offset, session.path == path { editor?.reveal(offset) }
120 } 123 }
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 }
121 .onDisappear { 129 .onDisappear {
122 // Typing the reader shows next, and a save, don't wait for the autosave delay. 130 // Typing the reader shows next, and a save, don't wait for the autosave delay.
123 if session.dirtyPaths.contains(path) { _ = session.save(path) } 131 if session.dirtyPaths.contains(path) { _ = session.save(path) }
@@ -146,8 +154,10 @@ struct EditorScreen: View {
146 private func start() { 154 private func start() {
147 if session.path != path { session.open(path) } 155 if session.path != path { session.open(path) }
148 if editor == nil, let document = session.document { 156 if editor == nil, let document = session.document {
149 let editor = MobileEditor(document: document, editable: document.isEditable) 157 let editor = MobileEditor(
150 editor.options = session.options 158 document: document, editable: document.isEditable, options: session.options, theme: model.theme,
159 showMarkup: model.showMarkup, baseDirectory: URL(fileURLWithPath: path).deletingLastPathComponent()
160 )
151 editor.knownTags = { [index = workspace.index] in (try? index.tags()) ?? [] } 161 editor.knownTags = { [index = workspace.index] in (try? index.tags()) ?? [] }
152 editor.onChange = { [weak session] in session?.bufferChanged(path) } 162 editor.onChange = { [weak session] in session?.bufferChanged(path) }
153 editor.onMessage = { message = $0 } 163 editor.onMessage = { message = $0 }
@@ -171,6 +181,7 @@ struct EditorScreen: View {
171 case "app.clock.cancel": message = clock.clockOut(session, cancel: true) 181 case "app.clock.cancel": message = clock.clockOut(session, cancel: true)
172 case "app.clock.goto": 182 case "app.clock.goto":
173 if let location = clock.location(session) { model.openRequest = .offset(location.path, location.offset) } else { message = "No active clock" } 183 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()
174 default: message = "Not available on iOS yet" 185 default: message = "Not available on iOS yet"
175 } 186 }
176 } 187 }
@@ -401,7 +412,8 @@ struct CommandPalette: View {
401 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), 412 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"),
402 ("edit.undo", "Undo"), ("edit.redo", "Redo"), 413 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
403 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"), 414 ("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"),
405 ] 417 ]
406 418
407 var body: some View { 419 var body: some View {
Sources/OrgstarMobile/MobileEditor.swift +397 −30
@@ -17,12 +17,56 @@ final class LockedRanges: @unchecked Sendable {
17 func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } } 17 func containing(_ offset: Int) -> Range<Int>? { lock.withLock { ranges.first { $0.contains(offset) } } }
18} 18}
19 19
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
20/// A text view that offers hardware-keyboard presses to the editor's keymap first. 29/// A text view that offers hardware-keyboard presses to the editor's keymap first.
21final class KeyedTextView: UITextView { 30final class KeyedTextView: UITextView {
22 var keyHandler: ((UIKey) -> Bool)? 31 var keyHandler: ((UIKey) -> Bool)?
23 /// Keys the editor took, whose release the text system doesn't see. Optional: made by 32 /// Keys the editor took, whose release the text system doesn't see. Optional: made by
24 /// UIKit's `init(usingTextLayoutManager:)`, the view skips Swift's default values. 33 /// UIKit's `init(usingTextLayoutManager:)`, the view skips Swift's default values.
25 private var handled: Set<UIKeyboardHIDUsage>? 34 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 }
26 70
27 override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) { 71 override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
28 var rest: Set<UIPress> = [] 72 var rest: Set<UIPress> = []
@@ -55,7 +99,29 @@ final class MobileEditor: NSObject, DocumentBuffer {
55 private(set) var document: DocumentState 99 private(set) var document: DocumentState
56 let textView: UITextView 100 let textView: UITextView
57 var options: EditingOptions = .org { 101 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() } }
59 } 125 }
60 var keymap = KeymapPreset.emacs.keymap 126 var keymap = KeymapPreset.emacs.keymap
61 var onChange: (() -> Void)? 127 var onChange: (() -> Void)?
@@ -79,19 +145,44 @@ final class MobileEditor: NSObject, DocumentBuffer {
79 /// Tags used across the workspace, for completion. 145 /// Tags used across the workspace, for completion.
80 var knownTags: @Sendable () -> [String] = { [] } 146 var knownTags: @Sendable () -> [String] = { [] }
81 147
82 private let theme = MobileTheme() 148 private var theme: MobileTheme
83 private(set) var fold = FoldState() 149 private(set) var fold = FoldState()
84 private var globalVisibility: GlobalVisibility? 150 private var globalVisibility: GlobalVisibility?
85 let hidden = LockedRanges() 151 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>()
86 private var loading = false 169 private var loading = false
87 private var restyleScheduled: Range<Int>? 170 private var restyleScheduled: Range<Int>?
88 171
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 ) {
90 self.document = document 176 self.document = document
177 self.options = options
178 self.showMarkup = showMarkup
179 self.baseDirectory = baseDirectory
180 theme = MobileTheme(spec)
91 let keyed = KeyedTextView(usingTextLayoutManager: true) 181 let keyed = KeyedTextView(usingTextLayoutManager: true)
92 textView = keyed 182 textView = keyed
93 super.init() 183 super.init()
94 keyed.keyHandler = { [weak self] key in self?.handleKey(key) ?? false } 184 keyed.keyHandler = { [weak self] key in self?.handleKey(key) ?? false }
185 keyed.onLayout = { [weak self] in self?.viewResized() }
95 textView.isEditable = editable 186 textView.isEditable = editable
96 textView.autocorrectionType = .no 187 textView.autocorrectionType = .no
97 textView.autocapitalizationType = .none 188 textView.autocapitalizationType = .none
@@ -106,11 +197,15 @@ final class MobileEditor: NSObject, DocumentBuffer {
106 textView.textStorage.delegate = self 197 textView.textStorage.delegate = self
107 textView.textLayoutManager?.delegate = self 198 textView.textLayoutManager?.delegate = self
108 (textView.textLayoutManager?.textContentManager as? NSTextContentStorage)?.delegate = self 199 (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()
109 load(document.text) 205 load(document.text)
110 let tree = document.tree 206 setFolds(startup.folds)
111 var state = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree) 207 if startup.alignTables { _ = perform(AlignAllTables()) }
112 state.wrapperFolds = Presentation.startupWrapperFolds(tree) 208 truncatesLines = startup.truncated
113 setFolds(state)
114 textView.selectedRange = NSRange(location: 0, length: 0) 209 textView.selectedRange = NSRange(location: 0, length: 0)
115 } 210 }
116 211
@@ -215,6 +310,10 @@ final class MobileEditor: NSObject, DocumentBuffer {
215 if !cycleAtCaret() { onMessage?("Not on a heading, drawer or block") } 310 if !cycleAtCaret() { onMessage?("Not on a heading, drawer or block") }
216 case "org.cycle-global": cycleGlobal() 311 case "org.cycle-global": cycleGlobal()
217 case "org.link.open": openLinkAtCaret() 312 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")
218 case "edit.undo": textView.undoManager?.undo() 317 case "edit.undo": textView.undoManager?.undo()
219 case "edit.redo": textView.undoManager?.redo() 318 case "edit.redo": textView.undoManager?.redo()
220 case _ where id.hasPrefix("app."): 319 case _ where id.hasPrefix("app."):
@@ -233,8 +332,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
233 switch id { 332 switch id {
234 case "org.cycle": 333 case "org.cycle":
235 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil 334 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 335 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true
237 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto": return onAppCommand != nil 336 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.toggle-markup": return onAppCommand != nil
238 case "org.link.open": return OpenLinkAtPoint().applies(in: context) 337 case "org.link.open": return OpenLinkAtPoint().applies(in: context)
239 default: return false 338 default: return false
240 } 339 }
@@ -382,10 +481,38 @@ final class MobileEditor: NSObject, DocumentBuffer {
382 loading = true 481 loading = true
383 textView.textStorage.setAttributedString(NSAttributedString(string: text, attributes: theme.base)) 482 textView.textStorage.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
384 loading = false 483 loading = false
484 shaded.set(Presentation.shadedBlocks(document.tree), folded: [])
385 restyle(0..<(text as NSString).length) 485 restyle(0..<(text as NSString).length)
386 textView.typingAttributes = theme.base 486 textView.typingAttributes = theme.base
387 } 487 }
388 488
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
389 /// Styles are set after the edit has been processed, as TextKit asks. 516 /// Styles are set after the edit has been processed, as TextKit asks.
390 private func scheduleRestyle(_ range: Range<Int>) { 517 private func scheduleRestyle(_ range: Range<Int>) {
391 if let pending = restyleScheduled { 518 if let pending = restyleScheduled {
@@ -396,6 +523,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
396 DispatchQueue.main.async { [weak self] in 523 DispatchQueue.main.async { [weak self] in
397 guard let self, let range = self.restyleScheduled else { return } 524 guard let self, let range = self.restyleScheduled else { return }
398 self.restyleScheduled = nil 525 self.restyleScheduled = nil
526 self.shaded.set(Presentation.shadedBlocks(self.document.tree), folded: [])
399 // Small files are restyled whole: a heading or block line changes what follows it. 527 // Small files are restyled whole: a heading or block line changes what follows it.
400 let length = self.textView.textStorage.length 528 let length = self.textView.textStorage.length
401 self.restyle(length < 60_000 ? 0..<length : range) 529 self.restyle(length < 60_000 ? 0..<length : range)
@@ -404,21 +532,171 @@ final class MobileEditor: NSObject, DocumentBuffer {
404 } 532 }
405 } 533 }
406 534
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.
407 private func restyle(_ range: Range<Int>) { 541 private func restyle(_ range: Range<Int>) {
408 let storage = textView.textStorage 542 let storage = textView.textStorage
409 let ns = storage.string as NSString 543 let ns = storage.string as NSString
410 guard ns.length > 0 else { return } 544 guard ns.length > 0 else { return }
411 let lower = min(range.lowerBound, ns.length) 545 let lower = min(range.lowerBound, ns.length)
412 let lines = ns.lineRange(for: NSRange(location: lower, length: min(range.count, ns.length - lower))) 546 let lines = ns.lineRange(for: NSRange(location: lower, length: min(range.count, ns.length - lower)))
547 let whole = lines.location..<NSMaxRange(lines)
413 storage.beginEditing() 548 storage.beginEditing()
414 storage.setAttributes(theme.base, range: lines) 549 storage.setAttributes(theme.base, range: lines)
415 for run in Presentation.styles(document.tree, in: lines.location..<NSMaxRange(lines)) { 550 if indented {
416 let clipped = NSIntersectionRange(NSRange(location: run.range.lowerBound, length: run.range.count), lines) 551 for line in Presentation.lineIndents(document.tree, in: whole) {
417 if clipped.length > 0 { theme.apply(run.role, to: storage, range: clipped) } 552 storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range))
553 }
418 } 554 }
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)
419 storage.endEditing() 570 storage.endEditing()
420 } 571 }
421 572
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
422 // MARK: - Folding 700 // MARK: - Folding
423 701
424 func setFolds(_ state: FoldState) { 702 func setFolds(_ state: FoldState) {
@@ -478,6 +756,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
478 fileprivate func selectionChanged() { 756 fileprivate func selectionChanged() {
479 let caret = textView.selectedRange.location 757 let caret = textView.selectedRange.location
480 if hidden.containing(caret) != nil { unfold(around: caret) } 758 if hidden.containing(caret) != nil { unfold(around: caret) }
759 revealCaretLine()
481 } 760 }
482} 761}
483 762
@@ -530,36 +809,124 @@ extension MobileEditor: NSTextContentStorageDelegate {
530} 809}
531 810
532extension MobileEditor: NSTextLayoutManagerDelegate { 811extension 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.
534 nonisolated func textLayoutManager( 814 nonisolated func textLayoutManager(
535 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement 815 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
536 ) -> NSTextLayoutFragment { 816 ) -> NSTextLayoutFragment {
537 let range = textElement.elementRange 817 let range = textElement.elementRange
538 if let range, let manager = textLayoutManager.textContentManager { 818 guard let range, let manager = textLayoutManager.textContentManager else { return NSTextLayoutFragment(textElement: textElement, range: range) }
539 let offset = manager.offset(from: manager.documentRange.location, to: range.location) 819 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
540 if hidden.isFolded(offset) { return FoldedLineFragment(textElement: textElement, range: range) } 820 let folded = hidden.isFolded(offset)
541 } 821 let band = shaded.containing(offset) != nil ? fragmentStyle.blockBand : nil
542 return NSTextLayoutFragment(textElement: textElement, range: range) 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
543 } 831 }
544} 832}
545 833
546/// A folded line drawn with an ellipsis after it, like org's folded headings. 834extension NSAttributedString.Key {
547final class FoldedLineFragment: NSTextLayoutFragment, @unchecked Sendable { 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
548 override func draw(at point: CGPoint, in context: CGContext) { 895 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 }
549 super.draw(at: point, in: context) 902 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 ])
556 UIGraphicsPushContext(context) 903 UIGraphicsPushContext(context)
557 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY)) 904 defer { UIGraphicsPopContext() }
558 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 }
559 } 915 }
560 916
561 override var renderingSurfaceBounds: CGRect { 917 private func drawEntities(at point: CGPoint) {
562 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0) 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 }
563 } 930 }
564} 931}
565#endif 932#endif
Sources/OrgstarMobile/MobileModel.swift +41 −11
@@ -4,7 +4,9 @@ import Observation
4import OrgApp 4import OrgApp
5import OrgCore 5import OrgCore
6import OrgIndex 6import OrgIndex
7import OrgPresentation
7import OrgWorkspace 8import OrgWorkspace
9import UIKit
8 10
9/// The app's models, and the settings from the configuration folder: a folder you choose 11/// The app's models, and the settings from the configuration folder: a folder you choose
10/// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`. 12/// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`.
@@ -36,7 +38,13 @@ final class MobileModel {
36 } 38 }
37 /// Bumped when settings apply, so views that read them update. 39 /// Bumped when settings apply, so views that read them update.
38 private(set) var settingsVersion = 0 40 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?
39 @ObservationIgnored private var presenter: FolderPresenter? 46 @ObservationIgnored private var presenter: FolderPresenter?
47 @ObservationIgnored private var sizeObserver: NSObjectProtocol?
40 48
41 private static let bookmarkKey = "configFolderBookmark" 49 private static let bookmarkKey = "configFolderBookmark"
42 50
@@ -44,7 +52,7 @@ final class MobileModel {
44 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder. 52 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder.
45 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) } 53 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) }
46 UserConfig.directoryOverride = folder 54 UserConfig.directoryOverride = folder
47 let problems = Self.readConfig(folder) 55 let (problems, colors) = Self.readConfig(folder)
48 let settings = OrgPreferences.orgSettings() 56 let settings = OrgPreferences.orgSettings()
49 do { 57 do {
50 let workspace = try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: settings)) 58 let workspace = try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: settings))
@@ -58,9 +66,13 @@ final class MobileModel {
58 } 66 }
59 configFolder = folder 67 configFolder = folder
60 configProblems = problems 68 configProblems = problems
61 applySettings() 69 applySettings(colors)
62 shareTemplates() 70 shareTemplates()
63 watchConfig() 71 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 }
64 } 76 }
65 77
66 /// Chooses the configuration folder, or with nil stops using one; its settings apply now. 78 /// Chooses the configuration folder, or with nil stops using one; its settings apply now.
@@ -80,12 +92,19 @@ final class MobileModel {
80 92
81 func reloadConfig() { 93 func reloadConfig() {
82 UserConfig.directoryOverride = configFolder 94 UserConfig.directoryOverride = configFolder
83 configProblems = Self.readConfig(configFolder) 95 let (problems, colors) = Self.readConfig(configFolder)
84 applySettings() 96 configProblems = problems
97 applySettings(colors)
85 shareTemplates() 98 shareTemplates()
86 } 99 }
87 100
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 }
89 let settings = OrgPreferences.orgSettings() 108 let settings = OrgPreferences.orgSettings()
90 session.defaults = settings 109 session.defaults = settings
91 session.options = OrgPreferences.options() 110 session.options = OrgPreferences.options()
@@ -124,17 +143,28 @@ final class MobileModel {
124 return url 143 return url
125 } 144 }
126 145
127 /// Applies the folder's `config.toml` to the settings; returns its problems. 146 /// Applies the folder's `config.toml` to the settings; returns its problems and the theme's
128 private static func readConfig(_ folder: URL?) -> [String] { 147 /// colors.
129 guard let folder else { return [] } 148 private static func readConfig(_ folder: URL?) -> ([String], ThemeSpec) {
130 let file = folder.appendingPathComponent("config.toml") 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? {
131 var text: String? 162 var text: String?
132 var error: NSError? 163 var error: NSError?
133 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: file, options: [], error: &error) { url in 164 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: file, options: [], error: &error) { url in
134 text = try? String(contentsOf: url, encoding: .utf8) 165 text = try? String(contentsOf: url, encoding: .utf8)
135 } 166 }
136 guard let text else { return ["No config.toml in \(folder.lastPathComponent); the defaults apply."] } 167 return text
137 return UserConfig.apply(text, to: .standard)
138 } 168 }
139 169
140 /// Notifications for the next week's timed entries, at most 64, as iOS allows. 170 /// Notifications for the next week's timed entries, at most 64, as iOS allows.
Sources/OrgstarMobile/MobileTheme.swift +54 −15
@@ -1,5 +1,6 @@
1#if os(iOS) 1#if os(iOS)
2import OrgPresentation 2import OrgPresentation
3import SwiftUI
3import UIKit 4import UIKit
4 5
5/// Attributes for each style role, from a `ThemeSpec`, as the Mac editor's theme sets them. 6/// Attributes for each style role, from a `ThemeSpec`, as the Mac editor's theme sets them.
@@ -10,12 +11,17 @@ struct MobileTheme {
10 11
11 init(_ spec: ThemeSpec = .default) { 12 init(_ spec: ThemeSpec = .default) {
12 self.spec = spec 13 self.spec = spec
13 // Dynamic Type, monospaced as on the Mac. 14 let size = CGFloat(max(6, spec.fontSize))
14 font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .monospacedSystemFont(ofSize: 15, weight: .regular)) 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))
15 } 20 }
16 21
17 /// A color that follows the appearance, or nil when the theme has none for `key`. 22 /// A color that follows the appearance, or nil when the theme has none for `key`.
18 func color(_ key: String) -> UIColor? { 23 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.
19 let light = spec.light[key] ?? ThemeSpec.default.light[key] ?? spec.dark[key] 25 let light = spec.light[key] ?? ThemeSpec.default.light[key] ?? spec.dark[key]
20 let dark = spec.dark[key] ?? ThemeSpec.default.dark[key] ?? spec.light[key] 26 let dark = spec.dark[key] ?? ThemeSpec.default.dark[key] ?? spec.light[key]
21 guard let light, let dark else { return nil } 27 guard let light, let dark else { return nil }
@@ -29,10 +35,45 @@ struct MobileTheme {
29 } 35 }
30 } 36 }
31 37
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
32 var base: [NSAttributedString.Key: Any] { 57 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 {
33 let style = NSMutableParagraphStyle() 72 let style = NSMutableParagraphStyle()
73 style.firstLineHeadIndent = CGFloat(firstLine) * column
74 style.headIndent = CGFloat(wrapped) * column
34 style.lineSpacing = CGFloat(max(0, spec.lineSpacing)) 75 style.lineSpacing = CGFloat(max(0, spec.lineSpacing))
35 return [.font: font, .foregroundColor: color("foreground") ?? .label, .paragraphStyle: style] 76 return style
36 } 77 }
37 78
38 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) { 79 func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) {
@@ -41,25 +82,18 @@ struct MobileTheme {
41 } 82 }
42 func traits(_ add: UIFontDescriptor.SymbolicTraits) { 83 func traits(_ add: UIFontDescriptor.SymbolicTraits) {
43 let current = storage.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont ?? font 84 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)) { 85 storage.addAttribute(.font, value: Self.adding(add, to: current), range: range)
45 storage.addAttribute(.font, value: UIFont(descriptor: descriptor, size: current.pointSize), range: range)
46 }
47 } 86 }
48 switch role { 87 switch role {
49 case .heading(let level): 88 case .heading(let level):
50 let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level)) 89 storage.addAttribute(.font, value: headingFont(level), range: range)
51 storage.addAttribute(.font, value: font.withSize(size), range: range)
52 traits(.traitBold)
53 fg("heading-\(min(max(level, 1), 8))") 90 fg("heading-\(min(max(level, 1), 8))")
54 case .leadingStars: fg("markup") 91 case .leadingStars:
92 storage.addAttribute(.foregroundColor, value: UIColor.clear, range: range)
55 case .todo, .done: 93 case .todo, .done:
56 traits(.traitBold) 94 traits(.traitBold)
57 let keyword = (storage.string as NSString).substring(with: range) 95 let keyword = (storage.string as NSString).substring(with: range)
58 if let custom = spec.todo[keyword] { 96 if let color = keywordColor(keyword, done: role == .done) { storage.addAttribute(.foregroundColor, value: color, range: range) }
59 storage.addAttribute(.foregroundColor, value: Self.dynamic(custom, custom), range: range)
60 } else {
61 fg(role == .todo ? "todo" : "done")
62 }
63 case .priority: fg("priority") 97 case .priority: fg("priority")
64 case .tags: fg("tags") 98 case .tags: fg("tags")
65 case .comment: fg("comment") 99 case .comment: fg("comment")
@@ -84,5 +118,10 @@ struct MobileTheme {
84 case .syntax: break 118 case .syntax: break
85 } 119 }
86 } 120 }
121
122 // MARK: - SwiftUI
123
124 /// A theme color for SwiftUI views.
125 func swiftColor(_ key: String) -> Color? { color(key).map { Color(uiColor: $0) } }
87} 126}
88#endif 127#endif
Sources/OrgstarMobile/ReaderScreen.swift +62 −31
@@ -4,6 +4,7 @@ import OrgCore
4import OrgIndex 4import OrgIndex
5import OrgPresentation 5import OrgPresentation
6import SwiftUI 6import SwiftUI
7import UIKit
7 8
8/// What to read: a file, and where in it. 9/// What to read: a file, and where in it.
9enum ReaderRoute: Hashable { 10enum ReaderRoute: Hashable {
@@ -31,23 +32,27 @@ struct ReaderScreen: View {
31 @State private var showingOutline = false 32 @State private var showingOutline = false
32 @State private var message: String? 33 @State private var message: String?
33 @Environment(\.openURL) private var openURL 34 @Environment(\.openURL) private var openURL
35 @Environment(MobileModel.self) private var model
34 36
35 private var outline: [OutlineEntry] { session.outline } 37 private var outline: [OutlineEntry] { session.outline }
36 38
37 var body: some View { 39 var body: some View {
38 let _ = session.editVersion 40 let _ = session.editVersion
41 // Read again when settings or the Dynamic Type size change.
42 let _ = model.settingsVersion
43 let theme = MobileTheme(model.theme)
39 ScrollViewReader { proxy in 44 ScrollViewReader { proxy in
40 ScrollView { 45 ScrollView {
41 LazyVStack(alignment: .leading, spacing: 8) { 46 LazyVStack(alignment: .leading, spacing: 8) {
42 if let tree = session.document?.tree { 47 if let tree = session.document?.tree {
43 let preambleEnd = outline.first?.start ?? (tree.text as NSString).length 48 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) }
45 ForEach(Array(outline.enumerated()), id: \.element.start) { index, entry in 50 ForEach(Array(outline.enumerated()), id: \.element.start) { index, entry in
46 if isShown(index) { 51 if isShown(index) {
47 heading(tree, entry).id(entry.start) 52 heading(tree, entry, theme).id(entry.start)
48 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) { 53 if !fold.folds.contains(entry.start), !fold.bodyFolds.contains(entry.start) {
49 let body = bodyRange(index, tree) 54 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)) }
51 } 56 }
52 } 57 }
53 } 58 }
@@ -56,6 +61,7 @@ struct ReaderScreen: View {
56 .padding() 61 .padding()
57 .frame(maxWidth: .infinity, alignment: .leading) 62 .frame(maxWidth: .infinity, alignment: .leading)
58 } 63 }
64 .background(theme.swiftColor("background") ?? Color(uiColor: .systemBackground))
59 .onChange(of: session.revealRequest?.id) { 65 .onChange(of: session.revealRequest?.id) {
60 guard let offset = session.revealRequest?.offset else { return } 66 guard let offset = session.revealRequest?.offset else { return }
61 reveal(offset, proxy) 67 reveal(offset, proxy)
@@ -108,19 +114,20 @@ struct ReaderScreen: View {
108 return min(lineEnd, firstChild)..<firstChild 114 return min(lineEnd, firstChild)..<firstChild
109 } 115 }
110 116
111 private func heading(_ tree: OrgTree, _ entry: OutlineEntry) -> some View { 117 private func heading(_ tree: OrgTree, _ entry: OutlineEntry, _ theme: MobileTheme) -> some View {
112 let ns = tree.text as NSString 118 let ns = tree.text as NSString
113 var line = ns.lineRange(for: NSRange(location: entry.start, length: 0)) 119 var line = ns.lineRange(for: NSRange(location: entry.start, length: 0))
114 let title = min(entry.start + entry.level + 1, NSMaxRange(line)) 120 let title = min(entry.start + entry.level + 1, NSMaxRange(line))
115 line.length = NSMaxRange(line) - title 121 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)
117 let hasContent = entry.end > NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0))) 123 let hasContent = entry.end > NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
118 let foldedMark = fold.folds.contains(entry.start) && hasContent 124 let foldedMark = fold.folds.contains(entry.start) && hasContent
119 return HStack(alignment: .firstTextBaseline, spacing: 6) { 125 return HStack(alignment: .firstTextBaseline, spacing: 6) {
120 Image(systemName: foldedMark ? "chevron.right" : "chevron.down") 126 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))
122 .opacity(hasContent ? 1 : 0) 128 .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)
124 } 131 }
125 .padding(.leading, indent(entry)) 132 .padding(.leading, indent(entry))
126 .padding(.top, entry.level == 1 ? 6 : 0) 133 .padding(.top, entry.level == 1 ? 6 : 0)
@@ -130,50 +137,72 @@ struct ReaderScreen: View {
130 .accessibilityHint(foldedMark ? "Unfolds the heading" : "Folds the heading") 137 .accessibilityHint(foldedMark ? "Unfolds the heading" : "Folds the heading")
131 } 138 }
132 139
133 private func textView(_ tree: OrgTree, _ range: Range<Int>) -> some View { 140 private func textView(_ tree: OrgTree, _ range: Range<Int>, _ theme: MobileTheme) -> some View {
134 Text(attributed(ReadingText.segments(tree, in: range, folded: folded), heading: nil).trimmingTrailingNewlines()) 141 Text(attributed(ReadingText.segments(tree, in: range, folded: folded), heading: nil, theme).trimmingTrailingNewlines())
142 .lineSpacing(CGFloat(max(0, theme.spec.lineSpacing)))
135 .textSelection(.enabled) 143 .textSelection(.enabled)
136 .frame(maxWidth: .infinity, alignment: .leading) 144 .frame(maxWidth: .infinity, alignment: .leading)
137 } 145 }
138 146
139 /// Segments as styled text; links get `orgstar-link:` URLs to follow. 147 /// Segments as styled text in the theme's font and colors, as the editor styles them;
140 private func attributed(_ segments: [ReadingText.Segment], heading level: Int?) -> AttributedString { 148 /// links get `orgstar-link:` URLs to follow.
149 private func attributed(_ segments: [ReadingText.Segment], heading level: Int?, _ theme: MobileTheme) -> AttributedString {
141 var result = AttributedString() 150 var result = AttributedString()
142 for segment in segments { 151 for segment in segments {
143 var part = AttributedString(segment.text) 152 var part = AttributedString(segment.text)
144 if let level { 153 var font = level.map(theme.headingFont) ?? theme.font
145 part.font = level == 1 ? .title3.weight(.semibold) : level == 2 ? .headline : .body.weight(.semibold) 154 var color = level.flatMap { theme.color("heading-\(min(max($0, 1), 8))") } ?? theme.foreground
146 } 155 func fg(_ key: String) { if let c = theme.color(key) { color = c } }
147 for role in segment.roles { 156 // Outer roles first, so inner ones win.
157 for role in segment.roles.sorted(by: { Self.depth($0) < Self.depth($1) }) {
148 switch role { 158 switch role {
149 case .todo: part.foregroundColor = .red; part.font = (part.font ?? .body).weight(.semibold) 159 case .todo, .done:
150 case .done: part.foregroundColor = .green 160 font = MobileTheme.adding(.traitBold, to: font)
151 case .priority: part.foregroundColor = .orange 161 if let c = theme.keywordColor(segment.text, done: role == .done) { color = c }
152 case .tags: part.foregroundColor = .secondary; part.font = .caption 162 case .priority: fg("priority")
153 case .bold: part.inlinePresentationIntent = .stronglyEmphasized 163 case .tags: fg("tags")
154 case .italic: part.inlinePresentationIntent = .emphasized 164 case .bold: font = MobileTheme.adding(.traitBold, to: font)
165 case .italic: font = MobileTheme.adding(.traitItalic, to: font)
155 case .underline: part.underlineStyle = .single 166 case .underline: part.underlineStyle = .single
156 case .strikeThrough: part.strikethroughStyle = .single 167 case .strikeThrough: part.strikethroughStyle = .single
157 case .verbatim, .code, .inlineSource: part.font = .body.monospaced() 168 case .verbatim, .code, .inlineSource:
158 case .markup, .leadingStars: part.foregroundColor = .secondary 169 if let background = theme.color("inline-background") { part.backgroundColor = Color(uiColor: background) }
159 case .timestamp: part.foregroundColor = .purple 170 fg(role == .verbatim ? "verbatim" : "code")
160 case .metadata, .keyword, .comment, .blockDelimiter: 171 case .markup, .linkMarkup, .leadingStars: fg("markup")
161 part.foregroundColor = .secondary 172 case .timestamp: fg("timestamp")
162 part.font = .footnote.monospaced() 173 case .comment: fg("comment")
163 case .block, .table, .syntax: part.font = .callout.monospaced() 174 case .keyword: fg("keyword")
164 default: break 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
165 } 183 }
166 } 184 }
167 if segment.link != nil { 185 if segment.link != nil {
168 // Found again by position when followed. 186 // Found again by position when followed.
169 part.link = URL(string: "orgstar-link:\(segment.offset)") 187 part.link = URL(string: "orgstar-link:\(segment.offset)")
170 part.foregroundColor = .accentColor
171 } 188 }
189 part.font = Font(font as CTFont)
190 part.foregroundColor = Color(uiColor: color)
172 result += part 191 result += part
173 } 192 }
174 return result 193 return result
175 } 194 }
176 195
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
177 // MARK: - Actions 206 // MARK: - Actions
178 207
179 private func open() { 208 private func open() {
@@ -190,7 +219,9 @@ struct ReaderScreen: View {
190 /// The file's startup visibility, kept while the same file is open. 219 /// The file's startup visibility, kept while the same file is open.
191 private func loadFolds() { 220 private func loadFolds() {
192 guard let tree = session.document?.tree else { return } 221 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)
194 guard loadedPath != session.path else { return } 225 guard loadedPath != session.path else { return }
195 loadedPath = session.path 226 loadedPath = session.path
196 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree) 227 fold = Presentation.applyVisibilityProperties(Presentation.startupFoldState(tree) ?? FoldState(), in: tree)
Sources/OrgstarMobile/SettingsScreen.swift +11
@@ -2,6 +2,7 @@
2import OrgApp 2import OrgApp
3import OrgCore 3import OrgCore
4import SwiftUI 4import SwiftUI
5import UIKit
5 6
6/// Where settings come from: a folder with the Mac's `config.toml` and `capture.toml`, kept in 7/// Where settings come from: a folder with the Mac's `config.toml` and `capture.toml`, kept in
7/// sync by iCloud Drive or another app. 8/// sync by iCloud Drive or another app.
@@ -29,6 +30,8 @@ struct SettingsScreen: View {
29 LabeledContent("Agenda", value: agenda) 30 LabeledContent("Agenda", value: agenda)
30 LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off") 31 LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off")
31 LabeledContent("Capture templates", value: CaptureTemplates.load().templates.map(\.key).joined(separator: " ")) 32 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)
32 } 35 }
33 } 36 }
34 .navigationTitle("Settings") 37 .navigationTitle("Settings")
@@ -45,6 +48,14 @@ struct SettingsScreen: View {
45 return "\(days(OrgPreferences.agendaSpan())), starting \(start)" 48 return "\(days(OrgPreferences.agendaSpan())), starting \(start)"
46 } 49 }
47 50
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
48 private var todoKeywords: String { 59 private var todoKeywords: String {
49 OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") } 60 OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") }
50 .joined(separator: "; ") 61 .joined(separator: "; ")
Tests/OrgAppTests/OrgPreferencesTests.swift +15
@@ -1,5 +1,6 @@
1import Foundation 1import Foundation
2import OrgCore 2import OrgCore
3import OrgPresentation
3import Testing 4import Testing
4@testable import OrgApp 5@testable import OrgApp
5 6
@@ -36,4 +37,18 @@ struct OrgPreferencesTests {
36 #expect(OrgPreferences.agendaSpan(defaults) == 7 && OrgPreferences.agendaStartOffset(defaults) == -1) 37 #expect(OrgPreferences.agendaSpan(defaults) == 7 && OrgPreferences.agendaStartOffset(defaults) == -1)
37 #expect(!OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 30) 38 #expect(!OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 30)
38 } 39 }
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 }
39} 54}
Tests/OrgAppTests/UserConfigTests.swift +36
@@ -151,3 +151,39 @@ struct ThemeConfigTests {
151 #expect(!sync.isThemeFile(folder.appendingPathComponent("x.toml"))) 151 #expect(!sync.isThemeFile(folder.appendingPathComponent("x.toml")))
152 } 152 }
153} 153}
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 {
431 } 431 }
432} 432}
433 433
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
444@MainActor 434@MainActor
445struct CommandTests { 435struct CommandTests {
446 @Test func performEditsThroughTheTextView() { 436 @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}