Editor: electric pairs, timestamp links, table field editor !121

merged merged by cmc on 2026-10-07 01:38 UTC · krz/orgstar:editor-pairs-timestamps-fields into main

24 files changed, +1004 −31

Layout: unified · split

Sources/OrgApp/OrgPreferences.swift +3 −1
@@ -16,12 +16,14 @@ public enum OrgPreferences {
1616
1717 /// What commands run from the agenda and capture follow: logging and tags.
1818 public static func options(_ defaults: UserDefaults = .standard) -> EditingOptions {
19 EditingOptions(
19 var options = EditingOptions(
2020 tagsColumn: integer("tagsColumn", defaults), insertHeadingRespectContent: bool("headingAfterSubtree", defaults),
2121 listAllowAlphabetical: bool("alphabeticalLists", defaults), fillColumn: integer("fillColumn", defaults),
2222 logDone: LogHow(rawValue: string("logDone", defaults)), logIntoDrawer: string("logIntoDrawer", defaults).nonEmpty,
2323 logReschedule: LogHow(rawValue: string("logReschedule", defaults)), logRedeadline: LogHow(rawValue: string("logRedeadline", defaults))
2424 )
25 options.electricPair = bool("electricPair", defaults)
26 return options
2527 }
2628
2729 public static func agendaSpan(_ defaults: UserDefaults = .standard) -> Int { integer("agendaSpan", defaults) }
Sources/OrgApp/UserConfig.swift +2
@@ -96,6 +96,8 @@ public enum UserConfig {
9696 comment: "truncate long lines instead of wrapping them (Emacs's default is t; Doom wraps)", legacy: ""),
9797 Setting(section: "", key: "spell-check", defaultsKey: "spellCheck", kind: .bool, fallback: .bool(false),
9898 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: ""),
99101 Setting(section: "", key: "org-startup-with-inline-images", defaultsKey: "startupWithInlineImages", kind: .bool, fallback: .bool(false),
100102 comment: "show image links as images when a file opens; #+STARTUP: (no)inlineimages overrides", legacy: ""),
101103 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 {
111111 public let content: String
112112 /// The src block's language, for highlighting.
113113 public let language: String?
114 /// `src`, `example` or `export`.
114 /// `src`, `example` or `export`; `formulas` or `field` for a table's.
115115 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 }
116128
117129 /// `org-unescape-code-in-string`.
118130 public static func unescape(_ s: String) -> String {
@@ -193,6 +205,7 @@ public struct ReplaceBlockLines: OrgCommand {
193205 return .failed("The block changed while you edited it; its edit wasn't saved.")
194206 }
195207 if edit.kind == BlockEdit.formulasKind { return edit.installFormulas(content, in: context) }
208 if edit.kind == BlockEdit.fieldKind { return edit.installField(content, in: context) }
196209 let lines = edit.lines(for: content)
197210 return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: edit.range, replacement: lines)]))
198211 }
Sources/OrgCore/Commands/Command.swift +6 −1
@@ -36,6 +36,8 @@ public struct EditingOptions: Sendable, Equatable, Codable {
3636 public var startupTruncated = false
3737 /// Spell checking while typing, outside code and markup.
3838 public var spellCheck = false
39 /// `electric-pair-mode`: brackets and quotes typed in pairs.
40 public var electricPair = false
3941 /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens.
4042 public var hideDrawerStartup: Bool
4143 /// `org-cycle-hide-block-startup`: blocks are folded when a file opens.
@@ -234,6 +236,9 @@ public enum ExternalRequest: Sendable, Equatable {
234236 case editBlock(BlockEdit)
235237 /// Show a column view.
236238 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)
237242}
238243
239244/// A named operation. Keys, menus, the palette and touch controls all run commands.
@@ -262,7 +267,7 @@ public enum Commands {
262267 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
263268 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
264269 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(),
266271 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(),
267272 ]
268273
Sources/OrgCore/Commands/ElectricPair.swift added +436
@@ -0,0 +1,436 @@
1import 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.
9private enum SyntaxClass: Equatable {
10 case open(unichar), close(unichar), string, escape, other
11}
12
13private 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.
43private 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
324public 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.
391public 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 {
832832 effects: result.effects + [.message("New formulas installed - press C-u C-c C-c to apply.")]))
833833 }
834834}
835
836// `org-table-edit-field` (C-c `): a field's text edited apart, then put back.
837extension 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
873extension 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.
886public 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 {
789789 keys = "C-c '"
790790 command = "org.edit-special"
791791 [[bind]]
792 keys = "C-c `"
793 command = "org.table.edit-field"
794 when = "table"
795 [[bind]]
792796 keys = "C-x b"
793797 command = "app.buffer.switch"
794798 [[bind]]
Sources/OrgCore/Links/Links.swift +48 −3
@@ -198,16 +198,61 @@ public enum Links {
198198 }
199199}
200200
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).
202202public struct OpenLinkAtPoint: OrgCommand {
203203 public init() {}
204204 public var id: String { "org.link.open" }
205205 public var title: String { "Open Link" }
206206 public func applies(in context: EditContext) -> Bool {
207207 Links.link(at: context.caret, text: context.text, tree: context.tree) != nil
208 || Links.timestampDays(at: context.caret, text: context.text) != nil
208209 }
209210 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 return .external(.openLink(found.link))
211 if let found = Links.link(at: context.caret, text: context.text, tree: context.tree) {
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
221extension 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
212257 }
213258}
Sources/OrgEditorAppKit/Modal.swift +2 −2
@@ -48,8 +48,8 @@ extension OrgEditor {
4848 func feedVim(_ keys: [KeyChord]) -> Bool {
4949 for (index, key) in keys.enumerated() {
5050 if vim.mode == .insert, vim.passesThrough(key) {
51 if keys.count == 1 { return false }
52 insertTyped(key)
51 if keys.count == 1 { return electricPair(key) }
52 if !electricPair(key) { insertTyped(key) }
5353 continue
5454 }
5555 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 {
123123 super.init()
124124 orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false }
125125 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 }
127128 self.setCaret(offset)
128129 self.run("org.link.open")
129130 return true
@@ -395,7 +396,7 @@ public final class OrgEditor: NSObject {
395396 onMessage?("\(KeySequence.format(keys)) is undefined")
396397 return true
397398 case .unbound:
398 return false
399 return electricPair(chord)
399400 case .complete(let keys, let candidates):
400401 if keys.count > 1 { onKeysPending?(nil, []) }
401402 let context = context()
@@ -406,10 +407,21 @@ public final class OrgEditor: NSObject {
406407 run(binding.command)
407408 return true
408409 }
409 return false
410 return keys.count == 1 && electricPair(chord)
410411 }
411412 }
412413
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
413425 private func applies(_ id: String, in context: EditContext) -> Bool {
414426 if !isOrg, id.hasPrefix("org.") { return false }
415427 return switch EditorCommands.command(id)?.action {
Sources/Orgstar/AgendaView.swift +18
@@ -23,6 +23,8 @@ struct AgendaView: View {
2323 @State private var matchText = ""
2424 @State private var selection: String?
2525 @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
2628 @State private var message: String?
2729 @State private var prompt: PendingPrompt?
2830 @State private var promptText = ""
@@ -241,6 +243,8 @@ struct AgendaView: View {
241243 }
242244 .navigationTitle(viewName.replacingOccurrences(of: "…", with: ""))
243245 .onChange(of: viewName) { applyView() }
246 .onChange(of: commands.agendaRequest?.start, initial: true) { showRequestedDays() }
247 .onChange(of: commands.agendaRequest?.days) { showRequestedDays() }
244248 .task(id: refreshKey) {
245249 agenda.span = span
246250 agenda.startOffset = startOffset
@@ -293,7 +297,21 @@ struct AgendaView: View {
293297 message = loaded.problems.first
294298 }
295299
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
296313 private func applyView() {
314 if keepRequestedDays { return keepRequestedDays = false }
297315 selection = nil
298316 agenda.start = nil
299317 switch viewName {
Sources/Orgstar/BlockEditorView.swift +2 −2
@@ -13,7 +13,7 @@ struct BlockEditorView: View {
1313
1414 var body: some View {
1515 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)
1717 .font(.headline)
1818 CodeEditor(text: edit.content, language: edit.language, box: box) { save in finish(save ? box.text : nil) }
1919 .frame(minWidth: 560, minHeight: 320)
@@ -23,7 +23,7 @@ struct BlockEditorView: View {
2323 Spacer()
2424 Button("Cancel", role: .cancel) { finish(nil) }
2525 .keyboardShortcut(.cancelAction)
26 Button("Save Block") { finish(box.text) }
26 Button(edit.kind == "field" ? "Save Field" : "Save Block") { finish(box.text) }
2727 .keyboardShortcut(.return, modifiers: .command)
2828 }
2929 }
Sources/Orgstar/ContentView.swift +5 −1
@@ -45,6 +45,7 @@ struct ContentView: View {
4545 @AppStorage("startupAlignAllTables") private var startupAlignAllTables = false
4646 @AppStorage("startupTruncated") private var startupTruncated = false
4747 @AppStorage("spellCheck") private var spellCheck = false
48 @AppStorage("electricPair") private var electricPair = true
4849 @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true
4950 @AppStorage("hideBlockStartup") private var hideBlockStartup = false
5051
@@ -55,7 +56,7 @@ struct ContentView: View {
5556 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline,
5657 useSpeedCommands: useSpeedCommands, startupWithInlineImages: startupWithInlineImages,
5758 startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables,
58 startupTruncated: startupTruncated, spellCheck: spellCheck
59 startupTruncated: startupTruncated, spellCheck: spellCheck, electricPair: electricPair
5960 )
6061 }
6162 /// A block being edited apart (`org-edit-special`).
@@ -422,6 +423,9 @@ struct ContentView: View {
422423 switch request {
423424 case .openLink(let link):
424425 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")
425429 case .editBlock(let edit):
426430 blockEdit = BlockEditRequest(edit: edit)
427431 case .columnView(let view):
Sources/Orgstar/OrgstarApp.swift +2
@@ -17,6 +17,8 @@ final class AppCommands {
1717 /// A location another window asked the main window to show.
1818 private(set) var jumpRequest: (path: String, offset: Int, id: Int)?
1919 private var jumpID = 0
20 /// Days the agenda window should show, from a followed timestamp.
21 var agendaRequest: (start: Int, days: Int)?
2022 /// The editor showing the open file, for menus and the palette.
2123 var editor: OrgEditor?
2224 /// The pending key prefix, and what can follow it.
Sources/Orgstar/Preferences.swift +4 −2
@@ -18,9 +18,9 @@ enum Preferences {
1818 tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int,
1919 hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String,
2020 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
2222 ) -> EditingOptions {
23 EditingOptions(
23 var options = EditingOptions(
2424 tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree,
2525 metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn,
2626 hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true,
@@ -31,5 +31,7 @@ enum Preferences {
3131 startupIndented: startupIndented, hideLeadingStars: hideLeadingStars, startupAlignAllTables: startupAlignAllTables,
3232 startupTruncated: startupTruncated, spellCheck: spellCheck
3333 )
34 options.electricPair = electricPair
35 return options
3436 }
3537}
Sources/OrgstarMobile/AgendaScreen.swift +4
@@ -65,6 +65,10 @@ struct AgendaScreen: View {
6565 }
6666 .listStyle(.insetGrouped)
6767 .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 }
6872 .overlay {
6973 if workspace.rootPaths.isEmpty {
7074 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 {
2727 let reply: (String?) -> Void
2828 }
2929
30 enum Sheet: String, Identifiable {
30 /// One sheet at a time: two presenters on this screen interfere.
31 enum Sheet: Identifiable {
3132 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 }
3344 }
3445
3546 var body: some View {
@@ -81,6 +92,11 @@ struct EditorScreen: View {
8192 case .conflict: ConflictSheet(session: session)
8293 case .syncCopies: SyncConflictSheet(session: session) { message = $0 }
8394 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 }
84100 }
85101 }
86102 .onChange(of: session.conflict) { _, conflict in
@@ -115,6 +131,8 @@ struct EditorScreen: View {
115131 case .message(let text): message = text
116132 }
117133 }
134 editor.onEditBlock = { edit in sheet = .block(edit) }
135 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
118136 editor.onAppCommand = { id in
119137 switch id {
120138 case "app.clock.in": message = clock.clockIn(session)
@@ -311,6 +329,35 @@ struct PromptSheet: View {
311329 }
312330}
313331
332/// `org-edit-special` and `org-table-edit-field`: the text edited apart, put back with Save.
333struct 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
314361/// Every command that applies at the caret, by name.
315362struct CommandPalette: View {
316363 let editor: MobileEditor
@@ -318,7 +365,7 @@ struct CommandPalette: View {
318365 @Environment(\.dismiss) private var dismiss
319366
320367 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"),
322369 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
323370 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"),
324371 ("app.clock.goto", "Go to Clocked Entry"),
Sources/OrgstarMobile/MobileEditor.swift +62 −12
@@ -60,6 +60,12 @@ final class MobileEditor: NSObject, DocumentBuffer {
6060 var onMessage: ((String) -> Void)?
6161 var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)?
6262 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)?
6369 /// Commands the app runs, as `app.clock.in`.
6470 var onAppCommand: ((String) -> Void)?
6571 /// Tags used across the workspace, for completion.
@@ -158,6 +164,10 @@ final class MobileEditor: NSObject, DocumentBuffer {
158164 guard let answer else { return self.onMessage?("Quit") ?? () }
159165 _ = self.perform(command, now: now, answers: answers.merging([prompt.key: answer]) { $1 })
160166 }
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)
161171 case .external:
162172 onMessage?("\(command.title) needs Emacs, which runs on the Mac.")
163173 }
@@ -203,7 +213,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
203213 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
204214 case "org.cycle-global", "edit.undo", "edit.redo": return true
205215 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)
207217 default: return false
208218 }
209219 }
@@ -272,10 +282,14 @@ final class MobileEditor: NSObject, DocumentBuffer {
272282 }
273283
274284 private func openLinkAtCaret() {
275 guard let link = Links.link(at: textView.selectedRange.location, text: document.text, tree: document.tree)?.link else {
276 return onMessage?("No link here") ?? ()
285 let caret = textView.selectedRange.location
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")
277292 }
278 onLink?(link)
279293 }
280294
281295 // MARK: - Editing
@@ -283,15 +297,31 @@ final class MobileEditor: NSObject, DocumentBuffer {
283297 /// Applies `edits` (against the current text) through the text view as one undo step.
284298 func applyEdits(_ edits: [TextEdit]) {
285299 guard !edits.isEmpty else { return }
286 let undo = textView.undoManager
287 undo?.beginUndoGrouping()
288 for edit in edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
289 guard let start = textView.position(from: textView.beginningOfDocument, offset: edit.range.lowerBound),
290 let end = textView.position(from: textView.beginningOfDocument, offset: edit.range.upperBound),
291 let range = textView.textRange(from: start, to: end) else { continue }
292 textView.replace(range, withText: edit.replacement)
300 applyingEdits = true
301 defer { applyingEdits = false }
302 // Straight into the storage: replacing through text input would take the active
303 // keyboard's substitutions (smart dashes) when another view has the keyboard.
304 let storage = textView.textStorage
305 let old = storage.string as NSString
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
293324 }
294 undo?.endUndoGrouping()
295325 }
296326
297327 /// The storage changed (typing, a command, undo): the document takes the same edit, and
@@ -432,6 +462,26 @@ extension MobileEditor: UITextViewDelegate {
432462 func textViewDidChangeSelection(_ textView: UITextView) {
433463 selectionChanged()
434464 }
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 }
435485}
436486
437487extension MobileEditor: NSTextStorageDelegate {
Sources/OrgstarMobile/MobileModel.swift +7
@@ -27,6 +27,13 @@ final class MobileModel {
2727 private(set) var configProblems: [String] = []
2828 /// An entry to show, from a tapped reminder.
2929 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 }
3037 /// Bumped when settings apply, so views that read them update.
3138 private(set) var settingsVersion = 0
3239 @ObservationIgnored private var presenter: FolderPresenter?
Sources/OrgstarMobile/OrgstarMobileApp.swift +8
@@ -149,6 +149,14 @@ struct RootView: View {
149149 tab = .agenda
150150 agendaPath.append(route)
151151 }
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 }
152160 .onChange(of: workspace.changedFile?.version) {
153161 // An open file takes in what changed on disk.
154162 if let path = workspace.changedFile?.path { session.fileChanged(path) }
Tests/OrgCoreTests/ElectricPairTests.swift added +97
@@ -0,0 +1,97 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Keys typed with `electric-pair-mode` in an Org buffer, against Emacs.
6struct 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 @@
1import Foundation
2import 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.
6struct 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 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// C-c C-o on timestamps and date ranges: the agenda days `org-follow-timestamp-link` shows.
6struct 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 @@
1import AppKit
2import OrgCore
3import Testing
4@testable import OrgEditorAppKit
5
6/// `electric-pair-mode` through key events, in the Emacs and Doom presets.
7@MainActor
8struct 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}