krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit d32d29c062

d32d29c0620e94a09a204185d1a2004a8d58c518

parent: a7f4c9d619

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 01:38 UTC

Editor: electric pairs, timestamp links, table field editor

electric-pair-mode as Emacs 31.1 has it in an Org buffer: (), [], {}, <>
and quotes typed in pairs, closers skipped, adjacent pairs deleted
together, RET opening a line between them, a selection wrapped; pairing
keeps delimiters balanced as electric-pair-preserve-balance does, from a
port of the syntax scanning it uses with org-mode-syntax-table. On by
default (config: electric-pair-mode), on the Mac in all presets and on iOS
from either keyboard.

C-c C-o and ⌘-click on a timestamp or date range open the agenda on its
days, as org-follow-timestamp-link does; on iOS the Agenda tab shows them.

C-c ` (org-table-edit-field) edits a field in the block editor and puts it
back as org-table-finish-edit-field does. iOS gets an editor sheet for it
and for C-c ' blocks and formulas, which it refused before.

On iOS, command edits go straight into the text storage with their own
undo, since replacing through text input took the active keyboard's smart
dashes when a sheet had it.

Electric pairs, timestamp days and the field editor are tested against
Emacs.

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}