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