Commit 0cfdac07f7
Verified · cmc
Layout: unified · split
Sources/OrgApp/ConfigSync.swift +42 −2
| @@ -1,6 +1,7 @@ | |||
| 1 | #if os(macOS) | 1 | #if os(macOS) |
| 2 | import Foundation | 2 | import Foundation |
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import OrgPresentation | ||
| 4 | import OrgWorkspace | 5 | import OrgWorkspace |
| 5 | 6 | ||
| 6 | /// Keeps `config.toml` and the settings in user defaults the same: the file applies at launch | 7 | /// Keeps `config.toml` and the settings in user defaults the same: the file applies at launch |
| @@ -13,12 +14,16 @@ public final class ConfigSync { | |||
| 13 | 14 | ||
| 14 | /// Problems in the file, from the last time it was read. | 15 | /// Problems in the file, from the last time it was read. |
| 15 | public private(set) var problems: [String] = [] | 16 | public private(set) var problems: [String] = [] |
| 17 | /// The colors of `[theme]`, `[theme.light]`, `[theme.dark]` and `[theme.todo]` over the | ||
| 18 | /// default theme, with `theme-file`'s under them. | ||
| 19 | public private(set) var theme = ThemeSpec.default | ||
| 16 | /// The last file of the configuration folder that changed, with a serial number. | 20 | /// The last file of the configuration folder that changed, with a serial number. |
| 17 | public private(set) var changedFile: (name: String, serial: Int)? | 21 | public private(set) var changedFile: (name: String, serial: Int)? |
| 18 | 22 | ||
| 19 | private let url: URL | 23 | private let url: URL |
| 20 | private let defaults: UserDefaults | 24 | private let defaults: UserDefaults |
| 21 | @ObservationIgnored private var text: String? | 25 | @ObservationIgnored private var text: String? |
| 26 | @ObservationIgnored private var configProblems: [String] = [] | ||
| 22 | @ObservationIgnored private var applying = false | 27 | @ObservationIgnored private var applying = false |
| 23 | @ObservationIgnored private var watcher: FSEventsWatcher? | 28 | @ObservationIgnored private var watcher: FSEventsWatcher? |
| 24 | @ObservationIgnored private var observer: NSObjectProtocol? | 29 | @ObservationIgnored private var observer: NSObjectProtocol? |
| @@ -32,13 +37,24 @@ public final class ConfigSync { | |||
| 32 | public func start(watch: Bool = true) { | 37 | public func start(watch: Bool = true) { |
| 33 | guard observer == nil else { return } | 38 | guard observer == nil else { return } |
| 34 | try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) | 39 | try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) |
| 40 | let reference = url.deletingLastPathComponent().appendingPathComponent("default-theme.toml") | ||
| 41 | if (try? String(contentsOf: reference, encoding: .utf8)) != ThemeSpec.defaultThemeFile() { | ||
| 42 | try? ThemeSpec.defaultThemeFile().write(to: reference, atomically: true, encoding: .utf8) | ||
| 43 | } | ||
| 35 | if FileManager.default.fileExists(atPath: url.path) { | 44 | if FileManager.default.fileExists(atPath: url.path) { |
| 36 | reload() | 45 | reload() |
| 37 | // A file from before settings took Emacs's names is written again with them. | 46 | // A file from before settings took Emacs's names is written again with them; one |
| 47 | // from before a section existed gets the section. | ||
| 38 | if let text, UserConfig.usesLegacyNames(text) { | 48 | if let text, UserConfig.usesLegacyNames(text) { |
| 39 | let template = UserConfig.template(from: defaults) | 49 | let template = UserConfig.template(from: defaults) |
| 40 | try? template.write(to: url, atomically: true, encoding: .utf8) | 50 | try? template.write(to: url, atomically: true, encoding: .utf8) |
| 41 | self.text = template | 51 | self.text = template |
| 52 | } else if let text { | ||
| 53 | let completed = UserConfig.addingMissingSections(text, from: defaults) | ||
| 54 | if completed != text { | ||
| 55 | try? completed.write(to: url, atomically: true, encoding: .utf8) | ||
| 56 | self.text = completed | ||
| 57 | } | ||
| 42 | } | 58 | } |
| 43 | } else { | 59 | } else { |
| 44 | let template = UserConfig.template(from: defaults) | 60 | let template = UserConfig.template(from: defaults) |
| @@ -59,6 +75,7 @@ public final class ConfigSync { | |||
| 59 | guard let self else { return } | 75 | guard let self else { return } |
| 60 | for name in names.sorted() { | 76 | for name in names.sorted() { |
| 61 | if name == self.url.lastPathComponent { self.reload() } | 77 | if name == self.url.lastPathComponent { self.reload() } |
| 78 | if name == self.defaults.string(forKey: "themeFile") { self.readTheme() } | ||
| 62 | self.serial += 1 | 79 | self.serial += 1 |
| 63 | self.changedFile = (name, self.serial) | 80 | self.changedFile = (name, self.serial) |
| 64 | } | 81 | } |
| @@ -72,8 +89,31 @@ public final class ConfigSync { | |||
| 72 | guard let new = try? String(contentsOf: url, encoding: .utf8), new != text else { return } | 89 | guard let new = try? String(contentsOf: url, encoding: .utf8), new != text else { return } |
| 73 | text = new | 90 | text = new |
| 74 | applying = true | 91 | applying = true |
| 75 | problems = UserConfig.apply(new, to: defaults) | 92 | configProblems = UserConfig.apply(new, to: defaults) |
| 76 | applying = false | 93 | applying = false |
| 94 | readTheme() | ||
| 95 | } | ||
| 96 | |||
| 97 | /// The theme from the file read last and its theme file. | ||
| 98 | func readTheme() { | ||
| 99 | guard let text, let tables = try? TOML.parse(text) else { return } | ||
| 100 | var spec = ThemeSpec.default | ||
| 101 | var themeProblems: [String] = [] | ||
| 102 | if let name = defaults.string(forKey: "themeFile"), !name.isEmpty { | ||
| 103 | let file = url.deletingLastPathComponent().appendingPathComponent(name) | ||
| 104 | if let themeText = try? String(contentsOf: file, encoding: .utf8) { | ||
| 105 | do { | ||
| 106 | (spec, themeProblems) = ThemeSpec.reading(try TOML.parse(themeText), file: name) | ||
| 107 | } catch { | ||
| 108 | themeProblems.append("\(name): \(error)") | ||
| 109 | } | ||
| 110 | } else { | ||
| 111 | themeProblems.append("config.toml: theme-file \(name) can't be read") | ||
| 112 | } | ||
| 113 | } | ||
| 114 | let (merged, more) = ThemeSpec.reading(tables, over: spec) | ||
| 115 | if merged != theme { theme = merged } | ||
| 116 | problems = configProblems + themeProblems + more | ||
| 77 | } | 117 | } |
| 78 | 118 | ||
| 79 | /// Writes settings that differ from the file into it. | 119 | /// Writes settings that differ from the file into it. |
Sources/OrgApp/UserConfig.swift +33
| @@ -1,5 +1,6 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import OrgPresentation | ||
| 3 | 4 | ||
| 4 | /// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and | 5 | /// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and |
| 5 | /// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a | 6 | /// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a |
| @@ -80,6 +81,16 @@ public enum UserConfig { | |||
| 80 | comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"), | 81 | comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"), |
| 81 | Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12), | 82 | Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12), |
| 82 | comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"), | 83 | comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"), |
| 84 | Setting(section: "theme", key: "font", defaultsKey: "themeFont", kind: .string, fallback: .string(""), | ||
| 85 | comment: "a monospaced font family; \"\" for the system's", legacy: ""), | ||
| 86 | Setting(section: "theme", key: "font-size", defaultsKey: "themeFontSize", kind: .integer, fallback: .integer(13), | ||
| 87 | comment: "points", legacy: ""), | ||
| 88 | Setting(section: "theme", key: "line-spacing", defaultsKey: "themeLineSpacing", kind: .integer, fallback: .integer(2), | ||
| 89 | comment: "points between lines", legacy: ""), | ||
| 90 | Setting(section: "theme", key: "heading-size-step", defaultsKey: "themeHeadingStep", kind: .integer, fallback: .integer(1), | ||
| 91 | comment: "points a heading is larger than the level below; level 4 and deeper are body size", legacy: ""), | ||
| 92 | Setting(section: "theme", key: "theme-file", defaultsKey: "themeFile", kind: .string, fallback: .string(""), | ||
| 93 | comment: "a theme in its own file in this folder, under the colors set here; colors: see default-theme.toml", legacy: ""), | ||
| 83 | Setting(section: "orgstar", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]), | 94 | Setting(section: "orgstar", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]), |
| 84 | fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)", legacy: "general.save"), | 95 | fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)", legacy: "general.save"), |
| 85 | Setting(section: "orgstar", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]), | 96 | Setting(section: "orgstar", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]), |
| @@ -162,7 +173,10 @@ public enum UserConfig { | |||
| 162 | if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) } | 173 | if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) } |
| 163 | } | 174 | } |
| 164 | for table in tables { | 175 | for table in tables { |
| 176 | // Colors are the theme's to read. | ||
| 177 | if ThemeSpec.isThemeTable(table.name), table.name != "theme" { continue } | ||
| 165 | for (key, value) in table.values.sorted(by: { $0.key < $1.key }) { | 178 | for (key, value) in table.values.sorted(by: { $0.key < $1.key }) { |
| 179 | if table.name == "theme", !ThemeSpec.typeKeys.contains(key) { continue } | ||
| 166 | let name = path(table.name, key) | 180 | let name = path(table.name, key) |
| 167 | guard let setting = settings.first(where: { $0.path == name || $0.legacy == name }) else { | 181 | guard let setting = settings.first(where: { $0.path == name || $0.legacy == name }) else { |
| 168 | problems.append("config.toml: unknown setting \(name)") | 182 | problems.append("config.toml: unknown setting \(name)") |
| @@ -228,6 +242,25 @@ public enum UserConfig { | |||
| 228 | return out | 242 | return out |
| 229 | } | 243 | } |
| 230 | 244 | ||
| 245 | /// `text` with the sections it lacks entirely added at the end, every setting at its | ||
| 246 | /// value in `defaults`: how a file from an earlier version gets settings added since. | ||
| 247 | /// Single lines left out stay out; they mean the default. | ||
| 248 | public static func addingMissingSections(_ text: String, from defaults: UserDefaults) -> String { | ||
| 249 | let tables = (try? TOML.parse(text)) ?? [] | ||
| 250 | let present = Set(tables.filter { !$0.values.isEmpty || $0.name != "" }.map(\.name)) | ||
| 251 | var out = text | ||
| 252 | var section: String? | ||
| 253 | for setting in settings where !setting.section.isEmpty && !present.contains(setting.section) { | ||
| 254 | if setting.section != section { | ||
| 255 | section = setting.section | ||
| 256 | if !out.hasSuffix("\n") { out += "\n" } | ||
| 257 | out += "\n[\(setting.section)]\n" | ||
| 258 | } | ||
| 259 | out += "\(setting.key) = \(format(value(setting, in: defaults))) # \(setting.comment)\n" | ||
| 260 | } | ||
| 261 | return out | ||
| 262 | } | ||
| 263 | |||
| 231 | /// `text` with `section.key` set to `value`: the line's value is replaced (its comment | 264 | /// `text` with `section.key` set to `value`: the line's value is replaced (its comment |
| 232 | /// stays), or a line is added at the end of the section, or the section at the end. | 265 | /// stays), or a line is added at the end of the section, or the section at the end. |
| 233 | public static func setting(_ text: String, section: String, key: String, to value: TOML.Value) -> String { | 266 | public static func setting(_ text: String, section: String, key: String, to value: TOML.Value) -> String { |
Sources/OrgEditorAppKit/Keys.swift +3
| @@ -15,6 +15,9 @@ final class OrgTextView: NSTextView { | |||
| 15 | /// Returns true when the key was handled. | 15 | /// Returns true when the key was handled. |
| 16 | var keyHandler: ((NSEvent) -> Bool)? | 16 | var keyHandler: ((NSEvent) -> Bool)? |
| 17 | var onMouseDown: (() -> Void)? | 17 | var onMouseDown: (() -> Void)? |
| 18 | var lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) | ||
| 19 | var lineNumberColor = NSColor.tertiaryLabelColor | ||
| 20 | var currentLineNumberColor = NSColor.labelColor | ||
| 18 | var showsLineNumbers = false { | 21 | var showsLineNumbers = false { |
| 19 | didSet { if showsLineNumbers != oldValue { updateGutter() } } | 22 | didSet { if showsLineNumbers != oldValue { updateGutter() } } |
| 20 | } | 23 | } |
Sources/OrgEditorAppKit/LineNumbers.swift +3 −5
| @@ -5,15 +5,13 @@ import AppKit | |||
| 5 | /// per line of the file (folded lines skipped, wrapped lines numbered once), the caret's line | 5 | /// per line of the file (folded lines skipped, wrapped lines numbered once), the caret's line |
| 6 | /// bright. Drawn by the text view itself: an `NSRulerView` stops TextKit 2 from drawing text. | 6 | /// bright. Drawn by the text view itself: an `NSRulerView` stops TextKit 2 from drawing text. |
| 7 | extension OrgTextView { | 7 | extension OrgTextView { |
| 8 | static let lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) | ||
| 9 | |||
| 10 | /// Width of the margin for the largest line number, or 0 when numbers are off. | 8 | /// Width of the margin for the largest line number, or 0 when numbers are off. |
| 11 | func updateGutter() { | 9 | func updateGutter() { |
| 12 | var width: CGFloat = 0 | 10 | var width: CGFloat = 0 |
| 13 | if showsLineNumbers { | 11 | if showsLineNumbers { |
| 14 | let lines = (string as NSString).components(separatedBy: "\n").count | 12 | let lines = (string as NSString).components(separatedBy: "\n").count |
| 15 | let digits = max(2, String(lines).count) | 13 | let digits = max(2, String(lines).count) |
| 16 | width = ceil(("0" as NSString).size(withAttributes: [.font: Self.lineNumberFont]).width * CGFloat(digits) + 16) | 14 | width = ceil(("0" as NSString).size(withAttributes: [.font: lineNumberFont]).width * CGFloat(digits) + 16) |
| 17 | } | 15 | } |
| 18 | if textContainerInset.width != width { | 16 | if textContainerInset.width != width { |
| 19 | textContainerInset = NSSize(width: width, height: textContainerInset.height) | 17 | textContainerInset = NSSize(width: width, height: textContainerInset.height) |
| @@ -44,8 +42,8 @@ extension OrgTextView { | |||
| 44 | counted += 1 | 42 | counted += 1 |
| 45 | } | 43 | } |
| 46 | let label = NSAttributedString(string: "\(line)", attributes: [ | 44 | let label = NSAttributedString(string: "\(line)", attributes: [ |
| 47 | .font: Self.lineNumberFont, | 45 | .font: lineNumberFont, |
| 48 | .foregroundColor: offset == caretLine ? NSColor.labelColor : NSColor.tertiaryLabelColor, | 46 | .foregroundColor: offset == caretLine ? currentLineNumberColor : lineNumberColor, |
| 49 | ]) | 47 | ]) |
| 50 | let lineHeight = fragment.textLineFragments.first?.typographicBounds.height ?? frame.height | 48 | let lineHeight = fragment.textLineFragments.first?.typographicBounds.height ?? frame.height |
| 51 | let size = label.size() | 49 | let size = label.size() |
Sources/OrgEditorAppKit/OrgEditor.swift +29 −3
| @@ -54,7 +54,17 @@ public final class OrgEditor: NSObject { | |||
| 54 | let folded = FoldedHeadings() | 54 | let folded = FoldedHeadings() |
| 55 | /// Blocks drawn on a shaded band. | 55 | /// Blocks drawn on a shaded band. |
| 56 | let shaded = HiddenRanges() | 56 | let shaded = HiddenRanges() |
| 57 | let theme = Theme() | 57 | private(set) var theme = Theme() |
| 58 | /// Colors and type; a change restyles the file. | ||
| 59 | public var themeSpec: ThemeSpec = .default { | ||
| 60 | didSet { | ||
| 61 | guard themeSpec != oldValue else { return } | ||
| 62 | theme = Theme(themeSpec) | ||
| 63 | applyThemeToView() | ||
| 64 | restyleEverything() | ||
| 65 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) | ||
| 66 | } | ||
| 67 | } | ||
| 58 | private var isLoading = false | 68 | private var isLoading = false |
| 59 | private var previousSelection = 0 | 69 | private var previousSelection = 0 |
| 60 | /// Text before this offset is styled; after it, only base attributes until the background | 70 | /// Text before this offset is styled; after it, only base attributes until the background |
| @@ -113,8 +123,7 @@ public final class OrgEditor: NSObject { | |||
| 113 | textView.isAutomaticDashSubstitutionEnabled = false | 123 | textView.isAutomaticDashSubstitutionEnabled = false |
| 114 | textView.isAutomaticTextReplacementEnabled = false | 124 | textView.isAutomaticTextReplacementEnabled = false |
| 115 | textView.isAutomaticSpellingCorrectionEnabled = false | 125 | textView.isAutomaticSpellingCorrectionEnabled = false |
| 116 | textView.font = theme.font | 126 | applyThemeToView() |
| 117 | textView.typingAttributes = theme.base | ||
| 118 | textView.isVerticallyResizable = true | 127 | textView.isVerticallyResizable = true |
| 119 | textView.autoresizingMask = [.width] | 128 | textView.autoresizingMask = [.width] |
| 120 | textView.textContainer?.widthTracksTextView = true | 129 | textView.textContainer?.widthTracksTextView = true |
| @@ -151,6 +160,23 @@ public final class OrgEditor: NSObject { | |||
| 151 | styledUpTo = end | 160 | styledUpTo = end |
| 152 | } | 161 | } |
| 153 | 162 | ||
| 163 | /// The text view's own colors and font, and the line numbers'. | ||
| 164 | private func applyThemeToView() { | ||
| 165 | textView.font = theme.font | ||
| 166 | textView.typingAttributes = theme.base | ||
| 167 | textView.drawsBackground = true | ||
| 168 | textView.backgroundColor = theme.color("background") ?? .textBackgroundColor | ||
| 169 | textView.insertionPointColor = theme.color("cursor") ?? .textColor | ||
| 170 | textView.selectedTextAttributes = [.backgroundColor: theme.color("selection") ?? .selectedTextBackgroundColor] | ||
| 171 | if let view = orgTextView { | ||
| 172 | view.lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: max(8, theme.font.pointSize - 2), weight: .regular) | ||
| 173 | view.lineNumberColor = theme.color("line-number") ?? .tertiaryLabelColor | ||
| 174 | view.currentLineNumberColor = theme.color("line-number-current") ?? .labelColor | ||
| 175 | view.updateGutter() | ||
| 176 | view.needsDisplay = true | ||
| 177 | } | ||
| 178 | } | ||
| 179 | |||
| 154 | /// Styles the whole file again, a screenful now and the rest in the background. | 180 | /// Styles the whole file again, a screenful now and the rest in the background. |
| 155 | private func restyleEverything() { | 181 | private func restyleEverything() { |
| 156 | styledUpTo = 0 | 182 | styledUpTo = 0 |
Sources/OrgEditorAppKit/Theme.swift +79 −37
| @@ -2,13 +2,46 @@ | |||
| 2 | import AppKit | 2 | import AppKit |
| 3 | import OrgPresentation | 3 | import OrgPresentation |
| 4 | 4 | ||
| 5 | /// Attributes for each style role. A fixed theme for now; the app's theme settings replace it. | 5 | /// Attributes for each style role, from a `ThemeSpec`. |
| 6 | @MainActor | 6 | @MainActor |
| 7 | struct Theme { | 7 | struct Theme { |
| 8 | let font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) | 8 | let spec: ThemeSpec |
| 9 | let font: NSFont | ||
| 10 | |||
| 11 | init(_ spec: ThemeSpec = .default) { | ||
| 12 | self.spec = spec | ||
| 13 | let size = CGFloat(max(6, spec.fontSize)) | ||
| 14 | font = spec.font.isEmpty ? NSFont.monospacedSystemFont(ofSize: size, weight: .regular) | ||
| 15 | : NSFontManager.shared.font(withFamily: spec.font, traits: [], weight: 5, size: size) | ||
| 16 | ?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular) | ||
| 17 | Self.blockBackground = color("block-background") ?? .clear | ||
| 18 | } | ||
| 19 | |||
| 20 | /// A color that follows the appearance, or nil when the theme has none for `key`. | ||
| 21 | func color(_ key: String) -> NSColor? { | ||
| 22 | let light = spec.light[key] ?? spec.dark[key] | ||
| 23 | let dark = spec.dark[key] ?? spec.light[key] | ||
| 24 | guard let light, let dark else { return nil } | ||
| 25 | return Self.dynamic(light, dark) | ||
| 26 | } | ||
| 27 | |||
| 28 | /// One color object per pair, so equal styling compares equal. | ||
| 29 | private static var colors: [[RGBA]: NSColor] = [:] | ||
| 30 | |||
| 31 | static func dynamic(_ light: RGBA, _ dark: RGBA) -> NSColor { | ||
| 32 | if let cached = colors[[light, dark]] { return cached } | ||
| 33 | let color = NSColor(name: nil) { appearance in | ||
| 34 | let c = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light | ||
| 35 | return NSColor(srgbRed: c.red, green: c.green, blue: c.blue, alpha: c.alpha) | ||
| 36 | } | ||
| 37 | colors[[light, dark]] = color | ||
| 38 | return color | ||
| 39 | } | ||
| 40 | |||
| 41 | var foreground: NSColor { color("foreground") ?? .textColor } | ||
| 9 | 42 | ||
| 10 | var base: [NSAttributedString.Key: Any] { | 43 | var base: [NSAttributedString.Key: Any] { |
| 11 | [.font: font, .foregroundColor: NSColor.textColor] | 44 | [.font: font, .foregroundColor: foreground, .paragraphStyle: paragraphStyle(firstLine: 0, wrapped: 0)] |
| 12 | } | 45 | } |
| 13 | 46 | ||
| 14 | /// Width of one column of the monospaced font. | 47 | /// Width of one column of the monospaced font. |
| @@ -16,10 +49,8 @@ struct Theme { | |||
| 16 | ("0" as NSString).size(withAttributes: [.font: font]).width | 49 | ("0" as NSString).size(withAttributes: [.font: font]).width |
| 17 | } | 50 | } |
| 18 | 51 | ||
| 19 | /// The band behind blocks. | 52 | /// The band behind blocks, read by layout fragments off the main actor. |
| 20 | nonisolated(unsafe) static let blockBackground = NSColor(name: nil) { appearance in | 53 | nonisolated(unsafe) static var blockBackground = NSColor.clear |
| 21 | appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(white: 1, alpha: 0.06) : NSColor(white: 0, alpha: 0.045) | ||
| 22 | } | ||
| 23 | 54 | ||
| 24 | /// Markup hidden from view: no width, no color. | 55 | /// Markup hidden from view: no width, no color. |
| 25 | var hidden: [NSAttributedString.Key: Any] { | 56 | var hidden: [NSAttributedString.Key: Any] { |
| @@ -30,24 +61,35 @@ struct Theme { | |||
| 30 | let style = NSMutableParagraphStyle() | 61 | let style = NSMutableParagraphStyle() |
| 31 | style.firstLineHeadIndent = CGFloat(firstLine) * column | 62 | style.firstLineHeadIndent = CGFloat(firstLine) * column |
| 32 | style.headIndent = CGFloat(wrapped) * column | 63 | style.headIndent = CGFloat(wrapped) * column |
| 64 | style.lineSpacing = CGFloat(max(0, spec.lineSpacing)) | ||
| 33 | return style | 65 | return style |
| 34 | } | 66 | } |
| 35 | 67 | ||
| 36 | func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) { | 68 | func apply(_ role: StyleRole, to storage: NSTextStorage, range: NSRange) { |
| 69 | func fg(_ key: String) { | ||
| 70 | if let color = color(key) { storage.addAttribute(.foregroundColor, value: color, range: range) } | ||
| 71 | } | ||
| 37 | switch role { | 72 | switch role { |
| 38 | case .heading(let level): | 73 | case .heading(let level): |
| 39 | let size = font.pointSize + CGFloat(max(0, 4 - level)) | 74 | let size = font.pointSize + CGFloat(max(0, spec.headingSizeStep) * max(0, 4 - level)) |
| 40 | storage.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: size, weight: .semibold), range: range) | 75 | let heading = NSFontManager.shared.convert(font.withSize(size), toHaveTrait: .boldFontMask) |
| 76 | storage.addAttribute(.font, value: heading, range: range) | ||
| 77 | fg("heading-\(min(max(level, 1), 8))") | ||
| 41 | case .leadingStars: | 78 | case .leadingStars: |
| 42 | storage.addAttribute(.foregroundColor, value: NSColor.clear, range: range) | 79 | storage.addAttribute(.foregroundColor, value: NSColor.clear, range: range) |
| 43 | case .todo: | 80 | case .todo, .done: |
| 44 | storage.addAttributes([.foregroundColor: NSColor.systemRed, .font: bold(storage, range)], range: range) | 81 | let keyword = (storage.string as NSString).substring(with: range) |
| 45 | case .done: | 82 | storage.addAttribute(.font, value: bold(storage, range), range: range) |
| 46 | storage.addAttributes([.foregroundColor: NSColor.systemGreen, .font: bold(storage, range)], range: range) | 83 | if let custom = spec.todo[keyword] { |
| 47 | case .priority: | 84 | storage.addAttribute(.foregroundColor, value: Self.dynamic(custom, custom), range: range) |
| 48 | storage.addAttribute(.foregroundColor, value: NSColor.systemOrange, range: range) | 85 | } else { |
| 49 | case .tags, .comment, .keyword, .metadata: | 86 | fg(role == .todo ? "todo" : "done") |
| 50 | storage.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: range) | 87 | } |
| 88 | case .priority: fg("priority") | ||
| 89 | case .tags: fg("tags") | ||
| 90 | case .comment: fg("comment") | ||
| 91 | case .keyword: fg("keyword") | ||
| 92 | case .metadata: fg("metadata") | ||
| 51 | case .bold: | 93 | case .bold: |
| 52 | storage.addAttribute(.font, value: bold(storage, range), range: range) | 94 | storage.addAttribute(.font, value: bold(storage, range), range: range) |
| 53 | case .italic: | 95 | case .italic: |
| @@ -57,33 +99,33 @@ struct Theme { | |||
| 57 | case .strikeThrough: | 99 | case .strikeThrough: |
| 58 | storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) | 100 | storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) |
| 59 | case .verbatim, .code, .inlineSource: | 101 | case .verbatim, .code, .inlineSource: |
| 60 | storage.addAttribute(.backgroundColor, value: NSColor.quaternarySystemFill, range: range) | 102 | if let background = color("inline-background") { storage.addAttribute(.backgroundColor, value: background, range: range) } |
| 61 | case .markup, .linkMarkup, .blockDelimiter: | 103 | fg(role == .verbatim ? "verbatim" : "code") |
| 62 | storage.addAttribute(.foregroundColor, value: NSColor.tertiaryLabelColor, range: range) | 104 | case .markup, .linkMarkup: fg("markup") |
| 105 | case .blockDelimiter: fg("block-delimiter") | ||
| 63 | case .link: | 106 | case .link: |
| 64 | storage.addAttributes([.foregroundColor: NSColor.linkColor, .underlineStyle: NSUnderlineStyle.single.rawValue], range: range) | 107 | storage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: range) |
| 65 | case .timestamp: | 108 | fg("link") |
| 66 | storage.addAttribute(.foregroundColor, value: NSColor.systemPurple, range: range) | 109 | case .timestamp: fg("timestamp") |
| 67 | case .footnote, .cookie, .target, .macro, .latex: | 110 | case .footnote, .cookie, .target, .macro, .latex: fg("special") |
| 68 | storage.addAttribute(.foregroundColor, value: NSColor.systemTeal, range: range) | 111 | case .table, .rule: fg("table") |
| 69 | case .block, .table, .rule: | 112 | case .block: |
| 70 | break | 113 | break |
| 71 | case .syntax(let category): | 114 | case .syntax(let category): |
| 72 | if let color = Self.color(category) { storage.addAttribute(.foregroundColor, value: color, range: range) } | 115 | if let key = Self.slot(category) { fg(key) } |
| 73 | } | 116 | } |
| 74 | } | 117 | } |
| 75 | 118 | ||
| 76 | /// Code colors, close to Xcode's default theme. | 119 | static func slot(_ category: SyntaxCategory) -> String? { |
| 77 | static func color(_ category: SyntaxCategory) -> NSColor? { | ||
| 78 | switch category { | 120 | switch category { |
| 79 | case .keyword: .systemPink | 121 | case .keyword: "syntax-keyword" |
| 80 | case .string: .systemRed | 122 | case .string: "syntax-string" |
| 81 | case .comment: .secondaryLabelColor | 123 | case .comment: "syntax-comment" |
| 82 | case .function: .systemBlue | 124 | case .function: "syntax-function" |
| 83 | case .type: .systemTeal | 125 | case .type: "syntax-type" |
| 84 | case .number, .constant, .escape: .systemOrange | 126 | case .number, .constant, .escape: "syntax-number" |
| 85 | case .property, .attribute, .tag: .systemIndigo | 127 | case .property, .attribute, .tag: "syntax-property" |
| 86 | case .label: .systemPurple | 128 | case .label: "syntax-label" |
| 87 | case .variable, .operator, .punctuation: nil | 129 | case .variable, .operator, .punctuation: nil |
| 88 | } | 130 | } |
| 89 | } | 131 | } |
Sources/OrgPresentation/ThemeSpec.swift added +201
| @@ -0,0 +1,201 @@ | |||
| 1 | import OrgCore | ||
| 2 | |||
| 3 | /// A color as `#rrggbb` or `#rrggbbaa`. | ||
| 4 | public struct RGBA: Hashable, Sendable { | ||
| 5 | public var red, green, blue, alpha: Double | ||
| 6 | |||
| 7 | public init(red: Double, green: Double, blue: Double, alpha: Double = 1) { | ||
| 8 | self.red = red | ||
| 9 | self.green = green | ||
| 10 | self.blue = blue | ||
| 11 | self.alpha = alpha | ||
| 12 | } | ||
| 13 | |||
| 14 | public init?(hex: String) { | ||
| 15 | var digits = Substring(hex.trimmingCharacters(in: .whitespaces)) | ||
| 16 | guard digits.first == "#" else { return nil } | ||
| 17 | digits = digits.dropFirst() | ||
| 18 | guard digits.count == 6 || digits.count == 8, let value = UInt64(digits, radix: 16) else { return nil } | ||
| 19 | let full = digits.count == 6 ? value << 8 | 0xFF : value | ||
| 20 | red = Double(full >> 24 & 0xFF) / 255 | ||
| 21 | green = Double(full >> 16 & 0xFF) / 255 | ||
| 22 | blue = Double(full >> 8 & 0xFF) / 255 | ||
| 23 | alpha = Double(full & 0xFF) / 255 | ||
| 24 | } | ||
| 25 | |||
| 26 | public var hex: String { | ||
| 27 | func byte(_ d: Double) -> String { | ||
| 28 | let s = String(Int((d * 255).rounded()), radix: 16) | ||
| 29 | return s.count == 1 ? "0" + s : s | ||
| 30 | } | ||
| 31 | return "#" + byte(red) + byte(green) + byte(blue) + (alpha < 1 ? byte(alpha) : "") | ||
| 32 | } | ||
| 33 | } | ||
| 34 | |||
| 35 | /// The editor's look: colors for each kind of text in light and dark appearance, colors for | ||
| 36 | /// TODO keywords, and type. Read from `[theme]`, `[theme.light]`, `[theme.dark]` and | ||
| 37 | /// `[theme.todo]` in config.toml, on top of the default theme. | ||
| 38 | public struct ThemeSpec: Equatable, Sendable { | ||
| 39 | /// A font family; empty for the system's monospaced font. | ||
| 40 | public var font = "" | ||
| 41 | public var fontSize = 13 | ||
| 42 | /// Points between lines. | ||
| 43 | public var lineSpacing = 2 | ||
| 44 | /// Points a heading is larger than the next level down (level 4 and deeper are body size). | ||
| 45 | public var headingSizeStep = 1 | ||
| 46 | public var light: [String: RGBA] | ||
| 47 | public var dark: [String: RGBA] | ||
| 48 | /// `org-todo-keyword-faces`: a color per keyword, the same in both appearances. | ||
| 49 | public var todo: [String: RGBA] = [:] | ||
| 50 | |||
| 51 | public init(light: [String: RGBA], dark: [String: RGBA]) { | ||
| 52 | self.light = light | ||
| 53 | self.dark = dark | ||
| 54 | } | ||
| 55 | |||
| 56 | public func color(_ key: String, dark isDark: Bool) -> RGBA? { | ||
| 57 | (isDark ? dark : light)[key] | ||
| 58 | } | ||
| 59 | |||
| 60 | // MARK: - Slots | ||
| 61 | |||
| 62 | /// Every color key, with what it colors. | ||
| 63 | public static let slots: [(key: String, description: String)] = [ | ||
| 64 | ("background", "the editor's background"), | ||
| 65 | ("foreground", "body text"), | ||
| 66 | ("cursor", "the caret"), | ||
| 67 | ("selection", "selected text's background"), | ||
| 68 | ("heading-1", "level 1 headings"), ("heading-2", "level 2 headings"), ("heading-3", "level 3 headings"), | ||
| 69 | ("heading-4", "level 4 headings"), ("heading-5", "level 5 headings"), ("heading-6", "level 6 headings"), | ||
| 70 | ("heading-7", "level 7 headings"), ("heading-8", "level 8 and deeper headings"), | ||
| 71 | ("todo", "TODO keywords not yet done"), | ||
| 72 | ("done", "DONE keywords"), | ||
| 73 | ("priority", "[#A] cookies"), | ||
| 74 | ("tags", ":tags:"), | ||
| 75 | ("link", "links"), | ||
| 76 | ("timestamp", "timestamps"), | ||
| 77 | ("code", "~code~ and inline source"), | ||
| 78 | ("verbatim", "=verbatim="), | ||
| 79 | ("inline-background", "behind ~code~ and =verbatim="), | ||
| 80 | ("markup", "link brackets and emphasis markers"), | ||
| 81 | ("comment", "comments"), | ||
| 82 | ("keyword", "#+KEYWORD lines"), | ||
| 83 | ("metadata", "planning lines, drawers, properties and clocks"), | ||
| 84 | ("special", "footnotes, statistics cookies, targets, macros and LaTeX"), | ||
| 85 | ("block-background", "the band behind blocks"), | ||
| 86 | ("block-delimiter", "#+begin_ and #+end_ lines"), | ||
| 87 | ("table", "tables"), | ||
| 88 | ("line-number", "line numbers"), | ||
| 89 | ("line-number-current", "the caret's line number"), | ||
| 90 | ("syntax-keyword", "code: keywords"), | ||
| 91 | ("syntax-string", "code: strings"), | ||
| 92 | ("syntax-comment", "code: comments"), | ||
| 93 | ("syntax-function", "code: functions"), | ||
| 94 | ("syntax-type", "code: types and modules"), | ||
| 95 | ("syntax-number", "code: numbers, constants and escapes"), | ||
| 96 | ("syntax-property", "code: properties, attributes and tags"), | ||
| 97 | ("syntax-label", "code: labels"), | ||
| 98 | ] | ||
| 99 | |||
| 100 | // MARK: - The default theme | ||
| 101 | |||
| 102 | private static func palette(_ pairs: [String: String]) -> [String: RGBA] { | ||
| 103 | pairs.compactMapValues { RGBA(hex: $0) } | ||
| 104 | } | ||
| 105 | |||
| 106 | /// Calm colors after GitHub's light and dark themes: headings colored by level, keywords | ||
| 107 | /// clear without shouting, code in the colors developers know. | ||
| 108 | public static let `default` = ThemeSpec( | ||
| 109 | light: palette([ | ||
| 110 | "background": "#ffffff", "foreground": "#1f2328", "cursor": "#0969da", "selection": "#0969da33", | ||
| 111 | "heading-1": "#0550ae", "heading-2": "#8250df", "heading-3": "#116329", "heading-4": "#953800", | ||
| 112 | "heading-5": "#0550ae", "heading-6": "#8250df", "heading-7": "#116329", "heading-8": "#953800", | ||
| 113 | "todo": "#cf222e", "done": "#1a7f37", "priority": "#bc4c00", "tags": "#6e7781", | ||
| 114 | "link": "#0969da", "timestamp": "#8250df", "code": "#953800", "verbatim": "#0a3069", "inline-background": "#afb8c133", | ||
| 115 | "markup": "#8c959f", "comment": "#6e7781", "keyword": "#6e7781", "metadata": "#6e7781", "special": "#1b7c83", | ||
| 116 | "block-background": "#f6f8fa", "block-delimiter": "#8c959f", "table": "#1f2328", | ||
| 117 | "line-number": "#8c959f", "line-number-current": "#1f2328", | ||
| 118 | "syntax-keyword": "#cf222e", "syntax-string": "#0a3069", "syntax-comment": "#6e7781", "syntax-function": "#8250df", | ||
| 119 | "syntax-type": "#953800", "syntax-number": "#0550ae", "syntax-property": "#0550ae", "syntax-label": "#8250df", | ||
| 120 | ]), | ||
| 121 | dark: palette([ | ||
| 122 | "background": "#0d1117", "foreground": "#e6edf3", "cursor": "#58a6ff", "selection": "#388bfd66", | ||
| 123 | "heading-1": "#79c0ff", "heading-2": "#d2a8ff", "heading-3": "#7ee787", "heading-4": "#ffa657", | ||
| 124 | "heading-5": "#79c0ff", "heading-6": "#d2a8ff", "heading-7": "#7ee787", "heading-8": "#ffa657", | ||
| 125 | "todo": "#ff7b72", "done": "#3fb950", "priority": "#ffa657", "tags": "#8b949e", | ||
| 126 | "link": "#58a6ff", "timestamp": "#d2a8ff", "code": "#ffa657", "verbatim": "#a5d6ff", "inline-background": "#6e768166", | ||
| 127 | "markup": "#6e7681", "comment": "#8b949e", "keyword": "#8b949e", "metadata": "#8b949e", "special": "#56d4dd", | ||
| 128 | "block-background": "#161b22", "block-delimiter": "#6e7681", "table": "#e6edf3", | ||
| 129 | "line-number": "#6e7681", "line-number-current": "#e6edf3", | ||
| 130 | "syntax-keyword": "#ff7b72", "syntax-string": "#a5d6ff", "syntax-comment": "#8b949e", "syntax-function": "#d2a8ff", | ||
| 131 | "syntax-type": "#ffa657", "syntax-number": "#79c0ff", "syntax-property": "#79c0ff", "syntax-label": "#d2a8ff", | ||
| 132 | ]) | ||
| 133 | ) | ||
| 134 | |||
| 135 | // MARK: - Reading | ||
| 136 | |||
| 137 | /// Keys of `[theme]` that aren't colors; config.toml's settings handle them. | ||
| 138 | public static let typeKeys: Set<String> = ["font", "font-size", "line-spacing", "heading-size-step", "theme-file"] | ||
| 139 | |||
| 140 | /// Whether a table holds theme colors. | ||
| 141 | public static func isThemeTable(_ name: String) -> Bool { | ||
| 142 | name == "theme" || name.hasPrefix("theme.") | ||
| 143 | } | ||
| 144 | |||
| 145 | /// `base` with the colors of `tables` laid over it, in order; problems for keys and | ||
| 146 | /// values it can't use. | ||
| 147 | public static func reading(_ tables: [TOML.Table], over base: ThemeSpec = .default, file: String = "config.toml") -> (ThemeSpec, [String]) { | ||
| 148 | var spec = base | ||
| 149 | var problems: [String] = [] | ||
| 150 | let known = Set(slots.map(\.key)) | ||
| 151 | for table in tables where isThemeTable(table.name) { | ||
| 152 | for (key, value) in table.values.sorted(by: { $0.key < $1.key }) { | ||
| 153 | if table.name == "theme", typeKeys.contains(key) { continue } | ||
| 154 | guard let text = value.string, let color = RGBA(hex: text) else { | ||
| 155 | problems.append("\(file): \(table.name).\(key) must be a color such as \"#1f2328\"") | ||
| 156 | continue | ||
| 157 | } | ||
| 158 | switch table.name { | ||
| 159 | case "theme.todo": | ||
| 160 | spec.todo[key] = color | ||
| 161 | case "theme", "theme.light", "theme.dark": | ||
| 162 | guard known.contains(key) else { | ||
| 163 | problems.append("\(file): unknown theme color \(key)") | ||
| 164 | continue | ||
| 165 | } | ||
| 166 | if table.name != "theme.dark" { spec.light[key] = color } | ||
| 167 | if table.name != "theme.light" { spec.dark[key] = color } | ||
| 168 | default: | ||
| 169 | problems.append("\(file): unknown table [\(table.name)]") | ||
| 170 | } | ||
| 171 | } | ||
| 172 | } | ||
| 173 | return (spec, problems) | ||
| 174 | } | ||
| 175 | |||
| 176 | /// The default theme written out, to copy colors from. | ||
| 177 | public static func defaultThemeFile() -> String { | ||
| 178 | var out = """ | ||
| 179 | # Orgstar's default theme, for reference: Orgstar writes this file again each time it | ||
| 180 | # starts, so change colors in config.toml. A key set under [theme] colors both | ||
| 181 | # appearances; [theme.light] and [theme.dark] set one. Colors are "#rrggbb" or | ||
| 182 | # "#rrggbbaa". [theme.todo] colors TODO keywords by name, as org-todo-keyword-faces: | ||
| 183 | # | ||
| 184 | # [theme.todo] | ||
| 185 | # WAIT = "#bf8700" | ||
| 186 | # | ||
| 187 | # A theme kept in its own file (with the same tables) is used with | ||
| 188 | # `theme-file = "name.toml"` under [theme] in config.toml. | ||
| 189 | |||
| 190 | """ | ||
| 191 | for (name, colors) in [("theme.light", ThemeSpec.default.light), ("theme.dark", ThemeSpec.default.dark)] { | ||
| 192 | out += "\n[\(name)]\n" | ||
| 193 | for slot in slots { | ||
| 194 | guard let color = colors[slot.key] else { continue } | ||
| 195 | let line = "\(slot.key) = \"\(color.hex)\"" | ||
| 196 | out += line + String(repeating: " ", count: max(2, 36 - line.count)) + "# \(slot.description)\n" | ||
| 197 | } | ||
| 198 | } | ||
| 199 | return out | ||
| 200 | } | ||
| 201 | } | ||
Sources/Orgstar/ContentView.swift +16 −1
| @@ -2,6 +2,7 @@ import AppKit | |||
| 2 | import OrgApp | 2 | import OrgApp |
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import OrgEditorAppKit | 4 | import OrgEditorAppKit |
| 5 | import OrgPresentation | ||
| 5 | import OrgIndex | 6 | import OrgIndex |
| 6 | import SwiftUI | 7 | import SwiftUI |
| 7 | import UniformTypeIdentifiers | 8 | import UniformTypeIdentifiers |
| @@ -45,6 +46,20 @@ struct ContentView: View { | |||
| 45 | @AppStorage("outlineShown") private var orgOutlineShown = true | 46 | @AppStorage("outlineShown") private var orgOutlineShown = true |
| 46 | @AppStorage("showMarkup") private var showMarkup = false | 47 | @AppStorage("showMarkup") private var showMarkup = false |
| 47 | @AppStorage("showLineNumbers") private var showLineNumbers = true | 48 | @AppStorage("showLineNumbers") private var showLineNumbers = true |
| 49 | @AppStorage("themeFont") private var themeFont = "" | ||
| 50 | @AppStorage("themeFontSize") private var themeFontSize = 13 | ||
| 51 | @AppStorage("themeLineSpacing") private var themeLineSpacing = 2 | ||
| 52 | @AppStorage("themeHeadingStep") private var themeHeadingStep = 1 | ||
| 53 | |||
| 54 | /// The theme's colors from config.toml with the type settings. | ||
| 55 | private var theme: ThemeSpec { | ||
| 56 | var spec = ConfigSync.shared.theme | ||
| 57 | spec.font = themeFont | ||
| 58 | spec.fontSize = themeFontSize | ||
| 59 | spec.lineSpacing = themeLineSpacing | ||
| 60 | spec.headingSizeStep = themeHeadingStep | ||
| 61 | return spec | ||
| 62 | } | ||
| 48 | /// The outline for a file that isn't org, off until asked for. | 63 | /// The outline for a file that isn't org, off until asked for. |
| 49 | @State private var plainOutlineShown = false | 64 | @State private var plainOutlineShown = false |
| 50 | @State private var showingImport = false | 65 | @State private var showingImport = false |
| @@ -68,7 +83,7 @@ struct ContentView: View { | |||
| 68 | if let previewPath { | 83 | if let previewPath { |
| 69 | FilePreview(path: previewPath) | 84 | FilePreview(path: previewPath) |
| 70 | } else if session.path != nil { | 85 | } else if session.path != nil { |
| 71 | EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, showLineNumbers: showLineNumbers, onAppCommand: perform, onExternal: runExternal) | 86 | EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, showLineNumbers: showLineNumbers, theme: theme, onAppCommand: perform, onExternal: runExternal) |
| 72 | .safeAreaInset(edge: .bottom, spacing: 0) { | 87 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 73 | VStack(spacing: 0) { | 88 | VStack(spacing: 0) { |
| 74 | ModeLine(session: session, commands: commands, clock: clock, perform: perform) | 89 | ModeLine(session: session, commands: commands, clock: clock, perform: perform) |
Sources/Orgstar/DevelopmentHooks.swift +4
| @@ -7,6 +7,7 @@ import AppKit | |||
| 7 | /// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it. | 7 | /// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it. |
| 8 | /// - `ORGSTAR_COMMAND`: a command to run at launch (`app.import-emacs`); a sheet it opens is | 8 | /// - `ORGSTAR_COMMAND`: a command to run at launch (`app.import-emacs`); a sheet it opens is |
| 9 | /// what the snapshot renders. | 9 | /// what the snapshot renders. |
| 10 | /// - `ORGSTAR_APPEARANCE`: `dark` or `light`, instead of the system's. | ||
| 10 | /// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main | 11 | /// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main |
| 11 | /// window to this PNG path and quit. Rendering from inside the app needs no screen-recording | 12 | /// window to this PNG path and quit. Rendering from inside the app needs no screen-recording |
| 12 | /// permission. | 13 | /// permission. |
| @@ -19,6 +20,9 @@ enum DevelopmentHooks { | |||
| 19 | @MainActor | 20 | @MainActor |
| 20 | static func scheduleSnapshot() { | 21 | static func scheduleSnapshot() { |
| 21 | let environment = ProcessInfo.processInfo.environment | 22 | let environment = ProcessInfo.processInfo.environment |
| 23 | if let appearance = environment["ORGSTAR_APPEARANCE"] { | ||
| 24 | NSApp.appearance = NSAppearance(named: appearance == "dark" ? .darkAqua : .aqua) | ||
| 25 | } | ||
| 22 | guard let path = environment["ORGSTAR_SNAPSHOT"] else { return } | 26 | guard let path = environment["ORGSTAR_SNAPSHOT"] else { return } |
| 23 | let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 | 27 | let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 |
| 24 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { | 28 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { |
Sources/Orgstar/EditorView.swift +3
| @@ -2,6 +2,7 @@ import AppKit | |||
| 2 | import OrgApp | 2 | import OrgApp |
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import OrgEditorAppKit | 4 | import OrgEditorAppKit |
| 5 | import OrgPresentation | ||
| 5 | import OrgHighlight | 6 | import OrgHighlight |
| 6 | import SwiftUI | 7 | import SwiftUI |
| 7 | 8 | ||
| @@ -19,6 +20,7 @@ struct EditorView: NSViewRepresentable { | |||
| 19 | let options: EditingOptions | 20 | let options: EditingOptions |
| 20 | let showMarkup: Bool | 21 | let showMarkup: Bool |
| 21 | let showLineNumbers: Bool | 22 | let showLineNumbers: Bool |
| 23 | let theme: ThemeSpec | ||
| 22 | let onAppCommand: (String) -> Void | 24 | let onAppCommand: (String) -> Void |
| 23 | let onExternal: (ExternalRequest) -> Void | 25 | let onExternal: (ExternalRequest) -> Void |
| 24 | 26 | ||
| @@ -71,6 +73,7 @@ struct EditorView: NSViewRepresentable { | |||
| 71 | editor.options = options | 73 | editor.options = options |
| 72 | editor.showMarkup = showMarkup | 74 | editor.showMarkup = showMarkup |
| 73 | editor.showLineNumbers = showLineNumbers | 75 | editor.showLineNumbers = showLineNumbers |
| 76 | editor.themeSpec = theme | ||
| 74 | } | 77 | } |
| 75 | container.window?.isDocumentEdited = session.isDirty | 78 | container.window?.isDocumentEdited = session.isDirty |
| 76 | if let request = session.revealRequest, request.id != coordinator.revealID { | 79 | if let request = session.revealRequest, request.id != coordinator.revealID { |
Sources/Orgstar/OrgstarApp.swift +45
| @@ -343,11 +343,21 @@ struct SettingsView: View { | |||
| 343 | @AppStorage("reminderLead") private var reminderLead = 12 | 343 | @AppStorage("reminderLead") private var reminderLead = 12 |
| 344 | @AppStorage("globalCapture") private var globalCapture = true | 344 | @AppStorage("globalCapture") private var globalCapture = true |
| 345 | @AppStorage("settingsTab") private var tab = "general" | 345 | @AppStorage("settingsTab") private var tab = "general" |
| 346 | @AppStorage("themeFont") private var themeFont = "" | ||
| 347 | @AppStorage("themeFontSize") private var themeFontSize = 13 | ||
| 348 | @AppStorage("themeLineSpacing") private var themeLineSpacing = 2 | ||
| 349 | @AppStorage("themeHeadingStep") private var themeHeadingStep = 1 | ||
| 350 | |||
| 351 | /// Installed monospaced font families. | ||
| 352 | private static let monospacedFamilies: [String] = NSFontManager.shared.availableFontFamilies.filter { family in | ||
| 353 | NSFontManager.shared.font(withFamily: family, traits: [], weight: 5, size: 13)?.isFixedPitch == true | ||
| 354 | } | ||
| 346 | 355 | ||
| 347 | var body: some View { | 356 | var body: some View { |
| 348 | TabView(selection: $tab) { | 357 | TabView(selection: $tab) { |
| 349 | Tab("General", systemImage: "gearshape", value: "general") { general } | 358 | Tab("General", systemImage: "gearshape", value: "general") { general } |
| 350 | Tab("Editing", systemImage: "text.alignleft", value: "editing") { editing } | 359 | Tab("Editing", systemImage: "text.alignleft", value: "editing") { editing } |
| 360 | Tab("Appearance", systemImage: "paintpalette", value: "appearance") { appearance } | ||
| 351 | Tab("Agenda", systemImage: "calendar", value: "agenda") { agenda } | 361 | Tab("Agenda", systemImage: "calendar", value: "agenda") { agenda } |
| 352 | Tab("Capture", systemImage: "tray.and.arrow.down", value: "capture") { capture } | 362 | Tab("Capture", systemImage: "tray.and.arrow.down", value: "capture") { capture } |
| 353 | } | 363 | } |
| @@ -434,6 +444,41 @@ struct SettingsView: View { | |||
| 434 | .frame(height: 450) | 444 | .frame(height: 450) |
| 435 | } | 445 | } |
| 436 | 446 | ||
| 447 | private var appearance: some View { | ||
| 448 | Form { | ||
| 449 | Section { | ||
| 450 | Picker("Font", selection: $themeFont) { | ||
| 451 | Text("System monospaced").tag("") | ||
| 452 | Divider() | ||
| 453 | ForEach(Self.monospacedFamilies, id: \.self) { Text($0).tag($0) } | ||
| 454 | if !themeFont.isEmpty, !Self.monospacedFamilies.contains(themeFont) { Text(themeFont).tag(themeFont) } | ||
| 455 | } | ||
| 456 | Stepper("Size: \(themeFontSize) pt", value: $themeFontSize, in: 8...36) | ||
| 457 | Stepper("Line spacing: \(themeLineSpacing) pt", value: $themeLineSpacing, in: 0...16) | ||
| 458 | Stepper("Headings grow by \(themeHeadingStep) pt a level", value: $themeHeadingStep, in: 0...8) | ||
| 459 | } footer: { | ||
| 460 | Text("Tags and tables line up in columns, so a monospaced font keeps them straight.") | ||
| 461 | .settingsFooter() | ||
| 462 | } | ||
| 463 | Section { | ||
| 464 | LabeledContent("Colors") { | ||
| 465 | HStack { | ||
| 466 | Button("Edit in config.toml") { editConfig() } | ||
| 467 | Button("Show Default Theme") { | ||
| 468 | openWindow(id: "main") | ||
| 469 | commands.jump(path: UserConfig.directory.appendingPathComponent("default-theme.toml").path, offset: 0) | ||
| 470 | } | ||
| 471 | } | ||
| 472 | } | ||
| 473 | } footer: { | ||
| 474 | Text("Set colors under [theme] (both appearances), [theme.light] or [theme.dark] in config.toml, and TODO keyword colors under [theme.todo]. default-theme.toml lists every color with the default theme's values.") | ||
| 475 | .settingsFooter() | ||
| 476 | } | ||
| 477 | } | ||
| 478 | .formStyle(.grouped) | ||
| 479 | .frame(height: 380) | ||
| 480 | } | ||
| 481 | |||
| 437 | private var agenda: some View { | 482 | private var agenda: some View { |
| 438 | Form { | 483 | Form { |
| 439 | Section { | 484 | Section { |
Tests/OrgAppTests/UserConfigTests.swift +33
| @@ -1,5 +1,6 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import OrgPresentation | ||
| 3 | import Testing | 4 | import Testing |
| 4 | @testable import OrgApp | 5 | @testable import OrgApp |
| 5 | 6 | ||
| @@ -55,6 +56,14 @@ struct UserConfigTests { | |||
| 55 | == "# top\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n") | 56 | == "# top\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n") |
| 56 | } | 57 | } |
| 57 | 58 | ||
| 59 | @Test func missingSectionsAreAdded() { | ||
| 60 | let text = "# mine\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n" | ||
| 61 | let completed = UserConfig.addingMissingSections(text, from: defaults()) | ||
| 62 | #expect(completed.hasPrefix(text)) | ||
| 63 | #expect(completed.contains("\n[theme]\nfont = \"\"") && !completed.contains("keymap")) | ||
| 64 | #expect(UserConfig.addingMissingSections(completed, from: defaults()) == completed) | ||
| 65 | } | ||
| 66 | |||
| 58 | @Test func legacyFilesAreRewritten() throws { | 67 | @Test func legacyFilesAreRewritten() throws { |
| 59 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)") | 68 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)") |
| 60 | defer { try? FileManager.default.removeItem(at: folder) } | 69 | defer { try? FileManager.default.removeItem(at: folder) } |
| @@ -90,3 +99,27 @@ struct UserConfigTests { | |||
| 90 | #expect(sync.problems.isEmpty) | 99 | #expect(sync.problems.isEmpty) |
| 91 | } | 100 | } |
| 92 | } | 101 | } |
| 102 | |||
| 103 | @MainActor | ||
| 104 | struct ThemeConfigTests { | ||
| 105 | @Test func themeTablesAreNotSettingsProblems() throws { | ||
| 106 | let name = "orgstar-theme-\(UUID().uuidString)" | ||
| 107 | let defaults = UserDefaults(suiteName: name)! | ||
| 108 | defer { defaults.removePersistentDomain(forName: name) } | ||
| 109 | let problems = UserConfig.apply("[theme]\nfont-size = 15\nlink = \"#123456\"\n[theme.dark]\nlink = \"#654321\"\n", to: defaults) | ||
| 110 | #expect(problems.isEmpty) | ||
| 111 | #expect(defaults.integer(forKey: "themeFontSize") == 15) | ||
| 112 | |||
| 113 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-theme-\(UUID().uuidString)") | ||
| 114 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 115 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | ||
| 116 | try "[theme.light]\nlink = \"#000001\"\ntodo = \"#000002\"\n".write(to: folder.appendingPathComponent("mine.toml"), atomically: true, encoding: .utf8) | ||
| 117 | try "[theme]\ntheme-file = \"mine.toml\"\nlink = \"#000003\"\n".write(to: folder.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8) | ||
| 118 | let sync = ConfigSync(url: folder.appendingPathComponent("config.toml"), defaults: defaults) | ||
| 119 | sync.start(watch: false) | ||
| 120 | #expect(sync.problems.isEmpty, "\(sync.problems)") | ||
| 121 | #expect(sync.theme.light["link"]?.hex == "#000003") | ||
| 122 | #expect(sync.theme.light["todo"]?.hex == "#000002") | ||
| 123 | #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("default-theme.toml").path)) | ||
| 124 | } | ||
| 125 | } | ||
Tests/OrgEditorAppKitTests/MarkupTests.swift +20
| @@ -1,5 +1,6 @@ | |||
| 1 | import AppKit | 1 | import AppKit |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import OrgPresentation | ||
| 3 | import Testing | 4 | import Testing |
| 4 | @testable import OrgEditorAppKit | 5 | @testable import OrgEditorAppKit |
| 5 | 6 | ||
| @@ -91,3 +92,22 @@ struct PrettyEntityTests { | |||
| 91 | #expect(storage.attribute(.orgEntityDisplay, at: last, effectiveRange: nil) == nil) | 92 | #expect(storage.attribute(.orgEntityDisplay, at: last, effectiveRange: nil) == nil) |
| 92 | } | 93 | } |
| 93 | } | 94 | } |
| 95 | |||
| 96 | @MainActor | ||
| 97 | struct EditorThemeTests { | ||
| 98 | @Test func themeChangesFontAndColors() { | ||
| 99 | let h = Harness("* Heading\nbody\n") | ||
| 100 | var spec = ThemeSpec.default | ||
| 101 | spec.fontSize = 17 | ||
| 102 | spec.light["heading-1"] = RGBA(hex: "#ff0000") | ||
| 103 | spec.dark["heading-1"] = RGBA(hex: "#ff0000") | ||
| 104 | spec.todo["WAIT"] = RGBA(hex: "#00ff00") | ||
| 105 | h.editor.themeSpec = spec | ||
| 106 | h.drainStyling() | ||
| 107 | let storage = h.textView.textStorage! | ||
| 108 | #expect((storage.attribute(.font, at: 11, effectiveRange: nil) as? NSFont)?.pointSize == 17) | ||
| 109 | let color = (storage.attribute(.foregroundColor, at: 3, effectiveRange: nil) as? NSColor)?.usingColorSpace(.sRGB) | ||
| 110 | #expect(color.map { ($0.redComponent * 255).rounded() } == 255) | ||
| 111 | #expect((storage.attribute(.font, at: 3, effectiveRange: nil) as? NSFont)?.pointSize == 20) | ||
| 112 | } | ||
| 113 | } | ||
Tests/OrgPresentationTests/PresentationTests.swift +30
| @@ -130,3 +130,33 @@ struct CyclingTests { | |||
| 130 | #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty) | 130 | #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty) |
| 131 | } | 131 | } |
| 132 | } | 132 | } |
| 133 | |||
| 134 | struct ThemeSpecTests { | ||
| 135 | @Test func readsColorsOverTheDefault() throws { | ||
| 136 | let tables = try TOML.parse(""" | ||
| 137 | [theme] | ||
| 138 | font = "Menlo" | ||
| 139 | link = "#112233" | ||
| 140 | [theme.dark] | ||
| 141 | link = "#445566cc" | ||
| 142 | heading-1 = "blue" | ||
| 143 | sparkle = "#000000" | ||
| 144 | [theme.todo] | ||
| 145 | WAIT = "#bf8700" | ||
| 146 | """) | ||
| 147 | let (spec, problems) = ThemeSpec.reading(tables) | ||
| 148 | #expect(spec.light["link"] == RGBA(hex: "#112233")) | ||
| 149 | #expect(spec.dark["link"]?.hex == "#445566cc") | ||
| 150 | #expect(spec.dark["heading-1"] == ThemeSpec.default.dark["heading-1"]) | ||
| 151 | #expect(spec.todo["WAIT"]?.hex == "#bf8700") | ||
| 152 | #expect(problems.count == 2) | ||
| 153 | } | ||
| 154 | |||
| 155 | @Test func defaultThemeFileReadsBackAsTheDefault() throws { | ||
| 156 | let (spec, problems) = ThemeSpec.reading(try TOML.parse(ThemeSpec.defaultThemeFile()), over: ThemeSpec(light: [:], dark: [:])) | ||
| 157 | #expect(problems.isEmpty) | ||
| 158 | #expect(spec.light == ThemeSpec.default.light && spec.dark == ThemeSpec.default.dark) | ||
| 159 | #expect(Set(ThemeSpec.default.light.keys) == Set(ThemeSpec.slots.map(\.key))) | ||
| 160 | #expect(Set(ThemeSpec.default.dark.keys) == Set(ThemeSpec.slots.map(\.key))) | ||
| 161 | } | ||
| 162 | } | ||