Editor: electric pairs, timestamp links, table field editor !121
24 files changed, +1004 −31
Layout: unified · split
Sources/OrgApp/OrgPreferences.swift +3 −1
| @@ -16,12 +16,14 @@ public enum OrgPreferences { | |||
| 16 | 16 | ||
| 17 | /// What commands run from the agenda and capture follow: logging and tags. | 17 | /// What commands run from the agenda and capture follow: logging and tags. |
| 18 | public static func options(_ defaults: UserDefaults = .standard) -> EditingOptions { | 18 | public static func options(_ defaults: UserDefaults = .standard) -> EditingOptions { |
| 19 | EditingOptions( | 19 | var options = EditingOptions( |
| 20 | tagsColumn: integer("tagsColumn", defaults), insertHeadingRespectContent: bool("headingAfterSubtree", defaults), | 20 | tagsColumn: integer("tagsColumn", defaults), insertHeadingRespectContent: bool("headingAfterSubtree", defaults), |
| 21 | listAllowAlphabetical: bool("alphabeticalLists", defaults), fillColumn: integer("fillColumn", defaults), | 21 | listAllowAlphabetical: bool("alphabeticalLists", defaults), fillColumn: integer("fillColumn", defaults), |
| 22 | logDone: LogHow(rawValue: string("logDone", defaults)), logIntoDrawer: string("logIntoDrawer", defaults).nonEmpty, | 22 | logDone: LogHow(rawValue: string("logDone", defaults)), logIntoDrawer: string("logIntoDrawer", defaults).nonEmpty, |
| 23 | logReschedule: LogHow(rawValue: string("logReschedule", defaults)), logRedeadline: LogHow(rawValue: string("logRedeadline", defaults)) | 23 | logReschedule: LogHow(rawValue: string("logReschedule", defaults)), logRedeadline: LogHow(rawValue: string("logRedeadline", defaults)) |
| 24 | ) | 24 | ) |
| 25 | options.electricPair = bool("electricPair", defaults) | ||
| 26 | return options | ||
| 25 | } | 27 | } |
| 26 | 28 | ||
| 27 | public static func agendaSpan(_ defaults: UserDefaults = .standard) -> Int { integer("agendaSpan", defaults) } | 29 | public static func agendaSpan(_ defaults: UserDefaults = .standard) -> Int { integer("agendaSpan", defaults) } |
Sources/OrgApp/UserConfig.swift +2
| @@ -96,6 +96,8 @@ public enum UserConfig { | |||
| 96 | comment: "truncate long lines instead of wrapping them (Emacs's default is t; Doom wraps)", legacy: ""), | 96 | comment: "truncate long lines instead of wrapping them (Emacs's default is t; Doom wraps)", legacy: ""), |
| 97 | Setting(section: "", key: "spell-check", defaultsKey: "spellCheck", kind: .bool, fallback: .bool(false), | 97 | Setting(section: "", key: "spell-check", defaultsKey: "spellCheck", kind: .bool, fallback: .bool(false), |
| 98 | comment: "check spelling while typing, outside code, links, dates, tags and keywords", legacy: ""), | 98 | comment: "check spelling while typing, outside code, links, dates, tags and keywords", legacy: ""), |
| 99 | Setting(section: "", key: "electric-pair-mode", defaultsKey: "electricPair", kind: .bool, fallback: .bool(true), | ||
| 100 | comment: "type brackets, <> and quotes in pairs as electric-pair-mode does (off in plain Emacs; Doom pairs with smartparens)", legacy: ""), | ||
| 99 | Setting(section: "", key: "org-startup-with-inline-images", defaultsKey: "startupWithInlineImages", kind: .bool, fallback: .bool(false), | 101 | Setting(section: "", key: "org-startup-with-inline-images", defaultsKey: "startupWithInlineImages", kind: .bool, fallback: .bool(false), |
| 100 | comment: "show image links as images when a file opens; #+STARTUP: (no)inlineimages overrides", legacy: ""), | 102 | comment: "show image links as images when a file opens; #+STARTUP: (no)inlineimages overrides", legacy: ""), |
| 101 | Setting(section: "", key: "org-use-speed-commands", defaultsKey: "useSpeedCommands", kind: .bool, fallback: .bool(false), | 103 | Setting(section: "", key: "org-use-speed-commands", defaultsKey: "useSpeedCommands", kind: .bool, fallback: .bool(false), |
Sources/OrgCore/Commands/BlockCommands.swift +14 −1
| @@ -111,8 +111,20 @@ public struct BlockEdit: Sendable, Equatable { | |||
| 111 | public let content: String | 111 | public let content: String |
| 112 | /// The src block's language, for highlighting. | 112 | /// The src block's language, for highlighting. |
| 113 | public let language: String? | 113 | public let language: String? |
| 114 | /// `src`, `example` or `export`. | 114 | /// `src`, `example` or `export`; `formulas` or `field` for a table's. |
| 115 | public let kind: String | 115 | public let kind: String |
| 116 | /// A table field's point and coordinate (`@2$3`). | ||
| 117 | public var point: Int? | ||
| 118 | public var coordinate: String? | ||
| 119 | |||
| 120 | /// What the editor is editing, for its title. | ||
| 121 | public var title: String { | ||
| 122 | switch kind { | ||
| 123 | case Self.formulasKind: "Editing table formulas" | ||
| 124 | case Self.fieldKind: "Editing field \(coordinate ?? "")" | ||
| 125 | default: "Editing \(language.map { "\($0) " } ?? "")\(kind) block" | ||
| 126 | } | ||
| 127 | } | ||
| 116 | 128 | ||
| 117 | /// `org-unescape-code-in-string`. | 129 | /// `org-unescape-code-in-string`. |
| 118 | public static func unescape(_ s: String) -> String { | 130 | public static func unescape(_ s: String) -> String { |
| @@ -193,6 +205,7 @@ public struct ReplaceBlockLines: OrgCommand { | |||
| 193 | return .failed("The block changed while you edited it; its edit wasn't saved.") | 205 | return .failed("The block changed while you edited it; its edit wasn't saved.") |
| 194 | } | 206 | } |
| 195 | if edit.kind == BlockEdit.formulasKind { return edit.installFormulas(content, in: context) } | 207 | if edit.kind == BlockEdit.formulasKind { return edit.installFormulas(content, in: context) } |
| 208 | if edit.kind == BlockEdit.fieldKind { return edit.installField(content, in: context) } | ||
| 196 | let lines = edit.lines(for: content) | 209 | let lines = edit.lines(for: content) |
| 197 | return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: edit.range, replacement: lines)])) | 210 | return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: edit.range, replacement: lines)])) |
| 198 | } | 211 | } |
Sources/OrgCore/Commands/Command.swift +6 −1
| @@ -36,6 +36,8 @@ public struct EditingOptions: Sendable, Equatable, Codable { | |||
| 36 | public var startupTruncated = false | 36 | public var startupTruncated = false |
| 37 | /// Spell checking while typing, outside code and markup. | 37 | /// Spell checking while typing, outside code and markup. |
| 38 | public var spellCheck = false | 38 | public var spellCheck = false |
| 39 | /// `electric-pair-mode`: brackets and quotes typed in pairs. | ||
| 40 | public var electricPair = false | ||
| 39 | /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens. | 41 | /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens. |
| 40 | public var hideDrawerStartup: Bool | 42 | public var hideDrawerStartup: Bool |
| 41 | /// `org-cycle-hide-block-startup`: blocks are folded when a file opens. | 43 | /// `org-cycle-hide-block-startup`: blocks are folded when a file opens. |
| @@ -234,6 +236,9 @@ public enum ExternalRequest: Sendable, Equatable { | |||
| 234 | case editBlock(BlockEdit) | 236 | case editBlock(BlockEdit) |
| 235 | /// Show a column view. | 237 | /// Show a column view. |
| 236 | case columnView(ColumnView.View) | 238 | case columnView(ColumnView.View) |
| 239 | /// Show the agenda for `days` days from `start` (a `Days` number), as | ||
| 240 | /// `org-follow-timestamp-link` does. | ||
| 241 | case agenda(start: Int, days: Int) | ||
| 237 | } | 242 | } |
| 238 | 243 | ||
| 239 | /// A named operation. Keys, menus, the palette and touch controls all run commands. | 244 | /// A named operation. Keys, menus, the palette and touch controls all run commands. |
| @@ -262,7 +267,7 @@ public enum Commands { | |||
| 262 | TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), | 267 | TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), |
| 263 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), | 268 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 264 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), | 269 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), |
| 265 | CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), | 270 | CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), TableEditField(), |
| 266 | UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(), SetPriority(), SetPriority(value: "A"), SetPriority(value: "B"), SetPriority(value: "C"), SetPriority(value: " "), ReadAndSetProperty(), DeleteProperty(), DeleteProperty(globally: true), PropertyAction(), NextAllowedValue(previous: false), NextAllowedValue(previous: true), InsertColumnView(), ShowColumnView(), ShowColumnView(global: true), ToggleArchiveTag(), ArchiveToSibling(), | 271 | UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(), SetPriority(), SetPriority(value: "A"), SetPriority(value: "B"), SetPriority(value: "C"), SetPriority(value: " "), ReadAndSetProperty(), DeleteProperty(), DeleteProperty(globally: true), PropertyAction(), NextAllowedValue(previous: false), NextAllowedValue(previous: true), InsertColumnView(), ShowColumnView(), ShowColumnView(global: true), ToggleArchiveTag(), ArchiveToSibling(), |
| 267 | ] | 272 | ] |
| 268 | 273 | ||
Sources/OrgCore/Commands/ElectricPair.swift added +436
| @@ -0,0 +1,436 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // `electric-pair-mode` (Emacs 31.1 elec-pair.el) in an Org buffer, with its defaults: | ||
| 4 | // `electric-pair-preserve-balance`, skipping whitespace before a closer, deleting adjacent | ||
| 5 | // pairs and opening a line between them. Delimiters are what `org-mode-syntax-table` makes | ||
| 6 | // them: () [] {} <> and "; inside a string, `prog-mode-syntax-table`'s. | ||
| 7 | |||
| 8 | /// A syntax table's view of a character, as far as pairing goes. | ||
| 9 | private enum SyntaxClass: Equatable { | ||
| 10 | case open(unichar), close(unichar), string, escape, other | ||
| 11 | } | ||
| 12 | |||
| 13 | private enum SyntaxTable { | ||
| 14 | case org, text | ||
| 15 | |||
| 16 | func classify(_ c: unichar) -> SyntaxClass { | ||
| 17 | switch c { | ||
| 18 | case 40: return .open(41) | ||
| 19 | case 91: return .open(93) | ||
| 20 | case 123: return .open(125) | ||
| 21 | case 41: return .close(40) | ||
| 22 | case 93: return .close(91) | ||
| 23 | case 125: return .close(123) | ||
| 24 | case 34: return .string | ||
| 25 | case 60: return self == .org ? .open(62) : .other | ||
| 26 | case 62: return self == .org ? .close(60) : .other | ||
| 27 | // Org makes the backslash a symbol constituent; the standard table escapes with it. | ||
| 28 | case 92: return self == .org ? .other : .escape | ||
| 29 | default: return .other | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | /// `matching-paren`. | ||
| 34 | func matching(_ c: unichar) -> unichar? { | ||
| 35 | switch classify(c) { | ||
| 36 | case .open(let m), .close(let m): return m | ||
| 37 | default: return nil | ||
| 38 | } | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | /// The text being typed into, with point, as the hook functions see the buffer. | ||
| 43 | private final class PairBuffer { | ||
| 44 | let s: NSMutableString | ||
| 45 | var point: Int | ||
| 46 | |||
| 47 | init(_ text: String, point: Int) { | ||
| 48 | s = NSMutableString(string: text) | ||
| 49 | self.point = point | ||
| 50 | } | ||
| 51 | |||
| 52 | var length: Int { s.length } | ||
| 53 | func char(_ i: Int) -> unichar? { i >= 0 && i < s.length ? s.character(at: i) : nil } | ||
| 54 | var charAfter: unichar? { char(point) } | ||
| 55 | var charBefore: unichar? { char(point - 1) } | ||
| 56 | |||
| 57 | func insert(_ text: String, at i: Int) { | ||
| 58 | s.insert(text, at: i) | ||
| 59 | if point >= i { point += (text as NSString).length } | ||
| 60 | } | ||
| 61 | |||
| 62 | func delete(_ range: Range<Int>) { | ||
| 63 | s.deleteCharacters(in: NSRange(location: range.lowerBound, length: range.count)) | ||
| 64 | if point > range.upperBound { point -= range.count } else if point > range.lowerBound { point = range.lowerBound } | ||
| 65 | } | ||
| 66 | |||
| 67 | // MARK: Parsing, as `parse-partial-sexp` and `scan-sexps` | ||
| 68 | |||
| 69 | struct State { | ||
| 70 | var depth = 0 | ||
| 71 | /// Positions of the open parens enclosing the end, innermost last (nth 9). | ||
| 72 | var opens: [Int] = [] | ||
| 73 | /// The string's delimiter while inside one (nth 3). | ||
| 74 | var stringChar: unichar? | ||
| 75 | /// Where that string starts (nth 8). | ||
| 76 | var stringStart: Int? | ||
| 77 | } | ||
| 78 | |||
| 79 | func parse(from: Int, to: Int, table: SyntaxTable) -> State { | ||
| 80 | var state = State() | ||
| 81 | var i = from | ||
| 82 | while i < to, let c = char(i) { | ||
| 83 | let cls = table.classify(c) | ||
| 84 | if let delimiter = state.stringChar { | ||
| 85 | if cls == .escape { i += 2; continue } | ||
| 86 | if c == delimiter { state.stringChar = nil; state.stringStart = nil } | ||
| 87 | } else { | ||
| 88 | switch cls { | ||
| 89 | case .open: state.depth += 1; state.opens.append(i) | ||
| 90 | case .close: state.depth -= 1; if !state.opens.isEmpty { state.opens.removeLast() } | ||
| 91 | case .string: state.stringChar = c; state.stringStart = i | ||
| 92 | case .escape: i += 2; continue | ||
| 93 | case .other: break | ||
| 94 | } | ||
| 95 | } | ||
| 96 | i += 1 | ||
| 97 | } | ||
| 98 | return state | ||
| 99 | } | ||
| 100 | |||
| 101 | /// `syntax-ppss`: the buffer's table, from the start. | ||
| 102 | func ppss(_ pos: Int) -> State { parse(from: 0, to: pos, table: .org) } | ||
| 103 | |||
| 104 | /// `electric-pair--syntax-ppss` with WHERE `(string comment)`: inside a string, its text | ||
| 105 | /// parsed with the text table. | ||
| 106 | func pairPpss(_ pos: Int) -> State { | ||
| 107 | let quick = ppss(pos) | ||
| 108 | if quick.stringChar != nil, let start = quick.stringStart { | ||
| 109 | return parse(from: start + 1, to: pos, table: .text) | ||
| 110 | } | ||
| 111 | return quick | ||
| 112 | } | ||
| 113 | |||
| 114 | enum ScanResult { | ||
| 115 | case done | ||
| 116 | /// "Containing expression ends prematurely", and where it stopped. | ||
| 117 | case premature(Int) | ||
| 118 | case unbalanced | ||
| 119 | } | ||
| 120 | |||
| 121 | /// `scan-sexps` from `from` over as many sexps as there are, forward or back. | ||
| 122 | func scan(from: Int, forward: Bool, table: SyntaxTable) -> ScanResult { | ||
| 123 | var depth = 0 | ||
| 124 | if forward { | ||
| 125 | var i = from | ||
| 126 | while let c = char(i) { | ||
| 127 | switch table.classify(c) { | ||
| 128 | case .open: depth += 1 | ||
| 129 | case .close: | ||
| 130 | depth -= 1 | ||
| 131 | if depth < 0 { return .premature(i + 1) } | ||
| 132 | case .string: | ||
| 133 | var j = i + 1 | ||
| 134 | while let d = char(j), d != c { j += table.classify(d) == .escape ? 2 : 1 } | ||
| 135 | if j >= length { return .unbalanced } | ||
| 136 | i = j | ||
| 137 | case .escape: i += 1 | ||
| 138 | case .other: break | ||
| 139 | } | ||
| 140 | i += 1 | ||
| 141 | } | ||
| 142 | } else { | ||
| 143 | var i = from - 1 | ||
| 144 | while i >= 0, let c = char(i) { | ||
| 145 | if quoted(i, table) { i -= 1; continue } | ||
| 146 | switch table.classify(c) { | ||
| 147 | case .close: depth += 1 | ||
| 148 | case .open: | ||
| 149 | depth -= 1 | ||
| 150 | if depth < 0 { return .premature(i) } | ||
| 151 | case .string: | ||
| 152 | var j = i - 1 | ||
| 153 | while j >= 0, !(char(j) == c && !quoted(j, table)) { j -= 1 } | ||
| 154 | if j < 0 { return .unbalanced } | ||
| 155 | i = j | ||
| 156 | default: break | ||
| 157 | } | ||
| 158 | i -= 1 | ||
| 159 | } | ||
| 160 | } | ||
| 161 | return depth > 0 ? .unbalanced : .done | ||
| 162 | } | ||
| 163 | |||
| 164 | /// `char-quoted`: an odd number of escapes before `i`. | ||
| 165 | private func quoted(_ i: Int, _ table: SyntaxTable) -> Bool { | ||
| 166 | var n = 0 | ||
| 167 | var j = i - 1 | ||
| 168 | while j >= 0, let c = char(j), table.classify(c) == .escape { n += 1; j -= 1 } | ||
| 169 | return n % 2 == 1 | ||
| 170 | } | ||
| 171 | |||
| 172 | /// `scan-sexps` forward by one from the open paren at `from`: past its closer, or nil on an | ||
| 173 | /// error. | ||
| 174 | func scanOne(from: Int, table: SyntaxTable) -> Int? { | ||
| 175 | var depth = 0 | ||
| 176 | var i = from | ||
| 177 | while let c = char(i) { | ||
| 178 | switch table.classify(c) { | ||
| 179 | case .open: depth += 1 | ||
| 180 | case .close: | ||
| 181 | depth -= 1 | ||
| 182 | if depth < 0 { return nil } | ||
| 183 | if depth == 0 { return i + 1 } | ||
| 184 | case .string: | ||
| 185 | var j = i + 1 | ||
| 186 | while let d = char(j), d != c { j += table.classify(d) == .escape ? 2 : 1 } | ||
| 187 | if j >= length { return nil } | ||
| 188 | i = j | ||
| 189 | if depth == 0 { return i + 1 } | ||
| 190 | case .escape: i += 1 | ||
| 191 | case .other: break | ||
| 192 | } | ||
| 193 | i += 1 | ||
| 194 | } | ||
| 195 | return nil | ||
| 196 | } | ||
| 197 | |||
| 198 | // MARK: elec-pair.el | ||
| 199 | |||
| 200 | struct Info { | ||
| 201 | enum Syntax { case open, close, string } | ||
| 202 | let syntax: Syntax | ||
| 203 | let pair: unichar | ||
| 204 | let unconditional: Bool | ||
| 205 | /// Where the string point is in starts. | ||
| 206 | let stringStart: Int? | ||
| 207 | } | ||
| 208 | |||
| 209 | /// `electric-quote-chars` pairs, from `electric-pair-pairs` (and the text pairs, the same). | ||
| 210 | static let fallback: [(unichar, unichar)] = [(34, 34), (0x2018, 0x2019), (0x201C, 0x201D)] | ||
| 211 | |||
| 212 | /// `electric-pair-syntax-info`. | ||
| 213 | func info(_ c: unichar) -> Info? { | ||
| 214 | let pre = point == 0 || ppss(point - 1).stringStart != nil | ||
| 215 | let post = ppss(point).stringStart | ||
| 216 | let stringStart = pre ? post : nil | ||
| 217 | let table: SyntaxTable = stringStart == nil ? .org : .text | ||
| 218 | switch table.classify(c) { | ||
| 219 | case .open(let m): return Info(syntax: .open, pair: m, unconditional: false, stringStart: stringStart) | ||
| 220 | case .close(let m): return Info(syntax: .close, pair: m, unconditional: false, stringStart: stringStart) | ||
| 221 | case .string: return Info(syntax: .string, pair: c, unconditional: false, stringStart: stringStart) | ||
| 222 | default: break | ||
| 223 | } | ||
| 224 | if let direct = Self.fallback.first(where: { $0.0 == c }) { | ||
| 225 | return direct.0 == direct.1 | ||
| 226 | ? Info(syntax: .string, pair: c, unconditional: true, stringStart: stringStart) | ||
| 227 | : Info(syntax: .open, pair: direct.1, unconditional: true, stringStart: stringStart) | ||
| 228 | } | ||
| 229 | if let reverse = Self.fallback.first(where: { $0.1 == c }) { | ||
| 230 | return Info(syntax: .close, pair: reverse.0, unconditional: true, stringStart: stringStart) | ||
| 231 | } | ||
| 232 | return nil | ||
| 233 | } | ||
| 234 | |||
| 235 | struct Side { | ||
| 236 | var matched: Bool | ||
| 237 | var pair: unichar? | ||
| 238 | } | ||
| 239 | |||
| 240 | /// `electric-pair--balance-info`: the innermost and outermost lists around point. | ||
| 241 | func balance(forward: Bool, stringStart: Int?) -> (innermost: Side, outermost: Side) { | ||
| 242 | let table: SyntaxTable = stringStart == nil ? .org : .text | ||
| 243 | var innermost: Side? | ||
| 244 | var outermost: Side? | ||
| 245 | let saved = point | ||
| 246 | defer { point = saved } | ||
| 247 | while outermost == nil { | ||
| 248 | switch scan(from: point, forward: forward, table: table) { | ||
| 249 | case .done, .unbalanced: | ||
| 250 | outermost = Side(matched: true) | ||
| 251 | if innermost == nil { innermost = Side(matched: true) } | ||
| 252 | case .premature(let pos): | ||
| 253 | if stringStart != nil, ppss(point).stringStart == nil { | ||
| 254 | outermost = Side(matched: true) | ||
| 255 | if innermost == nil { innermost = Side(matched: true) } | ||
| 256 | break | ||
| 257 | } | ||
| 258 | point = pos | ||
| 259 | let matched: Bool | ||
| 260 | if forward { | ||
| 261 | let state = pairPpss(point - 1) | ||
| 262 | if let start = state.opens.last, let opener = char(start) { | ||
| 263 | matched = charBefore == (table.matching(opener) ?? opener) | ||
| 264 | } else { | ||
| 265 | matched = false | ||
| 266 | } | ||
| 267 | } else if let end = scanOne(from: point, table: table), let closer = char(end - 1) { | ||
| 268 | matched = charAfter == table.matching(closer) | ||
| 269 | } else { | ||
| 270 | matched = false | ||
| 271 | } | ||
| 272 | let actual = forward ? charBefore : charAfter | ||
| 273 | if innermost == nil { innermost = Side(matched: matched, pair: actual) } | ||
| 274 | if !matched { outermost = Side(matched: matched, pair: actual) } | ||
| 275 | } | ||
| 276 | } | ||
| 277 | return (innermost!, outermost!) | ||
| 278 | } | ||
| 279 | |||
| 280 | /// `electric-pair-inhibit-if-helps-balance`, with the typed character before point. | ||
| 281 | func inhibit(_ c: unichar) -> Bool { | ||
| 282 | guard let info = info(c) else { return false } | ||
| 283 | let at = point - 1 | ||
| 284 | delete(at..<point) | ||
| 285 | defer { insert(String(utf16CodeUnits: [c], count: 1), at: at) } | ||
| 286 | switch info.syntax { | ||
| 287 | case .open: | ||
| 288 | let outermost = balance(forward: true, stringStart: info.stringStart).outermost | ||
| 289 | return outermost.matched ? false : outermost.pair == info.pair | ||
| 290 | case .string: | ||
| 291 | return ppss(length).stringChar == c | ||
| 292 | case .close: | ||
| 293 | return false | ||
| 294 | } | ||
| 295 | } | ||
| 296 | |||
| 297 | /// `electric-pair-skip-if-helps-balance`, with the typed character before point. | ||
| 298 | func skip(_ c: unichar) -> Bool { | ||
| 299 | guard let info = info(c) else { return false } | ||
| 300 | let at = point - 1 | ||
| 301 | delete(at..<point) | ||
| 302 | defer { insert(String(utf16CodeUnits: [c], count: 1), at: at) } | ||
| 303 | switch info.syntax { | ||
| 304 | case .close: | ||
| 305 | let (innermost, outermost) = balance(forward: false, stringStart: info.stringStart) | ||
| 306 | if outermost.matched { return innermost.matched } | ||
| 307 | return innermost.matched && outermost.pair != info.pair | ||
| 308 | case .string: | ||
| 309 | return ppss(point).stringChar == c | ||
| 310 | case .open: | ||
| 311 | return false | ||
| 312 | } | ||
| 313 | } | ||
| 314 | |||
| 315 | /// `electric-pair--skip-whitespace`, unless that leaves or enters a string. | ||
| 316 | func skipWhitespace() { | ||
| 317 | let saved = point | ||
| 318 | let before = ppss(point).stringStart | ||
| 319 | while let c = charAfter, c == 9 || c == 32 || c == 10 { point += 1 } | ||
| 320 | if ppss(point).stringStart != before { point = saved } | ||
| 321 | } | ||
| 322 | } | ||
| 323 | |||
| 324 | public enum ElectricPair { | ||
| 325 | /// Typing `c` into `text` with `selection` (point at its upper end, mark at its lower): | ||
| 326 | /// the text and point that `self-insert-command` and `electric-pair-post-self-insert-function` | ||
| 327 | /// leave. | ||
| 328 | public static func type(_ c: unichar, text: String, selection: Range<Int>) -> (text: String, point: Int) { | ||
| 329 | let b = PairBuffer(text, point: selection.upperBound) | ||
| 330 | let typed = String(utf16CodeUnits: [c], count: 1) | ||
| 331 | b.insert(typed, at: b.point) | ||
| 332 | let pos = b.point | ||
| 333 | guard let info = b.info(c) else { return (b.s as String, b.point) } | ||
| 334 | if !selection.isEmpty { | ||
| 335 | // `use-region-p`: the region is wrapped. | ||
| 336 | let mark = selection.lowerBound | ||
| 337 | let pair = String(utf16CodeUnits: [info.pair], count: 1) | ||
| 338 | if info.syntax == .string || info.syntax == .close { | ||
| 339 | b.insert(pair, at: mark) | ||
| 340 | } else { | ||
| 341 | b.delete(pos - 1..<pos) | ||
| 342 | b.insert(pair, at: b.point) | ||
| 343 | b.point = mark | ||
| 344 | b.insert(typed, at: b.point) | ||
| 345 | } | ||
| 346 | return (b.s as String, b.point) | ||
| 347 | } | ||
| 348 | if info.syntax == .close || info.syntax == .string { | ||
| 349 | let skipsSelf = info.unconditional || b.skip(c) | ||
| 350 | if skipsSelf { | ||
| 351 | let skipsSpace = !(info.unconditional && info.syntax == .string) | ||
| 352 | let saved = b.point | ||
| 353 | if skipsSpace { b.skipWhitespace() } | ||
| 354 | let lands = b.charAfter == c | ||
| 355 | b.point = saved | ||
| 356 | if lands { | ||
| 357 | if skipsSpace { b.skipWhitespace() } | ||
| 358 | b.delete(pos - 1..<pos) | ||
| 359 | b.point += 1 | ||
| 360 | return (b.s as String, b.point) | ||
| 361 | } | ||
| 362 | } | ||
| 363 | } | ||
| 364 | if info.syntax == .open || info.syntax == .string, info.unconditional || !b.inhibit(c) { | ||
| 365 | b.insert(String(utf16CodeUnits: [info.pair], count: 1), at: b.point) | ||
| 366 | b.point = pos | ||
| 367 | } | ||
| 368 | return (b.s as String, b.point) | ||
| 369 | } | ||
| 370 | |||
| 371 | /// DEL at `caret` between an opening delimiter and its pair: both go | ||
| 372 | /// (`electric-pair-delete-pair`). Nil leaves DEL as it is. | ||
| 373 | public static func deletePair(text: String, caret: Int) -> Range<Int>? { | ||
| 374 | let b = PairBuffer(text, point: caret) | ||
| 375 | guard let prev = b.charBefore, let next = b.charAfter, let info = b.info(prev), | ||
| 376 | info.syntax != .close, info.pair == next else { return nil } | ||
| 377 | return caret - 1..<caret + 1 | ||
| 378 | } | ||
| 379 | |||
| 380 | /// RET at `caret` between an open paren and its closer: a second newline after point | ||
| 381 | /// (`electric-pair-open-newline-between-pairs`). | ||
| 382 | public static func opensLine(text: String, caret: Int) -> Bool { | ||
| 383 | let b = PairBuffer(text, point: caret) | ||
| 384 | guard caret > 0, caret < b.length, let prev = b.charBefore, let next = b.charAfter else { return false } | ||
| 385 | return SyntaxTable.org.matching(next) == prev | ||
| 386 | } | ||
| 387 | } | ||
| 388 | |||
| 389 | /// A key typed with `electric-pair-mode` on: a pairing character, DEL or RET. It doesn't | ||
| 390 | /// apply where the key types as it would without the mode. | ||
| 391 | public struct ElectricPairKey: OrgCommand { | ||
| 392 | public let key: String | ||
| 393 | public init(key: String) { self.key = key } | ||
| 394 | public var id: String { "internal.electric-pair" } | ||
| 395 | public var title: String { "Electric Pair" } | ||
| 396 | |||
| 397 | /// The characters `electric-pair-syntax-info` knows in an Org buffer. | ||
| 398 | public static let characters: Set<String> = ["(", ")", "[", "]", "{", "}", "<", ">", "\"", "\u{2018}", "\u{2019}", "\u{201C}", "\u{201D}"] | ||
| 399 | |||
| 400 | public func applies(in context: EditContext) -> Bool { result(context) != nil } | ||
| 401 | |||
| 402 | public func run(in context: EditContext) -> CommandStep { | ||
| 403 | guard let result = result(context) else { return .failed("") } | ||
| 404 | return .commit(result) | ||
| 405 | } | ||
| 406 | |||
| 407 | private func result(_ context: EditContext) -> EditResult? { | ||
| 408 | guard context.options.electricPair else { return nil } | ||
| 409 | let selection = context.selection.first ?? context.caret..<context.caret | ||
| 410 | let ns = context.text as NSString | ||
| 411 | switch key { | ||
| 412 | case "DEL": | ||
| 413 | guard selection.isEmpty, let range = ElectricPair.deletePair(text: context.text, caret: selection.lowerBound) else { return nil } | ||
| 414 | return EditResult(baseRevision: context.revision, edits: [TextEdit(range: range, replacement: "")], selection: [range.lowerBound..<range.lowerBound]) | ||
| 415 | case "RET": | ||
| 416 | guard selection.isEmpty, ElectricPair.opensLine(text: context.text, caret: selection.lowerBound) else { return nil } | ||
| 417 | let at = selection.lowerBound | ||
| 418 | return EditResult(baseRevision: context.revision, edits: [TextEdit(range: at..<at, replacement: "\n\n")], selection: [at + 1..<at + 1]) | ||
| 419 | default: | ||
| 420 | guard Self.characters.contains(key), let unit = key.utf16.first else { return nil } | ||
| 421 | let typed = ElectricPair.type(unit, text: context.text, selection: selection) | ||
| 422 | let plain = ns.replacingCharacters(in: NSRange(location: selection.lowerBound, length: selection.count), with: key) | ||
| 423 | if typed.text == plain, typed.point == selection.lowerBound + 1 { return nil } | ||
| 424 | // One replacement: what lies between the common start and end. | ||
| 425 | let new = typed.text as NSString | ||
| 426 | var start = 0 | ||
| 427 | while start < ns.length, start < new.length, ns.character(at: start) == new.character(at: start) { start += 1 } | ||
| 428 | var end = 0 | ||
| 429 | while end < ns.length - start, end < new.length - start, | ||
| 430 | ns.character(at: ns.length - 1 - end) == new.character(at: new.length - 1 - end) { end += 1 } | ||
| 431 | let replacement = new.substring(with: NSRange(location: start, length: new.length - start - end)) | ||
| 432 | return EditResult(baseRevision: context.revision, edits: [TextEdit(range: start..<ns.length - end, replacement: replacement)], | ||
| 433 | selection: [typed.point..<typed.point]) | ||
| 434 | } | ||
| 435 | } | ||
| 436 | } | ||
Sources/OrgCore/Compute/TableFormulas.swift +63
| @@ -832,3 +832,66 @@ extension BlockEdit { | |||
| 832 | effects: result.effects + [.message("New formulas installed - press C-u C-c C-c to apply.")])) | 832 | effects: result.effects + [.message("New formulas installed - press C-u C-c C-c to apply.")])) |
| 833 | } | 833 | } |
| 834 | } | 834 | } |
| 835 | |||
| 836 | // `org-table-edit-field` (C-c `): a field's text edited apart, then put back. | ||
| 837 | extension BlockEdit { | ||
| 838 | static let fieldKind = "field" | ||
| 839 | |||
| 840 | /// The data field at `offset`, trimmed. The range is its line, to check it's unchanged. | ||
| 841 | public static func field(at offset: Int, text: String) -> BlockEdit? { | ||
| 842 | let buffer = EmacsBuffer(text, point: offset) | ||
| 843 | guard buffer.insideDataField else { return nil } | ||
| 844 | let coordinate = "@\(buffer.tableCurrentDataLine())$\(buffer.tableCurrentColumn())" | ||
| 845 | let field = buffer.saveExcursion { () -> String in | ||
| 846 | buffer.skipBackwardNot([124, 10]) | ||
| 847 | return buffer.substring(buffer.point, buffer.looking("[^|\\r\\n]*", caseInsensitive: false, setsMatch: false)![0]!.upperBound) | ||
| 848 | } | ||
| 849 | let start = buffer.lineStart(offset) | ||
| 850 | let end = buffer.lineEnd(offset) | ||
| 851 | return BlockEdit(range: start..<end, original: buffer.substring(start, end), content: field.trimmingCharacters(in: .whitespacesAndNewlines), | ||
| 852 | language: nil, kind: fieldKind, point: offset, coordinate: coordinate) | ||
| 853 | } | ||
| 854 | |||
| 855 | /// `org-table-finish-edit-field`: comment lines go, line breaks become spaces, and the | ||
| 856 | /// table is aligned. | ||
| 857 | func installField(_ content: String, in context: EditContext) -> CommandStep { | ||
| 858 | var text = content.replacingOccurrences(of: "(?m)^#.*\n?", with: "", options: .regularExpression) | ||
| 859 | text = text.replacingOccurrences(of: "[ \t]*\n[ \t\n]*", with: " ", options: .regularExpression) | ||
| 860 | text = text.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 861 | return commitBuffer(context) { buffer in | ||
| 862 | buffer.point = point ?? range.lowerBound | ||
| 863 | guard buffer.insideDataField else { throw EmacsBuffer.UserError(message: "Not in table data field") } | ||
| 864 | buffer.skipBackwardNot([124, 10]) | ||
| 865 | let end = buffer.looking("[^|\\r\\n]*", caseInsensitive: false, setsMatch: false)![0]!.upperBound | ||
| 866 | buffer.replaceRange(buffer.point, end, with: text.isEmpty ? " " : text) | ||
| 867 | buffer.point = min(buffer.lineEnd(buffer.point), buffer.point + 1) | ||
| 868 | buffer.tableAlign() | ||
| 869 | } | ||
| 870 | } | ||
| 871 | } | ||
| 872 | |||
| 873 | extension EmacsBuffer { | ||
| 874 | /// `org-table-check-inside-data-field` with NOERROR. | ||
| 875 | var insideDataField: Bool { | ||
| 876 | atTable && !atTableHline | ||
| 877 | && !saveExcursion { () -> Bool in | ||
| 878 | while let c = character(at: point - 1), c == 32 || c == 9 { point -= 1 } | ||
| 879 | return bolp | ||
| 880 | } | ||
| 881 | && looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) == nil | ||
| 882 | } | ||
| 883 | } | ||
| 884 | |||
| 885 | /// C-c `: `org-table-edit-field`, in the block editor. | ||
| 886 | public struct TableEditField: OrgCommand { | ||
| 887 | public init() {} | ||
| 888 | public var id: String { "org.table.edit-field" } | ||
| 889 | public var title: String { "Edit Table Field" } | ||
| 890 | public func applies(in context: EditContext) -> Bool { KeyContext.holds("table", in: context) } | ||
| 891 | public func run(in context: EditContext) -> CommandStep { | ||
| 892 | guard let edit = BlockEdit.field(at: context.caret, text: context.text) else { | ||
| 893 | return .failed(EmacsBuffer(context.text, point: context.caret).atTable ? "Not in table data field" : "Not at a table") | ||
| 894 | } | ||
| 895 | return .external(.editBlock(edit)) | ||
| 896 | } | ||
| 897 | } | ||
Sources/OrgCore/Keymap/Presets.swift +4
| @@ -789,6 +789,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 789 | keys = "C-c '" | 789 | keys = "C-c '" |
| 790 | command = "org.edit-special" | 790 | command = "org.edit-special" |
| 791 | [[bind]] | 791 | [[bind]] |
| 792 | keys = "C-c `" | ||
| 793 | command = "org.table.edit-field" | ||
| 794 | when = "table" | ||
| 795 | [[bind]] | ||
| 792 | keys = "C-x b" | 796 | keys = "C-x b" |
| 793 | command = "app.buffer.switch" | 797 | command = "app.buffer.switch" |
| 794 | [[bind]] | 798 | [[bind]] |
Sources/OrgCore/Links/Links.swift +48 −3
| @@ -198,16 +198,61 @@ public enum Links { | |||
| 198 | } | 198 | } |
| 199 | } | 199 | } |
| 200 | 200 | ||
| 201 | /// `org-open-at-point` on a link (C-c C-o). | 201 | /// `org-open-at-point` on a link or a timestamp (C-c C-o). |
| 202 | public struct OpenLinkAtPoint: OrgCommand { | 202 | public struct OpenLinkAtPoint: OrgCommand { |
| 203 | public init() {} | 203 | public init() {} |
| 204 | public var id: String { "org.link.open" } | 204 | public var id: String { "org.link.open" } |
| 205 | public var title: String { "Open Link" } | 205 | public var title: String { "Open Link" } |
| 206 | public func applies(in context: EditContext) -> Bool { | 206 | public func applies(in context: EditContext) -> Bool { |
| 207 | Links.link(at: context.caret, text: context.text, tree: context.tree) != nil | 207 | Links.link(at: context.caret, text: context.text, tree: context.tree) != nil |
| 208 | || Links.timestampDays(at: context.caret, text: context.text) != nil | ||
| 208 | } | 209 | } |
| 209 | public func run(in context: EditContext) -> CommandStep { | 210 | public func run(in context: EditContext) -> CommandStep { |
| 210 | guard let found = Links.link(at: context.caret, text: context.text, tree: context.tree) else { return .failed("No link to follow here") } | 211 | if let found = Links.link(at: context.caret, text: context.text, tree: context.tree) { |
| 211 | return .external(.openLink(found.link)) | 212 | return .external(.openLink(found.link)) |
| 213 | } | ||
| 214 | if let days = Links.timestampDays(at: context.caret, text: context.text) { | ||
| 215 | return .external(.agenda(start: days.start, days: days.count)) | ||
| 216 | } | ||
| 217 | return .failed("No link to follow here") | ||
| 218 | } | ||
| 219 | } | ||
| 220 | |||
| 221 | extension Links { | ||
| 222 | /// `org-at-date-range-p` with INACTIVE-OK: the range starting at one of the two brackets | ||
| 223 | /// back from `offset` that reaches it. | ||
| 224 | static func dateRange(at offset: Int, text ns: NSString) -> [Range<Int>?]? { | ||
| 225 | let stamp = "[\\[<]([0-9]{4}-[0-9]{2}-[0-9]{2}(?: .*?)?)[\\]>]" | ||
| 226 | let regex = try! NSRegularExpression(pattern: stamp + "--?-?" + stamp) | ||
| 227 | func char(_ i: Int) -> unichar? { i >= 0 && i < ns.length ? ns.character(at: i) : nil } | ||
| 228 | let brackets: Set<unichar> = [91, 60] | ||
| 229 | var p = offset | ||
| 230 | for _ in 0..<2 { | ||
| 231 | while let c = char(p - 1), !brackets.contains(c), c != 10, c != 13 { p -= 1 } | ||
| 232 | while let c = char(p - 1), brackets.contains(c) { p -= 1 } | ||
| 233 | if let m = regex.firstMatch(in: ns as String, options: .anchored, range: NSRange(location: p, length: ns.length - p)), | ||
| 234 | NSMaxRange(m.range) >= offset { | ||
| 235 | return (0..<m.numberOfRanges).map { i in m.range(at: i).location == NSNotFound ? nil : m.range(at: i).location..<NSMaxRange(m.range(at: i)) } | ||
| 236 | } | ||
| 237 | } | ||
| 238 | return nil | ||
| 239 | } | ||
| 240 | |||
| 241 | /// `org-follow-timestamp-link`: the agenda's first day and length for the date range or | ||
| 242 | /// timestamp at `offset`. Point just past a timestamp isn't on it. | ||
| 243 | public static func timestampDays(at offset: Int, text: String) -> (start: Int, count: Int)? { | ||
| 244 | let buffer = EmacsBuffer(text, point: offset) | ||
| 245 | func day(_ match: [Range<Int>?], _ group: Int) -> Int? { | ||
| 246 | guard let range = match[group] else { return nil } | ||
| 247 | let parts = buffer.substring(range.lowerBound, range.upperBound).prefix(10).split(separator: "-").compactMap { Int($0) } | ||
| 248 | return parts.count == 3 ? Days.absolute(year: parts[0], month: parts[1], day: parts[2]) : nil | ||
| 249 | } | ||
| 250 | if let range = dateRange(at: offset, text: text as NSString), let first = day(range, 1), let last = day(range, 2) { | ||
| 251 | return (first, last - first + 1) | ||
| 252 | } | ||
| 253 | if let (part, match) = buffer.atTimestamp(), part != .after, let first = day(match, 1) { | ||
| 254 | return (first, 1) | ||
| 255 | } | ||
| 256 | return nil | ||
| 212 | } | 257 | } |
| 213 | } | 258 | } |
Sources/OrgEditorAppKit/Modal.swift +2 −2
| @@ -48,8 +48,8 @@ extension OrgEditor { | |||
| 48 | func feedVim(_ keys: [KeyChord]) -> Bool { | 48 | func feedVim(_ keys: [KeyChord]) -> Bool { |
| 49 | for (index, key) in keys.enumerated() { | 49 | for (index, key) in keys.enumerated() { |
| 50 | if vim.mode == .insert, vim.passesThrough(key) { | 50 | if vim.mode == .insert, vim.passesThrough(key) { |
| 51 | if keys.count == 1 { return false } | 51 | if keys.count == 1 { return electricPair(key) } |
| 52 | insertTyped(key) | 52 | if !electricPair(key) { insertTyped(key) } |
| 53 | continue | 53 | continue |
| 54 | } | 54 | } |
| 55 | let leaving = key == KeyChord("ESC") || key == KeyChord("[", .control) || key == KeyChord("g", .control) | 55 | let leaving = key == KeyChord("ESC") || key == KeyChord("[", .control) || key == KeyChord("g", .control) |
Sources/OrgEditorAppKit/OrgEditor.swift +15 −3
| @@ -123,7 +123,8 @@ public final class OrgEditor: NSObject { | |||
| 123 | super.init() | 123 | super.init() |
| 124 | orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false } | 124 | orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false } |
| 125 | orgTextView.onCommandClick = { [weak self] offset in | 125 | orgTextView.onCommandClick = { [weak self] offset in |
| 126 | guard let self, Links.link(at: offset, text: self.document.text, tree: self.document.tree) != nil else { return false } | 126 | guard let self, Links.link(at: offset, text: self.document.text, tree: self.document.tree) != nil |
| 127 | || Links.timestampDays(at: offset, text: self.document.text) != nil else { return false } | ||
| 127 | self.setCaret(offset) | 128 | self.setCaret(offset) |
| 128 | self.run("org.link.open") | 129 | self.run("org.link.open") |
| 129 | return true | 130 | return true |
| @@ -395,7 +396,7 @@ public final class OrgEditor: NSObject { | |||
| 395 | onMessage?("\(KeySequence.format(keys)) is undefined") | 396 | onMessage?("\(KeySequence.format(keys)) is undefined") |
| 396 | return true | 397 | return true |
| 397 | case .unbound: | 398 | case .unbound: |
| 398 | return false | 399 | return electricPair(chord) |
| 399 | case .complete(let keys, let candidates): | 400 | case .complete(let keys, let candidates): |
| 400 | if keys.count > 1 { onKeysPending?(nil, []) } | 401 | if keys.count > 1 { onKeysPending?(nil, []) } |
| 401 | let context = context() | 402 | let context = context() |
| @@ -406,10 +407,21 @@ public final class OrgEditor: NSObject { | |||
| 406 | run(binding.command) | 407 | run(binding.command) |
| 407 | return true | 408 | return true |
| 408 | } | 409 | } |
| 409 | return false | 410 | return keys.count == 1 && electricPair(chord) |
| 410 | } | 411 | } |
| 411 | } | 412 | } |
| 412 | 413 | ||
| 414 | /// `electric-pair-mode` for a key the keymap leaves to the text system: true when it | ||
| 415 | /// typed the key itself. | ||
| 416 | func electricPair(_ key: KeyChord) -> Bool { | ||
| 417 | guard isOrg, options.electricPair, key.modifiers.isEmpty, !textView.hasMarkedText(), | ||
| 418 | key.key == "DEL" || key.key == "RET" || ElectricPairKey.characters.contains(key.key) else { return false } | ||
| 419 | let command = ElectricPairKey(key: key.key) | ||
| 420 | guard command.applies(in: context()) else { return false } | ||
| 421 | _ = perform(command) | ||
| 422 | return true | ||
| 423 | } | ||
| 424 | |||
| 413 | private func applies(_ id: String, in context: EditContext) -> Bool { | 425 | private func applies(_ id: String, in context: EditContext) -> Bool { |
| 414 | if !isOrg, id.hasPrefix("org.") { return false } | 426 | if !isOrg, id.hasPrefix("org.") { return false } |
| 415 | return switch EditorCommands.command(id)?.action { | 427 | return switch EditorCommands.command(id)?.action { |
Sources/Orgstar/AgendaView.swift +18
| @@ -23,6 +23,8 @@ struct AgendaView: View { | |||
| 23 | @State private var matchText = "" | 23 | @State private var matchText = "" |
| 24 | @State private var selection: String? | 24 | @State private var selection: String? |
| 25 | @State private var tick = 0 | 25 | @State private var tick = 0 |
| 26 | /// The view picker moved to the agenda for a followed timestamp, which sets its own days. | ||
| 27 | @State private var keepRequestedDays = false | ||
| 26 | @State private var message: String? | 28 | @State private var message: String? |
| 27 | @State private var prompt: PendingPrompt? | 29 | @State private var prompt: PendingPrompt? |
| 28 | @State private var promptText = "" | 30 | @State private var promptText = "" |
| @@ -241,6 +243,8 @@ struct AgendaView: View { | |||
| 241 | } | 243 | } |
| 242 | .navigationTitle(viewName.replacingOccurrences(of: "…", with: "")) | 244 | .navigationTitle(viewName.replacingOccurrences(of: "…", with: "")) |
| 243 | .onChange(of: viewName) { applyView() } | 245 | .onChange(of: viewName) { applyView() } |
| 246 | .onChange(of: commands.agendaRequest?.start, initial: true) { showRequestedDays() } | ||
| 247 | .onChange(of: commands.agendaRequest?.days) { showRequestedDays() } | ||
| 244 | .task(id: refreshKey) { | 248 | .task(id: refreshKey) { |
| 245 | agenda.span = span | 249 | agenda.span = span |
| 246 | agenda.startOffset = startOffset | 250 | agenda.startOffset = startOffset |
| @@ -293,7 +297,21 @@ struct AgendaView: View { | |||
| 293 | message = loaded.problems.first | 297 | message = loaded.problems.first |
| 294 | } | 298 | } |
| 295 | 299 | ||
| 300 | /// The days of a followed timestamp, in the agenda view. | ||
| 301 | private func showRequestedDays() { | ||
| 302 | guard let request = commands.agendaRequest else { return } | ||
| 303 | commands.agendaRequest = nil | ||
| 304 | selection = nil | ||
| 305 | if viewName != AgendaViewLoader.builtIn[0].name { | ||
| 306 | keepRequestedDays = true | ||
| 307 | viewName = AgendaViewLoader.builtIn[0].name | ||
| 308 | } | ||
| 309 | agenda.mode = .agenda(span: request.days, startOffset: nil) | ||
| 310 | agenda.start = request.start | ||
| 311 | } | ||
| 312 | |||
| 296 | private func applyView() { | 313 | private func applyView() { |
| 314 | if keepRequestedDays { return keepRequestedDays = false } | ||
| 297 | selection = nil | 315 | selection = nil |
| 298 | agenda.start = nil | 316 | agenda.start = nil |
| 299 | switch viewName { | 317 | switch viewName { |
Sources/Orgstar/BlockEditorView.swift +2 −2
| @@ -13,7 +13,7 @@ struct BlockEditorView: View { | |||
| 13 | 13 | ||
| 14 | var body: some View { | 14 | var body: some View { |
| 15 | VStack(alignment: .leading, spacing: 8) { | 15 | VStack(alignment: .leading, spacing: 8) { |
| 16 | Text(edit.kind == "formulas" ? "Editing table formulas" : "Editing \(edit.language.map { "\($0) " } ?? "")\(edit.kind) block") | 16 | Text(edit.title) |
| 17 | .font(.headline) | 17 | .font(.headline) |
| 18 | CodeEditor(text: edit.content, language: edit.language, box: box) { save in finish(save ? box.text : nil) } | 18 | CodeEditor(text: edit.content, language: edit.language, box: box) { save in finish(save ? box.text : nil) } |
| 19 | .frame(minWidth: 560, minHeight: 320) | 19 | .frame(minWidth: 560, minHeight: 320) |
| @@ -23,7 +23,7 @@ struct BlockEditorView: View { | |||
| 23 | Spacer() | 23 | Spacer() |
| 24 | Button("Cancel", role: .cancel) { finish(nil) } | 24 | Button("Cancel", role: .cancel) { finish(nil) } |
| 25 | .keyboardShortcut(.cancelAction) | 25 | .keyboardShortcut(.cancelAction) |
| 26 | Button("Save Block") { finish(box.text) } | 26 | Button(edit.kind == "field" ? "Save Field" : "Save Block") { finish(box.text) } |
| 27 | .keyboardShortcut(.return, modifiers: .command) | 27 | .keyboardShortcut(.return, modifiers: .command) |
| 28 | } | 28 | } |
| 29 | } | 29 | } |
Sources/Orgstar/ContentView.swift +5 −1
| @@ -45,6 +45,7 @@ struct ContentView: View { | |||
| 45 | @AppStorage("startupAlignAllTables") private var startupAlignAllTables = false | 45 | @AppStorage("startupAlignAllTables") private var startupAlignAllTables = false |
| 46 | @AppStorage("startupTruncated") private var startupTruncated = false | 46 | @AppStorage("startupTruncated") private var startupTruncated = false |
| 47 | @AppStorage("spellCheck") private var spellCheck = false | 47 | @AppStorage("spellCheck") private var spellCheck = false |
| 48 | @AppStorage("electricPair") private var electricPair = true | ||
| 48 | @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true | 49 | @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true |
| 49 | @AppStorage("hideBlockStartup") private var hideBlockStartup = false | 50 | @AppStorage("hideBlockStartup") private var hideBlockStartup = false |
| 50 | 51 | ||
| @@ -55,7 +56,7 @@ struct ContentView: View { | |||
| 55 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline, | 56 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline, |
| 56 | useSpeedCommands: useSpeedCommands, startupWithInlineImages: startupWithInlineImages, | 57 | useSpeedCommands: useSpeedCommands, startupWithInlineImages: startupWithInlineImages, |
| 57 | startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables, | 58 | startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables, |
| 58 | startupTruncated: startupTruncated, spellCheck: spellCheck | 59 | startupTruncated: startupTruncated, spellCheck: spellCheck, electricPair: electricPair |
| 59 | ) | 60 | ) |
| 60 | } | 61 | } |
| 61 | /// A block being edited apart (`org-edit-special`). | 62 | /// A block being edited apart (`org-edit-special`). |
| @@ -422,6 +423,9 @@ struct ContentView: View { | |||
| 422 | switch request { | 423 | switch request { |
| 423 | case .openLink(let link): | 424 | case .openLink(let link): |
| 424 | if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) } | 425 | if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) } |
| 426 | case .agenda(let start, let days): | ||
| 427 | commands.agendaRequest = (start, days) | ||
| 428 | openWindow(id: "agenda") | ||
| 425 | case .editBlock(let edit): | 429 | case .editBlock(let edit): |
| 426 | blockEdit = BlockEditRequest(edit: edit) | 430 | blockEdit = BlockEditRequest(edit: edit) |
| 427 | case .columnView(let view): | 431 | case .columnView(let view): |
Sources/Orgstar/OrgstarApp.swift +2
| @@ -17,6 +17,8 @@ final class AppCommands { | |||
| 17 | /// A location another window asked the main window to show. | 17 | /// A location another window asked the main window to show. |
| 18 | private(set) var jumpRequest: (path: String, offset: Int, id: Int)? | 18 | private(set) var jumpRequest: (path: String, offset: Int, id: Int)? |
| 19 | private var jumpID = 0 | 19 | private var jumpID = 0 |
| 20 | /// Days the agenda window should show, from a followed timestamp. | ||
| 21 | var agendaRequest: (start: Int, days: Int)? | ||
| 20 | /// The editor showing the open file, for menus and the palette. | 22 | /// The editor showing the open file, for menus and the palette. |
| 21 | var editor: OrgEditor? | 23 | var editor: OrgEditor? |
| 22 | /// The pending key prefix, and what can follow it. | 24 | /// The pending key prefix, and what can follow it. |
Sources/Orgstar/Preferences.swift +4 −2
| @@ -18,9 +18,9 @@ enum Preferences { | |||
| 18 | tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int, | 18 | tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int, |
| 19 | hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String, | 19 | hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String, |
| 20 | hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String, useSpeedCommands: Bool, startupWithInlineImages: Bool, | 20 | hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String, useSpeedCommands: Bool, startupWithInlineImages: Bool, |
| 21 | startupIndented: Bool, hideLeadingStars: Bool, startupAlignAllTables: Bool, startupTruncated: Bool, spellCheck: Bool | 21 | startupIndented: Bool, hideLeadingStars: Bool, startupAlignAllTables: Bool, startupTruncated: Bool, spellCheck: Bool, electricPair: Bool |
| 22 | ) -> EditingOptions { | 22 | ) -> EditingOptions { |
| 23 | EditingOptions( | 23 | var options = EditingOptions( |
| 24 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, | 24 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, |
| 25 | metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn, | 25 | metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn, |
| 26 | hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true, | 26 | hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true, |
| @@ -31,5 +31,7 @@ enum Preferences { | |||
| 31 | startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables, | 31 | startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables, |
| 32 | startupTruncated: startupTruncated, spellCheck: spellCheck | 32 | startupTruncated: startupTruncated, spellCheck: spellCheck |
| 33 | ) | 33 | ) |
| 34 | options.electricPair = electricPair | ||
| 35 | return options | ||
| 34 | } | 36 | } |
| 35 | } | 37 | } |
Sources/OrgstarMobile/AgendaScreen.swift +4
| @@ -65,6 +65,10 @@ struct AgendaScreen: View { | |||
| 65 | } | 65 | } |
| 66 | .listStyle(.insetGrouped) | 66 | .listStyle(.insetGrouped) |
| 67 | .navigationTitle(viewName) | 67 | .navigationTitle(viewName) |
| 68 | .onChange(of: agenda.start) { | ||
| 69 | // Days a followed timestamp asked for. | ||
| 70 | if agenda.start != nil, case .agenda = agenda.mode { viewName = "Agenda" } | ||
| 71 | } | ||
| 68 | .overlay { | 72 | .overlay { |
| 69 | if workspace.rootPaths.isEmpty { | 73 | if workspace.rootPaths.isEmpty { |
| 70 | ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders.")) | 74 | ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders.")) |
Sources/OrgstarMobile/EditorScreen.swift +50 −3
| @@ -27,9 +27,20 @@ struct EditorScreen: View { | |||
| 27 | let reply: (String?) -> Void | 27 | let reply: (String?) -> Void |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | enum Sheet: String, Identifiable { | 30 | /// One sheet at a time: two presenters on this screen interfere. |
| 31 | enum Sheet: Identifiable { | ||
| 31 | case palette, conflict, syncCopies, recovery | 32 | case palette, conflict, syncCopies, recovery |
| 32 | var id: String { rawValue } | 33 | case block(BlockEdit) |
| 34 | |||
| 35 | var id: String { | ||
| 36 | switch self { | ||
| 37 | case .palette: "palette" | ||
| 38 | case .conflict: "conflict" | ||
| 39 | case .syncCopies: "syncCopies" | ||
| 40 | case .recovery: "recovery" | ||
| 41 | case .block(let edit): "block \(edit.range)" | ||
| 42 | } | ||
| 43 | } | ||
| 33 | } | 44 | } |
| 34 | 45 | ||
| 35 | var body: some View { | 46 | var body: some View { |
| @@ -81,6 +92,11 @@ struct EditorScreen: View { | |||
| 81 | case .conflict: ConflictSheet(session: session) | 92 | case .conflict: ConflictSheet(session: session) |
| 82 | case .syncCopies: SyncConflictSheet(session: session) { message = $0 } | 93 | case .syncCopies: SyncConflictSheet(session: session) { message = $0 } |
| 83 | case .recovery: RecoverySheet(session: session) { message = $0 } | 94 | case .recovery: RecoverySheet(session: session) { message = $0 } |
| 95 | case .block(let edit): | ||
| 96 | BlockEditSheet(edit: edit) { content in | ||
| 97 | self.sheet = nil | ||
| 98 | if let content, let editor { _ = editor.perform(ReplaceBlockLines(edit: edit, content: content)) } | ||
| 99 | } | ||
| 84 | } | 100 | } |
| 85 | } | 101 | } |
| 86 | .onChange(of: session.conflict) { _, conflict in | 102 | .onChange(of: session.conflict) { _, conflict in |
| @@ -115,6 +131,8 @@ struct EditorScreen: View { | |||
| 115 | case .message(let text): message = text | 131 | case .message(let text): message = text |
| 116 | } | 132 | } |
| 117 | } | 133 | } |
| 134 | editor.onEditBlock = { edit in sheet = .block(edit) } | ||
| 135 | editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) } | ||
| 118 | editor.onAppCommand = { id in | 136 | editor.onAppCommand = { id in |
| 119 | switch id { | 137 | switch id { |
| 120 | case "app.clock.in": message = clock.clockIn(session) | 138 | case "app.clock.in": message = clock.clockIn(session) |
| @@ -311,6 +329,35 @@ struct PromptSheet: View { | |||
| 311 | } | 329 | } |
| 312 | } | 330 | } |
| 313 | 331 | ||
| 332 | /// `org-edit-special` and `org-table-edit-field`: the text edited apart, put back with Save. | ||
| 333 | struct BlockEditSheet: View { | ||
| 334 | let edit: BlockEdit | ||
| 335 | let finish: (String?) -> Void | ||
| 336 | @State private var text = "" | ||
| 337 | @FocusState private var focused: Bool | ||
| 338 | |||
| 339 | var body: some View { | ||
| 340 | NavigationStack { | ||
| 341 | TextEditor(text: $text) | ||
| 342 | .font(.body.monospaced()) | ||
| 343 | .autocorrectionDisabled() | ||
| 344 | .textInputAutocapitalization(.never) | ||
| 345 | .focused($focused) | ||
| 346 | .padding(.horizontal, 8) | ||
| 347 | .navigationTitle(edit.title.replacingOccurrences(of: "Editing ", with: "").capitalized(with: nil)) | ||
| 348 | .navigationBarTitleDisplayMode(.inline) | ||
| 349 | .toolbar { | ||
| 350 | ToolbarItem(placement: .cancellationAction) { Button("Cancel") { finish(nil) } } | ||
| 351 | ToolbarItem(placement: .confirmationAction) { Button("Save") { finish(text) } } | ||
| 352 | } | ||
| 353 | } | ||
| 354 | .onAppear { | ||
| 355 | text = edit.content | ||
| 356 | focused = true | ||
| 357 | } | ||
| 358 | } | ||
| 359 | } | ||
| 360 | |||
| 314 | /// Every command that applies at the caret, by name. | 361 | /// Every command that applies at the caret, by name. |
| 315 | struct CommandPalette: View { | 362 | struct CommandPalette: View { |
| 316 | let editor: MobileEditor | 363 | let editor: MobileEditor |
| @@ -318,7 +365,7 @@ struct CommandPalette: View { | |||
| 318 | @Environment(\.dismiss) private var dismiss | 365 | @Environment(\.dismiss) private var dismiss |
| 319 | 366 | ||
| 320 | private static let viewCommands: [(id: String, title: String)] = [ | 367 | private static let viewCommands: [(id: String, title: String)] = [ |
| 321 | ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), ("org.link.open", "Open Link"), | 368 | ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), |
| 322 | ("edit.undo", "Undo"), ("edit.redo", "Redo"), | 369 | ("edit.undo", "Undo"), ("edit.redo", "Redo"), |
| 323 | ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"), | 370 | ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"), |
| 324 | ("app.clock.goto", "Go to Clocked Entry"), | 371 | ("app.clock.goto", "Go to Clocked Entry"), |
Sources/OrgstarMobile/MobileEditor.swift +62 −12
| @@ -60,6 +60,12 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 60 | var onMessage: ((String) -> Void)? | 60 | var onMessage: ((String) -> Void)? |
| 61 | var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)? | 61 | var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)? |
| 62 | var onLink: ((OrgLink) -> Void)? | 62 | var onLink: ((OrgLink) -> Void)? |
| 63 | /// Edits being applied, which the typing hooks leave alone. | ||
| 64 | private var applyingEdits = false | ||
| 65 | /// A block, a table's formulas or a field to edit apart (C-c ', C-c `). | ||
| 66 | var onEditBlock: ((BlockEdit) -> Void)? | ||
| 67 | /// A followed timestamp's agenda days: the first (a `Days` number) and how many. | ||
| 68 | var onAgenda: ((Int, Int) -> Void)? | ||
| 63 | /// Commands the app runs, as `app.clock.in`. | 69 | /// Commands the app runs, as `app.clock.in`. |
| 64 | var onAppCommand: ((String) -> Void)? | 70 | var onAppCommand: ((String) -> Void)? |
| 65 | /// Tags used across the workspace, for completion. | 71 | /// Tags used across the workspace, for completion. |
| @@ -158,6 +164,10 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 158 | guard let answer else { return self.onMessage?("Quit") ?? () } | 164 | guard let answer else { return self.onMessage?("Quit") ?? () } |
| 159 | _ = self.perform(command, now: now, answers: answers.merging([prompt.key: answer]) { $1 }) | 165 | _ = self.perform(command, now: now, answers: answers.merging([prompt.key: answer]) { $1 }) |
| 160 | } | 166 | } |
| 167 | case .external(.editBlock(let edit)) where onEditBlock != nil: | ||
| 168 | onEditBlock?(edit) | ||
| 169 | case .external(.agenda(let start, let count)) where onAgenda != nil: | ||
| 170 | onAgenda?(start, count) | ||
| 161 | case .external: | 171 | case .external: |
| 162 | onMessage?("\(command.title) needs Emacs, which runs on the Mac.") | 172 | onMessage?("\(command.title) needs Emacs, which runs on the Mac.") |
| 163 | } | 173 | } |
| @@ -203,7 +213,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 203 | return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil | 213 | return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil |
| 204 | case "org.cycle-global", "edit.undo", "edit.redo": return true | 214 | case "org.cycle-global", "edit.undo", "edit.redo": return true |
| 205 | case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto": return onAppCommand != nil | 215 | case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto": return onAppCommand != nil |
| 206 | case "org.link.open": return Links.link(at: context.caret, text: context.text, tree: context.tree) != nil | 216 | case "org.link.open": return OpenLinkAtPoint().applies(in: context) |
| 207 | default: return false | 217 | default: return false |
| 208 | } | 218 | } |
| 209 | } | 219 | } |
| @@ -272,10 +282,14 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 272 | } | 282 | } |
| 273 | 283 | ||
| 274 | private func openLinkAtCaret() { | 284 | private func openLinkAtCaret() { |
| 275 | guard let link = Links.link(at: textView.selectedRange.location, text: document.text, tree: document.tree)?.link else { | 285 | let caret = textView.selectedRange.location |
| 276 | return onMessage?("No link here") ?? () | 286 | if let link = Links.link(at: caret, text: document.text, tree: document.tree)?.link { |
| 287 | onLink?(link) | ||
| 288 | } else if let days = Links.timestampDays(at: caret, text: document.text) { | ||
| 289 | onAgenda?(days.start, days.count) | ||
| 290 | } else { | ||
| 291 | onMessage?("No link here") | ||
| 277 | } | 292 | } |
| 278 | onLink?(link) | ||
| 279 | } | 293 | } |
| 280 | 294 | ||
| 281 | // MARK: - Editing | 295 | // MARK: - Editing |
| @@ -283,15 +297,31 @@ final class MobileEditor: NSObject, DocumentBuffer { | |||
| 283 | /// Applies `edits` (against the current text) through the text view as one undo step. | 297 | /// Applies `edits` (against the current text) through the text view as one undo step. |
| 284 | func applyEdits(_ edits: [TextEdit]) { | 298 | func applyEdits(_ edits: [TextEdit]) { |
| 285 | guard !edits.isEmpty else { return } | 299 | guard !edits.isEmpty else { return } |
| 286 | let undo = textView.undoManager | 300 | applyingEdits = true |
| 287 | undo?.beginUndoGrouping() | 301 | defer { applyingEdits = false } |
| 288 | for edit in edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { | 302 | // Straight into the storage: replacing through text input would take the active |
| 289 | guard let start = textView.position(from: textView.beginningOfDocument, offset: edit.range.lowerBound), | 303 | // keyboard's substitutions (smart dashes) when another view has the keyboard. |
| 290 | let end = textView.position(from: textView.beginningOfDocument, offset: edit.range.upperBound), | 304 | let storage = textView.textStorage |
| 291 | let range = textView.textRange(from: start, to: end) else { continue } | 305 | let old = storage.string as NSString |
| 292 | textView.replace(range, withText: edit.replacement) | 306 | let ascending = edits.sorted { $0.range.lowerBound < $1.range.lowerBound } |
| 307 | var inverse: [TextEdit] = [] | ||
| 308 | var delta = 0 | ||
| 309 | for edit in ascending { | ||
| 310 | let start = edit.range.lowerBound + delta | ||
| 311 | let length = (edit.replacement as NSString).length | ||
| 312 | inverse.append(TextEdit(range: start..<start + length, replacement: old.substring(with: NSRange(edit.range)))) | ||
| 313 | delta += length - edit.range.count | ||
| 314 | } | ||
| 315 | let caret = textView.selectedRange | ||
| 316 | storage.beginEditing() | ||
| 317 | for edit in ascending.reversed() { | ||
| 318 | storage.replaceCharacters(in: NSRange(edit.range), with: edit.replacement) | ||
| 319 | } | ||
| 320 | storage.endEditing() | ||
| 321 | textView.undoManager?.registerUndo(withTarget: self) { editor in | ||
| 322 | editor.applyEdits(inverse) | ||
| 323 | editor.textView.selectedRange = caret | ||
| 293 | } | 324 | } |
| 294 | undo?.endUndoGrouping() | ||
| 295 | } | 325 | } |
| 296 | 326 | ||
| 297 | /// The storage changed (typing, a command, undo): the document takes the same edit, and | 327 | /// The storage changed (typing, a command, undo): the document takes the same edit, and |
| @@ -432,6 +462,26 @@ extension MobileEditor: UITextViewDelegate { | |||
| 432 | func textViewDidChangeSelection(_ textView: UITextView) { | 462 | func textViewDidChangeSelection(_ textView: UITextView) { |
| 433 | selectionChanged() | 463 | selectionChanged() |
| 434 | } | 464 | } |
| 465 | |||
| 466 | /// `electric-pair-mode` for typed brackets and quotes, DEL and RET, from either keyboard. | ||
| 467 | func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { | ||
| 468 | guard options.electricPair, !applyingEdits, textView.markedTextRange == nil else { return true } | ||
| 469 | let selected = textView.selectedRange | ||
| 470 | let key: String | ||
| 471 | if text.isEmpty, selected.length == 0, range.length == 1, NSMaxRange(range) == selected.location { | ||
| 472 | key = "DEL" | ||
| 473 | } else if range == selected, text == "\n" { | ||
| 474 | key = "RET" | ||
| 475 | } else if range == selected, ElectricPairKey.characters.contains(text) { | ||
| 476 | key = text | ||
| 477 | } else { | ||
| 478 | return true | ||
| 479 | } | ||
| 480 | let command = ElectricPairKey(key: key) | ||
| 481 | guard command.applies(in: editContext()) else { return true } | ||
| 482 | _ = perform(command) | ||
| 483 | return false | ||
| 484 | } | ||
| 435 | } | 485 | } |
| 436 | 486 | ||
| 437 | extension MobileEditor: NSTextStorageDelegate { | 487 | extension MobileEditor: NSTextStorageDelegate { |
Sources/OrgstarMobile/MobileModel.swift +7
| @@ -27,6 +27,13 @@ final class MobileModel { | |||
| 27 | private(set) var configProblems: [String] = [] | 27 | private(set) var configProblems: [String] = [] |
| 28 | /// An entry to show, from a tapped reminder. | 28 | /// An entry to show, from a tapped reminder. |
| 29 | var openRequest: ReaderRoute? | 29 | var openRequest: ReaderRoute? |
| 30 | /// Agenda days to show, from a followed timestamp: the first (a `Days` number) and how many. | ||
| 31 | var agendaRequest: AgendaDays? | ||
| 32 | |||
| 33 | struct AgendaDays: Equatable { | ||
| 34 | let start: Int | ||
| 35 | let count: Int | ||
| 36 | } | ||
| 30 | /// Bumped when settings apply, so views that read them update. | 37 | /// Bumped when settings apply, so views that read them update. |
| 31 | private(set) var settingsVersion = 0 | 38 | private(set) var settingsVersion = 0 |
| 32 | @ObservationIgnored private var presenter: FolderPresenter? | 39 | @ObservationIgnored private var presenter: FolderPresenter? |
Sources/OrgstarMobile/OrgstarMobileApp.swift +8
| @@ -149,6 +149,14 @@ struct RootView: View { | |||
| 149 | tab = .agenda | 149 | tab = .agenda |
| 150 | agendaPath.append(route) | 150 | agendaPath.append(route) |
| 151 | } | 151 | } |
| 152 | .onChange(of: model.agendaRequest) { | ||
| 153 | guard let request = model.agendaRequest else { return } | ||
| 154 | model.agendaRequest = nil | ||
| 155 | agenda.mode = .agenda(span: request.count, startOffset: nil) | ||
| 156 | agenda.start = request.start | ||
| 157 | tab = .agenda | ||
| 158 | agendaPath = NavigationPath() | ||
| 159 | } | ||
| 152 | .onChange(of: workspace.changedFile?.version) { | 160 | .onChange(of: workspace.changedFile?.version) { |
| 153 | // An open file takes in what changed on disk. | 161 | // An open file takes in what changed on disk. |
| 154 | if let path = workspace.changedFile?.path { session.fileChanged(path) } | 162 | if let path = workspace.changedFile?.path { session.fileChanged(path) } |
Tests/OrgCoreTests/ElectricPairTests.swift added +97
| @@ -0,0 +1,97 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// Keys typed with `electric-pair-mode` in an Org buffer, against Emacs. | ||
| 6 | struct ElectricPairTests { | ||
| 7 | struct Typing { | ||
| 8 | let text: String | ||
| 9 | let point: Int | ||
| 10 | var mark: Int? | ||
| 11 | let keys: [String] | ||
| 12 | } | ||
| 13 | |||
| 14 | static let cases: [Typing] = [ | ||
| 15 | Typing(text: "", point: 0, keys: ["("]), | ||
| 16 | Typing(text: "foo", point: 3, keys: ["(", "x", ")"]), | ||
| 17 | Typing(text: ")", point: 0, keys: ["("]), | ||
| 18 | Typing(text: "(", point: 1, keys: [")"]), | ||
| 19 | Typing(text: "", point: 0, keys: ["\"", "a", "\""]), | ||
| 20 | Typing(text: "a \"b", point: 4, keys: ["\""]), | ||
| 21 | Typing(text: "(foo )", point: 4, keys: [")"]), | ||
| 22 | Typing(text: "x", point: 1, keys: ["<", ">"]), | ||
| 23 | Typing(text: "", point: 0, keys: ["[", "[", "a", "]", "]"]), | ||
| 24 | Typing(text: "{}", point: 1, keys: ["}"]), | ||
| 25 | Typing(text: "(())", point: 2, keys: [")", ")"]), | ||
| 26 | Typing(text: "[(])", point: 2, keys: [")"]), | ||
| 27 | Typing(text: "()", point: 1, keys: ["DEL"]), | ||
| 28 | Typing(text: "(x)", point: 2, keys: ["DEL"]), | ||
| 29 | Typing(text: "\"\"", point: 1, keys: ["DEL"]), | ||
| 30 | Typing(text: "()", point: 1, keys: ["RET"]), | ||
| 31 | Typing(text: "word", point: 4, mark: 0, keys: ["("]), | ||
| 32 | Typing(text: "word", point: 4, mark: 0, keys: [")"]), | ||
| 33 | Typing(text: "word", point: 4, mark: 0, keys: ["\""]), | ||
| 34 | Typing(text: "say ", point: 4, keys: ["“", "hi", "”"]), | ||
| 35 | Typing(text: "\"a b\"", point: 2, keys: ["("]), | ||
| 36 | Typing(text: "\"a b\"", point: 2, keys: ["<"]), | ||
| 37 | Typing(text: "* Heading\nit", point: 12, keys: ["'", "s"]), | ||
| 38 | Typing(text: "a (b", point: 4, keys: [")"]), | ||
| 39 | Typing(text: "a b)", point: 2, keys: ["("]), | ||
| 40 | Typing(text: "(a\n )", point: 2, keys: [")"]), | ||
| 41 | Typing(text: "word", point: 0, keys: ["\""]), | ||
| 42 | Typing(text: "say \"x\" and ", point: 12, keys: ["\"", "y", "\""]), | ||
| 43 | Typing(text: "- item (see [[x]]", point: 17, keys: [")"]), | ||
| 44 | Typing(text: "<2026-10-06 Tue>", point: 16, keys: ["DEL"]), | ||
| 45 | Typing(text: "f(\"(\")", point: 4, keys: [")"]), | ||
| 46 | ] | ||
| 47 | |||
| 48 | /// The keys typed into our side: `ElectricPairKey` where it applies, otherwise as the | ||
| 49 | /// text view types them. | ||
| 50 | static func ours(_ c: Typing) -> (text: String, point: Int) { | ||
| 51 | var text = c.text | ||
| 52 | var selection = (c.mark ?? c.point)..<c.point | ||
| 53 | if let mark = c.mark, mark > c.point { selection = c.point..<mark } | ||
| 54 | var options = EditingOptions.org | ||
| 55 | options.electricPair = true | ||
| 56 | let units = c.keys.flatMap { key in key == "DEL" || key == "RET" ? [key] : key.map { String($0) } } | ||
| 57 | for key in units { | ||
| 58 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [selection], options: options) | ||
| 59 | let command = ElectricPairKey(key: key) | ||
| 60 | if command.applies(in: context), case .commit(let result) = command.run(in: context) { | ||
| 61 | for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { text = edit.apply(to: text) } | ||
| 62 | selection = result.selection!.first! | ||
| 63 | continue | ||
| 64 | } | ||
| 65 | let ns = text as NSString | ||
| 66 | let caret = selection.upperBound | ||
| 67 | switch key { | ||
| 68 | case "DEL": | ||
| 69 | text = ns.replacingCharacters(in: NSRange(location: caret - 1, length: 1), with: "") | ||
| 70 | selection = caret - 1..<caret - 1 | ||
| 71 | default: | ||
| 72 | let typed = key == "RET" ? "\n" : key | ||
| 73 | text = ns.replacingCharacters(in: NSRange(location: selection.lowerBound, length: selection.count), with: typed) | ||
| 74 | selection = selection.lowerBound + 1..<selection.lowerBound + 1 | ||
| 75 | } | ||
| 76 | } | ||
| 77 | return (text, selection.upperBound) | ||
| 78 | } | ||
| 79 | |||
| 80 | @Test func typesLikeEmacs() throws { | ||
| 81 | let cases = Self.cases.map { c in | ||
| 82 | let keys = c.keys.map { key in | ||
| 83 | key == "DEL" || key == "RET" ? "(execute-kbd-macro (kbd \"\(key)\"))" | ||
| 84 | : key.map { "(execute-kbd-macro \(lispString(String($0))))" }.joined(separator: " ") | ||
| 85 | }.joined(separator: " ") | ||
| 86 | let region = c.mark.map { "(set-mark \(EmacsOracle.point($0, in: c.text))) (activate-mark)" } ?? "" | ||
| 87 | return EmacsOracle.Case(text: c.text, point: EmacsOracle.point(c.point, in: c.text), form: """ | ||
| 88 | (progn (switch-to-buffer (current-buffer)) (electric-pair-local-mode 1) (transient-mark-mode 1) \(region) \(keys)) | ||
| 89 | """) | ||
| 90 | } | ||
| 91 | for (c, theirs) in zip(Self.cases, try EmacsOracle.run(cases)) { | ||
| 92 | let ours = Self.ours(c) | ||
| 93 | #expect(ours.text == theirs.text && EmacsOracle.point(ours.point, in: ours.text) == theirs.point, | ||
| 94 | "\(c.text.debugDescription) at \(c.point) keys \(c.keys)\nours: \(ours.text.debugDescription) \(ours.point)\nemacs: \(theirs.text.debugDescription) \(theirs.point) \(theirs.error)") | ||
| 95 | } | ||
| 96 | } | ||
| 97 | } | ||
Tests/OrgCoreTests/TableEditFieldTests.swift added +56
| @@ -0,0 +1,56 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// C-c ` (`org-table-edit-field`): what the field editor holds, and the table once it's done. | ||
| 6 | struct TableEditFieldTests { | ||
| 7 | static let text = "Intro\n| Name | Note |\n|------+------|\n| Ann | a [[https://x.org][link]] here |\n| Bob | |\n| Cy | c |\nAfter\n" | ||
| 8 | |||
| 9 | static func offsets() -> [Int] { | ||
| 10 | let ns = text as NSString | ||
| 11 | return [ns.range(of: "Ann").location + 1, ns.range(of: "link]").location, ns.range(of: "Bob |").location + 5, | ||
| 12 | ns.range(of: "| c").location + 2, ns.range(of: "Note").location] | ||
| 13 | } | ||
| 14 | |||
| 15 | @Test func editsTheFieldLikeOrg() throws { | ||
| 16 | let offsets = Self.offsets() | ||
| 17 | let shown = try offsets.map { offset in | ||
| 18 | try EmacsOracle.evaluate(Self.text, "(list (with-current-buffer (find-file-noselect \"oracle.org\") (goto-char \(EmacsOracle.point(offset, in: Self.text))) (org-table-edit-field nil) (buffer-substring-no-properties (point-min) (point-max))))").first | ||
| 19 | } | ||
| 20 | for (offset, theirs) in zip(offsets, shown) { | ||
| 21 | let edit = try #require(BlockEdit.field(at: offset, text: Self.text)) | ||
| 22 | #expect("#\n# Edit field \(edit.coordinate ?? "") and finish with C-c C-c\n#\n" + edit.content == theirs) | ||
| 23 | } | ||
| 24 | let edited = "#\n# Edit field and finish with C-c C-c\n#\n first line\n\n second\tline \n" | ||
| 25 | let cases = offsets.map { offset in | ||
| 26 | EmacsOracle.Case(text: Self.text, point: EmacsOracle.point(offset, in: Self.text), form: """ | ||
| 27 | (let ((b (current-buffer))) | ||
| 28 | (org-table-edit-field nil) (erase-buffer) (insert \(lispString(edited))) | ||
| 29 | (cl-letf (((symbol-function 'set-window-configuration) (lambda (&rest _) (set-buffer b))) | ||
| 30 | ((symbol-function 'select-window) #'ignore)) | ||
| 31 | (org-table-finish-edit-field)) | ||
| 32 | (set-buffer b)) | ||
| 33 | """) | ||
| 34 | } | ||
| 35 | for (offset, theirs) in zip(offsets, try EmacsOracle.run(cases)) { | ||
| 36 | let edit = try #require(BlockEdit.field(at: offset, text: Self.text)) | ||
| 37 | let ours = runCommand(ReplaceBlockLines(edit: edit, content: edited), Self.text, caret: 0) | ||
| 38 | #expect(ours.text == theirs.text, "ours: \(ours.text.debugDescription) \(ours.failure ?? "")\nemacs: \(theirs.text.debugDescription) \(theirs.error)") | ||
| 39 | } | ||
| 40 | // Emptied, the field gets a space, as `org-table-get-field` puts one. | ||
| 41 | let edit = try #require(BlockEdit.field(at: offsets[0], text: Self.text)) | ||
| 42 | let emptied = try EmacsOracle.run([EmacsOracle.Case(text: Self.text, point: EmacsOracle.point(offsets[0], in: Self.text), form: """ | ||
| 43 | (let ((b (current-buffer))) | ||
| 44 | (org-table-edit-field nil) (erase-buffer) | ||
| 45 | (cl-letf (((symbol-function 'set-window-configuration) (lambda (&rest _) (set-buffer b))) | ||
| 46 | ((symbol-function 'select-window) #'ignore)) | ||
| 47 | (org-table-finish-edit-field)) | ||
| 48 | (set-buffer b)) | ||
| 49 | """)]) | ||
| 50 | #expect(runCommand(ReplaceBlockLines(edit: edit, content: ""), Self.text, caret: 0).text == emptied[0].text) | ||
| 51 | // Outside a data field there's nothing to edit. | ||
| 52 | let hline = (Self.text as NSString).range(of: "|---").location + 2 | ||
| 53 | #expect(BlockEdit.field(at: hline, text: Self.text) == nil) | ||
| 54 | #expect(BlockEdit.field(at: 2, text: Self.text) == nil) | ||
| 55 | } | ||
| 56 | } | ||
Tests/OrgCoreTests/TimestampLinkTests.swift added +40
| @@ -0,0 +1,40 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// C-c C-o on timestamps and date ranges: the agenda days `org-follow-timestamp-link` shows. | ||
| 6 | struct TimestampLinkTests { | ||
| 7 | static let text = """ | ||
| 8 | * TODO Task | ||
| 9 | SCHEDULED: <2026-10-08 Thu> DEADLINE: <2026-10-12 Mon 9:00> | ||
| 10 | :LOGBOOK: | ||
| 11 | CLOCK: [2026-10-05 Mon 10:00]--[2026-10-06 Tue 11:30] => 25:30 | ||
| 12 | :END: | ||
| 13 | Trip <2026-10-20 Tue>--<2026-10-23 Fri> and [2026-11-01 Sun] then <2026-12-24 Thu 18:00-20:00>. | ||
| 14 | No date here. | ||
| 15 | |||
| 16 | """ | ||
| 17 | |||
| 18 | @Test func opensTheAgendaLikeOrg() throws { | ||
| 19 | let ns = Self.text as NSString | ||
| 20 | var offsets: [Int] = [] | ||
| 21 | for needle in ["<2026-10-08", "Thu>", "<2026-10-12", "9:00>", "[2026-10-05", "Tue 11:30]", "=> 25", "<2026-10-20", "--<2026-10-23", "Fri>", "[2026-11-01", "Sun]", "18:00-20", "No date"] { | ||
| 22 | let at = ns.range(of: needle).location | ||
| 23 | offsets += [at, at + 1, at + 3] | ||
| 24 | } | ||
| 25 | let range = ns.range(of: "Sun]") | ||
| 26 | offsets.append(range.location + range.length) | ||
| 27 | let form = """ | ||
| 28 | (let (r) (require 'org-agenda) (condition-case nil | ||
| 29 | (cl-letf (((symbol-function 'org-agenda-list) (lambda (&optional _ start span &rest _) (setq r (format "%s %s" start span))))) | ||
| 30 | (org-open-at-point)) | ||
| 31 | (error nil)) | ||
| 32 | (erase-buffer) (insert (or r "nil"))) | ||
| 33 | """ | ||
| 34 | let results = try EmacsOracle.run(offsets.map { EmacsOracle.Case(text: Self.text, point: EmacsOracle.point($0, in: Self.text), form: form) }) | ||
| 35 | for (offset, theirs) in zip(offsets, results) { | ||
| 36 | let ours = Links.timestampDays(at: offset, text: Self.text).map { "\($0.start) \($0.count)" } ?? "nil" | ||
| 37 | #expect(ours == theirs.text, "at \(offset) \(ns.substring(with: NSRange(location: offset, length: min(8, ns.length - offset))).debugDescription): ours \(ours), emacs \(theirs.text) \(theirs.error)") | ||
| 38 | } | ||
| 39 | } | ||
| 40 | } | ||
Tests/OrgEditorAppKitTests/ElectricPairEditorTests.swift added +56
| @@ -0,0 +1,56 @@ | |||
| 1 | import AppKit | ||
| 2 | import OrgCore | ||
| 3 | import Testing | ||
| 4 | @testable import OrgEditorAppKit | ||
| 5 | |||
| 6 | /// `electric-pair-mode` through key events, in the Emacs and Doom presets. | ||
| 7 | @MainActor | ||
| 8 | struct ElectricPairEditorTests { | ||
| 9 | func harness(_ text: String, on: Bool = true) -> Harness { | ||
| 10 | let h = Harness(text) | ||
| 11 | var options = h.editor.options | ||
| 12 | options.electricPair = on | ||
| 13 | h.editor.options = options | ||
| 14 | return h | ||
| 15 | } | ||
| 16 | |||
| 17 | func key(_ h: Harness, _ characters: String, code: UInt16 = 0) { | ||
| 18 | KeyTests().send(h, characters: characters, ignoring: characters, code: code) | ||
| 19 | } | ||
| 20 | |||
| 21 | @Test func pairsSkipsAndDeletes() { | ||
| 22 | let h = harness("Text \n") | ||
| 23 | h.caret(at: 5) | ||
| 24 | key(h, "(") | ||
| 25 | #expect(h.editor.document.text == "Text ()\n") | ||
| 26 | #expect(h.textView.selectedRange().location == 6) | ||
| 27 | key(h, "\u{7f}", code: 51) | ||
| 28 | #expect(h.editor.document.text == "Text \n") | ||
| 29 | key(h, "[") | ||
| 30 | key(h, "\r", code: 36) | ||
| 31 | #expect(h.editor.document.text == "Text [\n\n]\n") | ||
| 32 | #expect(h.textView.selectedRange().location == 7) | ||
| 33 | h.caret(at: 8) | ||
| 34 | key(h, "]") | ||
| 35 | #expect(h.editor.document.text == "Text [\n\n]\n") | ||
| 36 | #expect(h.textView.selectedRange().location == 9) | ||
| 37 | } | ||
| 38 | |||
| 39 | @Test func offTypesAsUsual() { | ||
| 40 | let h = harness("Text \n", on: false) | ||
| 41 | h.caret(at: 5) | ||
| 42 | #expect(!h.editor.electricPair(KeyChord("("))) | ||
| 43 | #expect(h.editor.document.text == "Text \n") | ||
| 44 | } | ||
| 45 | |||
| 46 | @Test func doomInsertState() { | ||
| 47 | let h = harness("x\n") | ||
| 48 | h.editor.keymap = KeymapPreset.doom.keymap | ||
| 49 | h.editor.modal = true | ||
| 50 | h.caret(at: 1) | ||
| 51 | ModalTests().type(h, "a") | ||
| 52 | key(h, "\"") | ||
| 53 | #expect(h.editor.document.text == "x\"\"\n") | ||
| 54 | #expect(h.textView.selectedRange().location == 2) | ||
| 55 | } | ||
| 56 | } | ||