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