Modeline !34

merged merged by cmc on 2026-10-05 17:34 UTC · krz/orgstar:modeline into main

10 files changed, +980 −10

Layout: unified · split

Sources/OrgCore/Semantic/TextStats.swift added +112
@@ -0,0 +1,112 @@
1/// Position and counts for the modeline, as Emacs computes them in an org buffer.
2public enum TextStats {
3 /// The line (from 1) and `current-column` (from 0, tabs to multiples of 8, wide
4 /// characters two columns, control characters as `^X` or `\200`) at a UTF-16 offset.
5 public static func position(in text: String, at offset: Int) -> (line: Int, column: Int) {
6 let utf16 = text.utf16
7 let end = utf16.index(utf16.startIndex, offsetBy: min(max(0, offset), utf16.count))
8 var line = 1
9 var lineStart = utf16.startIndex
10 var index = utf16.startIndex
11 while index < end {
12 if utf16[index] == 10 {
13 line += 1
14 lineStart = utf16.index(after: index)
15 }
16 index = utf16.index(after: index)
17 }
18 var column = 0
19 for character in text[lineStart..<end] {
20 let v = character.unicodeScalars.first!.value
21 if character == "\t" {
22 column = (column / 8 + 1) * 8
23 } else if v < 0x20 || v == 0x7F {
24 column += 2 // ^X
25 } else if (0x80...0x9F).contains(v) {
26 column += 4 // \200
27 } else {
28 column += displayWidth(of: character)
29 }
30 }
31 return (line, column)
32 }
33
34 /// `count-words`: runs of characters with word syntax in org-mode (`WordSyntax.swift`),
35 /// split where `forward-word` sees a boundary between scripts.
36 public static func words(in text: some StringProtocol) -> Int {
37 var count = 0
38 var previous: Unicode.Scalar?
39 for scalar in text.unicodeScalars {
40 if isWord(scalar) {
41 if let previous, !boundary(previous, scalar) {} else { count += 1 }
42 previous = scalar
43 } else {
44 previous = nil
45 }
46 }
47 return count
48 }
49
50 /// `word_boundary_p` with the default `word-combining-categories` and
51 /// `word-separating-categories`.
52 static func boundary(_ a: Unicode.Scalar, _ b: Unicode.Scalar) -> Bool {
53 if script(a) == script(b) { return isHiragana(a) && isKatakana(b) }
54 if isCombining(a) || isCombining(b) { return false }
55 return !(isHan(a) && (isHiragana(b) || isKatakana(b)))
56 }
57
58 static func script(_ scalar: Unicode.Scalar) -> UInt16 {
59 let v = scalar.value
60 var low = 0
61 var high = scripts.count
62 while low < high {
63 let mid = (low + high) / 2
64 if scripts[mid].start <= v { low = mid + 1 } else { high = mid }
65 }
66 return scripts[low - 1].script
67 }
68
69 static func isCombining(_ scalar: Unicode.Scalar) -> Bool {
70 switch scalar.properties.generalCategory {
71 case .nonspacingMark, .spacingMark, .enclosingMark: true
72 default: false
73 }
74 }
75
76 static func isHan(_ scalar: Unicode.Scalar) -> Bool {
77 let v = scalar.value
78 return (0x3400...0x4DBF).contains(v) || (0x4E00...0x9FFF).contains(v) || (0xF900...0xFAFF).contains(v) || (0x20000...0x3FFFF).contains(v)
79 }
80
81 static func isHiragana(_ scalar: Unicode.Scalar) -> Bool { (0x3040...0x309F).contains(scalar.value) }
82
83 static func isKatakana(_ scalar: Unicode.Scalar) -> Bool {
84 let v = scalar.value
85 return (0x30A0...0x30FF).contains(v) || (0x31F0...0x31FF).contains(v) || (0xFF65...0xFF9F).contains(v)
86 }
87
88 static func isWord(_ scalar: Unicode.Scalar) -> Bool {
89 let v = scalar.value
90 var low = 0
91 var high = nonWord.count
92 while low < high {
93 let mid = (low + high) / 2
94 if nonWord[mid].upperBound < v { low = mid + 1 } else { high = mid }
95 }
96 return low == nonWord.count || !nonWord[low].contains(v)
97 }
98
99 /// `count-words-region`: lines (a partial last line counts), words and characters.
100 public static func region(_ text: some StringProtocol) -> (lines: Int, words: Int, characters: Int) {
101 var lines = 0
102 var characters = 0
103 var last: Unicode.Scalar?
104 for scalar in text.unicodeScalars {
105 characters += 1
106 if scalar == "\n" { lines += 1 }
107 last = scalar
108 }
109 if let last, last != "\n" { lines += 1 }
110 return (lines, words(in: text), characters)
111 }
112}
Sources/OrgCore/Semantic/WordSyntax.swift added +625
@@ -0,0 +1,625 @@
1// Generated by scripts/update-word-syntax.sh from GNU Emacs 31.1 and Org 9.8.7.
2
3extension TextStats {
4 /// Code points without word syntax in org-mode, sorted.
5 static let nonWord: [ClosedRange<UInt32>] = [
6 0x0...0x23,
7 0x26...0x26,
8 0x28...0x2F,
9 0x3A...0x40,
10 0x5B...0x60,
11 0x7B...0x7F,
12 0xA0...0xA4,
13 0xA6...0xB1,
14 0xB4...0xB4,
15 0xB6...0xB6,
16 0xB8...0xB8,
17 0xBA...0xBF,
18 0xD7...0xD7,
19 0xF7...0xF7,
20 0x2C7...0x2C7,
21 0x2C9...0x2C9,
22 0x2D0...0x2D0,
23 0x2D8...0x2DB,
24 0x2DD...0x2DD,
25 0x384...0x385,
26 0x5BE...0x5BE,
27 0x5C0...0x5C0,
28 0x5C3...0x5C3,
29 0x5C6...0x5C6,
30 0xE2F...0xE2F,
31 0xE3F...0xE3F,
32 0xE46...0xE46,
33 0xE4F...0xE4F,
34 0xE5A...0xE5B,
35 0xEAF...0xEAF,
36 0xEC6...0xEC6,
37 0xF00...0xF0B,
38 0xF0D...0xF18,
39 0xF1A...0xF1F,
40 0xF34...0xF34,
41 0xF36...0xF36,
42 0xF38...0xF3F,
43 0xF7F...0xF7F,
44 0xF85...0xF85,
45 0xFBE...0xFCF,
46 0x1361...0x1368,
47 0x1800...0x180A,
48 0x2000...0x2026,
49 0x202F...0x205F,
50 0x207D...0x207E,
51 0x208D...0x208E,
52 0x20AC...0x20AC,
53 0x2103...0x2103,
54 0x2109...0x2109,
55 0x2116...0x2116,
56 0x2121...0x2122,
57 0x2153...0x2154,
58 0x215B...0x215E,
59 0x2190...0x244F,
60 0x2460...0x246E,
61 0x2474...0x24B5,
62 0x2500...0x254B,
63 0x2592...0x2592,
64 0x25A0...0x25A1,
65 0x25A3...0x25A9,
66 0x25B2...0x25B3,
67 0x25B6...0x25B7,
68 0x25BC...0x25BD,
69 0x25C0...0x25C1,
70 0x25C6...0x25C8,
71 0x25CB...0x25CB,
72 0x25CE...0x25D1,
73 0x25EF...0x25EF,
74 0x2605...0x2606,
75 0x260E...0x260F,
76 0x261C...0x261C,
77 0x261E...0x261E,
78 0x2640...0x2640,
79 0x2642...0x2642,
80 0x2660...0x2661,
81 0x2663...0x2665,
82 0x2667...0x266A,
83 0x266C...0x266D,
84 0x266F...0x266F,
85 0x2768...0x276D,
86 0x2770...0x2775,
87 0x27E6...0x27EB,
88 0x2983...0x2998,
89 0x29FC...0x29FD,
90 0x2A00...0x2BFF,
91 0x2E00...0x2E7F,
92 0x3000...0x3003,
93 0x3008...0x301C,
94 0x30FB...0x30FB,
95 0x3200...0x321C,
96 0x3220...0x3229,
97 0x3260...0x327B,
98 0x327E...0x327F,
99 0x3380...0x3384,
100 0x3388...0x33CA,
101 0x33CF...0x33D0,
102 0x33D3...0x33D3,
103 0x33D6...0x33D6,
104 0x33D8...0x33D8,
105 0x33DB...0x33DD,
106 0xAADB...0xAADF,
107 0xFD3E...0xFD3F,
108 0xFE35...0xFE44,
109 0xFE59...0xFE5E,
110 0xFF01...0xFF0F,
111 0xFF1B...0xFF20,
112 0xFF3B...0xFF40,
113 0xFF5B...0xFF65,
114 0xFFE0...0xFFE3,
115 0xFFE5...0xFFE5,
116 0x11660...0x1166C,
117 0x1FB00...0x1FBFF,
118 ]
119
120 /// `char-script-table`: where each script's range starts, and its script.
121 static let scripts: [(start: UInt32, script: UInt16)] = [
122 (0x0, 0),
123 (0x80, 1),
124 (0xA0, 0),
125 (0x250, 2),
126 (0x2B0, 0),
127 (0x2EA, 3),
128 (0x2EC, 0),
129 (0x370, 4),
130 (0x3E2, 5),
131 (0x3F0, 4),
132 (0x400, 6),
133 (0x530, 7),
134 (0x590, 8),
135 (0x600, 9),
136 (0x700, 10),
137 (0x750, 9),
138 (0x780, 11),
139 (0x7C0, 12),
140 (0x800, 13),
141 (0x840, 14),
142 (0x860, 10),
143 (0x870, 9),
144 (0x900, 15),
145 (0x980, 16),
146 (0xA00, 17),
147 (0xA80, 18),
148 (0xB00, 19),
149 (0xB80, 20),
150 (0xC00, 21),
151 (0xC80, 22),
152 (0xD00, 23),
153 (0xD80, 24),
154 (0xE00, 25),
155 (0xE80, 26),
156 (0xF00, 27),
157 (0x1000, 28),
158 (0x10A0, 29),
159 (0x1100, 30),
160 (0x1200, 31),
161 (0x13A0, 32),
162 (0x1400, 33),
163 (0x1680, 34),
164 (0x16A0, 35),
165 (0x1700, 36),
166 (0x1720, 37),
167 (0x1740, 38),
168 (0x1760, 39),
169 (0x1780, 40),
170 (0x1800, 41),
171 (0x18B0, 33),
172 (0x1900, 42),
173 (0x1950, 43),
174 (0x1980, 44),
175 (0x19E0, 40),
176 (0x1A00, 45),
177 (0x1A20, 46),
178 (0x1AB0, 0),
179 (0x1B00, 47),
180 (0x1B80, 48),
181 (0x1BC0, 49),
182 (0x1C00, 50),
183 (0x1C50, 51),
184 (0x1C80, 6),
185 (0x1C90, 29),
186 (0x1CC0, 48),
187 (0x1CD0, 52),
188 (0x1D00, 2),
189 (0x1DC0, 0),
190 (0x1F00, 4),
191 (0x2000, 53),
192 (0x231A, 54),
193 (0x231C, 53),
194 (0x23E9, 54),
195 (0x23ED, 53),
196 (0x23F0, 54),
197 (0x23F1, 53),
198 (0x23F3, 54),
199 (0x23F4, 53),
200 (0x25FD, 54),
201 (0x25FF, 53),
202 (0x2614, 54),
203 (0x2616, 53),
204 (0x2648, 54),
205 (0x2654, 53),
206 (0x267F, 54),
207 (0x2680, 53),
208 (0x2693, 54),
209 (0x2694, 53),
210 (0x26A1, 54),
211 (0x26A2, 53),
212 (0x26AA, 54),
213 (0x26AC, 53),
214 (0x26BD, 54),
215 (0x26BF, 53),
216 (0x26C4, 54),
217 (0x26C6, 53),
218 (0x26CE, 54),
219 (0x26CF, 53),
220 (0x26D4, 54),
221 (0x26D5, 53),
222 (0x26EA, 54),
223 (0x26EB, 53),
224 (0x26F2, 54),
225 (0x26F4, 53),
226 (0x26F5, 54),
227 (0x26F6, 53),
228 (0x26FA, 54),
229 (0x26FB, 53),
230 (0x26FD, 54),
231 (0x26FE, 53),
232 (0x2705, 54),
233 (0x2706, 53),
234 (0x270A, 54),
235 (0x270C, 53),
236 (0x2728, 54),
237 (0x2729, 53),
238 (0x274C, 54),
239 (0x274D, 53),
240 (0x274E, 54),
241 (0x274F, 53),
242 (0x2753, 54),
243 (0x2756, 53),
244 (0x2757, 54),
245 (0x2758, 53),
246 (0x2795, 54),
247 (0x2798, 53),
248 (0x27B0, 54),
249 (0x27B1, 53),
250 (0x27BF, 54),
251 (0x27C0, 53),
252 (0x2800, 55),
253 (0x2900, 53),
254 (0x2B1B, 54),
255 (0x2B1D, 53),
256 (0x2B50, 54),
257 (0x2B51, 53),
258 (0x2B55, 54),
259 (0x2B56, 53),
260 (0x2C00, 56),
261 (0x2C60, 0),
262 (0x2C80, 5),
263 (0x2D00, 29),
264 (0x2D30, 57),
265 (0x2D80, 31),
266 (0x2DE0, 6),
267 (0x2E00, 53),
268 (0x2E80, 58),
269 (0x2FE0, 1),
270 (0x2FF0, 59),
271 (0x3000, 60),
272 (0x3040, 61),
273 (0x3100, 3),
274 (0x3130, 30),
275 (0x3190, 62),
276 (0x31A0, 3),
277 (0x31C0, 60),
278 (0x31F0, 61),
279 (0x3200, 58),
280 (0x3300, 61),
281 (0x3358, 58),
282 (0x4DC0, 60),
283 (0x4E00, 58),
284 (0xA000, 63),
285 (0xA4D0, 64),
286 (0xA500, 65),
287 (0xA640, 6),
288 (0xA6A0, 66),
289 (0xA700, 0),
290 (0xA800, 67),
291 (0xA830, 68),
292 (0xA840, 69),
293 (0xA880, 70),
294 (0xA8E0, 15),
295 (0xA900, 71),
296 (0xA930, 72),
297 (0xA960, 30),
298 (0xA980, 73),
299 (0xA9E0, 28),
300 (0xAA00, 74),
301 (0xAA60, 28),
302 (0xAA80, 75),
303 (0xAAE0, 76),
304 (0xAB00, 31),
305 (0xAB30, 0),
306 (0xAB65, 4),
307 (0xAB66, 0),
308 (0xAB70, 32),
309 (0xABC0, 76),
310 (0xAC00, 30),
311 (0xD800, 1),
312 (0xF900, 58),
313 (0xFB00, 0),
314 (0xFB07, 1),
315 (0xFB13, 7),
316 (0xFB18, 1),
317 (0xFB1D, 8),
318 (0xFB50, 9),
319 (0xFE00, 1),
320 (0xFE0F, 54),
321 (0xFE10, 77),
322 (0xFE20, 0),
323 (0xFE30, 58),
324 (0xFE50, 53),
325 (0xFE70, 9),
326 (0xFF00, 60),
327 (0xFF61, 61),
328 (0xFFA0, 30),
329 (0xFFE0, 60),
330 (0xFFF0, 1),
331 (0x10000, 78),
332 (0x10100, 79),
333 (0x10140, 80),
334 (0x10190, 81),
335 (0x101D0, 82),
336 (0x10200, 1),
337 (0x10280, 83),
338 (0x102A0, 84),
339 (0x102E0, 5),
340 (0x10300, 85),
341 (0x10330, 86),
342 (0x10350, 87),
343 (0x10380, 88),
344 (0x103A0, 89),
345 (0x103E0, 1),
346 (0x10400, 90),
347 (0x10450, 91),
348 (0x10480, 92),
349 (0x104B0, 93),
350 (0x10500, 94),
351 (0x10530, 95),
352 (0x10570, 96),
353 (0x105C0, 97),
354 (0x10600, 98),
355 (0x10780, 0),
356 (0x107C0, 1),
357 (0x10800, 99),
358 (0x10840, 100),
359 (0x10860, 101),
360 (0x10880, 102),
361 (0x108B0, 1),
362 (0x108E0, 103),
363 (0x10900, 104),
364 (0x10920, 105),
365 (0x10940, 106),
366 (0x10960, 1),
367 (0x10980, 107),
368 (0x10A00, 108),
369 (0x10A60, 109),
370 (0x10A80, 110),
371 (0x10AA0, 1),
372 (0x10AC0, 111),
373 (0x10B00, 112),
374 (0x10B40, 113),
375 (0x10B60, 114),
376 (0x10B80, 115),
377 (0x10BB0, 1),
378 (0x10C00, 116),
379 (0x10C50, 1),
380 (0x10C80, 117),
381 (0x10D00, 118),
382 (0x10D40, 119),
383 (0x10D90, 1),
384 (0x10E60, 9),
385 (0x10E80, 120),
386 (0x10EC0, 9),
387 (0x10F00, 121),
388 (0x10F30, 122),
389 (0x10F70, 123),
390 (0x10FB0, 124),
391 (0x10FE0, 125),
392 (0x11000, 126),
393 (0x11080, 127),
394 (0x110D0, 128),
395 (0x11100, 129),
396 (0x11150, 130),
397 (0x11180, 131),
398 (0x111E0, 24),
399 (0x11200, 132),
400 (0x11250, 1),
401 (0x11280, 133),
402 (0x112B0, 134),
403 (0x11300, 135),
404 (0x11380, 136),
405 (0x11400, 137),
406 (0x11480, 138),
407 (0x114E0, 1),
408 (0x11580, 139),
409 (0x11600, 140),
410 (0x11660, 41),
411 (0x11680, 141),
412 (0x116D0, 28),
413 (0x11700, 142),
414 (0x11750, 1),
415 (0x11800, 143),
416 (0x11850, 1),
417 (0x118A0, 144),
418 (0x11900, 145),
419 (0x11960, 1),
420 (0x119A0, 146),
421 (0x11A00, 147),
422 (0x11A50, 148),
423 (0x11AB0, 33),
424 (0x11AC0, 149),
425 (0x11B00, 15),
426 (0x11B60, 131),
427 (0x11B80, 1),
428 (0x11BC0, 150),
429 (0x11C00, 151),
430 (0x11C70, 152),
431 (0x11CC0, 1),
432 (0x11D00, 153),
433 (0x11D60, 154),
434 (0x11DB0, 155),
435 (0x11DF0, 1),
436 (0x11EE0, 156),
437 (0x11F00, 157),
438 (0x11F60, 1),
439 (0x11FB0, 64),
440 (0x11FC0, 20),
441 (0x12000, 158),
442 (0x12550, 1),
443 (0x12F90, 159),
444 (0x13000, 160),
445 (0x14400, 161),
446 (0x14680, 1),
447 (0x16100, 162),
448 (0x16140, 1),
449 (0x16800, 66),
450 (0x16A40, 163),
451 (0x16A70, 164),
452 (0x16AD0, 165),
453 (0x16B00, 166),
454 (0x16B90, 1),
455 (0x16D40, 167),
456 (0x16D80, 1),
457 (0x16E40, 168),
458 (0x16EA0, 169),
459 (0x16EE0, 1),
460 (0x16F00, 170),
461 (0x16FA0, 1),
462 (0x16FE0, 171),
463 (0x16FE1, 172),
464 (0x16FE2, 53),
465 (0x17000, 171),
466 (0x18B00, 173),
467 (0x18D00, 171),
468 (0x18E00, 1),
469 (0x1AFF0, 61),
470 (0x1B170, 172),
471 (0x1B300, 1),
472 (0x1BC00, 174),
473 (0x1BCB0, 1),
474 (0x1CC00, 53),
475 (0x1CF00, 175),
476 (0x1CFD0, 1),
477 (0x1D000, 176),
478 (0x1D100, 177),
479 (0x1D200, 178),
480 (0x1D250, 1),
481 (0x1D2C0, 179),
482 (0x1D2E0, 180),
483 (0x1D300, 181),
484 (0x1D360, 182),
485 (0x1D380, 1),
486 (0x1D400, 183),
487 (0x1D800, 184),
488 (0x1DAB0, 1),
489 (0x1DF00, 0),
490 (0x1E000, 56),
491 (0x1E030, 6),
492 (0x1E090, 1),
493 (0x1E100, 185),
494 (0x1E150, 1),
495 (0x1E290, 186),
496 (0x1E2C0, 187),
497 (0x1E300, 1),
498 (0x1E4D0, 188),
499 (0x1E500, 1),
500 (0x1E5D0, 189),
501 (0x1E600, 1),
502 (0x1E6C0, 190),
503 (0x1E700, 1),
504 (0x1E7E0, 31),
505 (0x1E800, 191),
506 (0x1E8E0, 1),
507 (0x1E900, 192),
508 (0x1E960, 1),
509 (0x1EC70, 193),
510 (0x1ECC0, 1),
511 (0x1ED00, 194),
512 (0x1ED50, 1),
513 (0x1EE00, 9),
514 (0x1EF00, 1),
515 (0x1F000, 195),
516 (0x1F004, 54),
517 (0x1F005, 195),
518 (0x1F030, 196),
519 (0x1F0A0, 197),
520 (0x1F0CF, 54),
521 (0x1F0D0, 197),
522 (0x1F100, 53),
523 (0x1F18E, 54),
524 (0x1F18F, 53),
525 (0x1F191, 54),
526 (0x1F19B, 53),
527 (0x1F1E6, 54),
528 (0x1F200, 58),
529 (0x1F201, 54),
530 (0x1F202, 58),
531 (0x1F21A, 54),
532 (0x1F21B, 58),
533 (0x1F22F, 54),
534 (0x1F230, 58),
535 (0x1F232, 54),
536 (0x1F237, 58),
537 (0x1F238, 54),
538 (0x1F23B, 58),
539 (0x1F250, 54),
540 (0x1F252, 58),
541 (0x1F300, 54),
542 (0x1F321, 53),
543 (0x1F32D, 54),
544 (0x1F336, 53),
545 (0x1F337, 54),
546 (0x1F37D, 53),
547 (0x1F37E, 54),
548 (0x1F394, 53),
549 (0x1F3A0, 54),
550 (0x1F3CB, 53),
551 (0x1F3CF, 54),
552 (0x1F3D4, 53),
553 (0x1F3E0, 54),
554 (0x1F3F1, 53),
555 (0x1F3F4, 54),
556 (0x1F3F5, 53),
557 (0x1F3F8, 54),
558 (0x1F43F, 53),
559 (0x1F440, 54),
560 (0x1F441, 53),
561 (0x1F442, 54),
562 (0x1F4FD, 53),
563 (0x1F4FF, 54),
564 (0x1F53E, 53),
565 (0x1F54B, 54),
566 (0x1F54F, 53),
567 (0x1F550, 54),
568 (0x1F568, 53),
569 (0x1F57A, 54),
570 (0x1F57B, 53),
571 (0x1F595, 54),
572 (0x1F597, 53),
573 (0x1F5A4, 54),
574 (0x1F5A5, 53),
575 (0x1F5FB, 54),
576 (0x1F650, 53),
577 (0x1F680, 54),
578 (0x1F6C6, 53),
579 (0x1F6CC, 54),
580 (0x1F6CD, 53),
581 (0x1F6D0, 54),
582 (0x1F6D3, 53),
583 (0x1F6D5, 54),
584 (0x1F6D9, 53),
585 (0x1F6DC, 54),
586 (0x1F6E0, 53),
587 (0x1F6EB, 54),
588 (0x1F6ED, 53),
589 (0x1F6F4, 54),
590 (0x1F6FD, 53),
591 (0x1F7E0, 54),
592 (0x1F7EC, 53),
593 (0x1F7F0, 54),
594 (0x1F7F1, 53),
595 (0x1F90C, 54),
596 (0x1F93B, 53),
597 (0x1F93C, 54),
598 (0x1F946, 53),
599 (0x1F947, 54),
600 (0x1FA00, 198),
601 (0x1FA70, 54),
602 (0x1FA7D, 53),
603 (0x1FA80, 54),
604 (0x1FA8B, 53),
605 (0x1FA8E, 54),
606 (0x1FAC7, 53),
607 (0x1FAC8, 54),
608 (0x1FAC9, 53),
609 (0x1FACD, 54),
610 (0x1FADD, 53),
611 (0x1FADF, 54),
612 (0x1FAEB, 53),
613 (0x1FAEF, 54),
614 (0x1FAF9, 53),
615 (0x1FC00, 1),
616 (0x20000, 58),
617 (0x2A6E0, 1),
618 (0x2A700, 58),
619 (0x2EE60, 1),
620 (0x2F800, 58),
621 (0x2FA20, 1),
622 (0x30000, 58),
623 (0x33480, 1),
624 ]
625}
Sources/OrgEditorAppKit/OrgEditor.swift +3
@@ -184,6 +184,8 @@ public final class OrgEditor: NSObject {
184 /// Called with the pending prefix and what can follow it, and with nil when it ends. 184 /// Called with the pending prefix and what can follow it, and with nil when it ends.
185 /// The evil state when editing modally (`normal`, `insert`, `visual`, `visual-line`), nil otherwise. 185 /// The evil state when editing modally (`normal`, `insert`, `visual`, `visual-line`), nil otherwise.
186 public var onModeChange: ((String?) -> Void)? 186 public var onModeChange: ((String?) -> Void)?
187 /// The selection moved.
188 public var onSelectionChange: ((NSRange) -> Void)?
187 var reportedMode: String? 189 var reportedMode: String?
188 public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? 190 public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)?
189 /// Called with `app.*` commands, which the app carries out. 191 /// Called with `app.*` commands, which the app carries out.
@@ -617,6 +619,7 @@ public final class OrgEditor: NSObject {
617 fileprivate func selectionChanged() { 619 fileprivate func selectionChanged() {
618 let selection = textView.selectedRange() 620 let selection = textView.selectedRange()
619 defer { previousSelection = selection.location } 621 defer { previousSelection = selection.location }
622 onSelectionChange?(selection)
620 guard selection.length == 0, let range = hidden.containing(selection.location) else { return } 623 guard selection.length == 0, let range = hidden.containing(selection.location) else { return }
621 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1) 624 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1)
622 } 625 }
Sources/Orgstar/ContentView.swift +6 −1
@@ -64,7 +64,12 @@ struct ContentView: View {
64 } detail: { 64 } detail: {
65 if session.path != nil { 65 if session.path != nil {
66 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal) 66 EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform, onExternal: runExternal)
67 .safeAreaInset(edge: .bottom, spacing: 0) { EchoArea(commands: commands) } 67 .safeAreaInset(edge: .bottom, spacing: 0) {
68 VStack(spacing: 0) {
69 ModeLine(session: session, commands: commands)
70 EchoArea(commands: commands)
71 }
72 }
68 } else { 73 } else {
69 ContentUnavailableView( 74 ContentUnavailableView(
70 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file", 75 workspace.roots.isEmpty ? "Add a folder of org files" : "Choose a file",
Sources/Orgstar/EditorView.swift +1
@@ -47,6 +47,7 @@ struct EditorView: NSViewRepresentable {
47 commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } 47 commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) }
48 } 48 }
49 editor.onModeChange = { [weak commands] in commands?.editorState = $0 } 49 editor.onModeChange = { [weak commands] in commands?.editorState = $0 }
50 editor.onSelectionChange = { [weak commands] in commands?.selection = $0 }
50 editor.onAppCommand = onAppCommand 51 editor.onAppCommand = onAppCommand
51 editor.onExternal = onExternal 52 editor.onExternal = onExternal
52 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } 53 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
Sources/Orgstar/Keys.swift +5 −9
@@ -71,15 +71,11 @@ struct EchoArea: View {
71 answerFocused = true 71 answerFocused = true
72 } 72 }
73 .id(request.id) 73 .id(request.id)
74 } else if commands.editorState != nil || commands.pendingKeys != nil || commands.message != nil { 74 } else if let text = commands.pendingKeys.map({ $0 + "-" }) ?? commands.message {
75 Divider() 75 Text(text)
76 HStack(spacing: 8) { 76 .font(.system(.callout, design: .monospaced))
77 if let state = commands.editorState { StateTag(state: state) } 77 .lineLimit(1)
78 Text(commands.pendingKeys.map { $0 + "-" } ?? commands.message ?? "") 78 .padding(.horizontal, 10)
79 .font(.system(.callout, design: .monospaced))
80 .lineLimit(1)
81 }
82 .padding(.horizontal, 10)
83 .padding(.vertical, 4) 79 .padding(.vertical, 4)
84 .frame(maxWidth: .infinity, alignment: .leading) 80 .frame(maxWidth: .infinity, alignment: .leading)
85 } 81 }
Sources/Orgstar/ModeLine.swift added +104
@@ -0,0 +1,104 @@
1import OrgApp
2import OrgCore
3import OrgPresentation
4import SwiftUI
5
6/// The line under the editor: evil state, unsaved mark and the headings around the caret on
7/// the left; the selection's counts, line and column, the file's words and anything unusual
8/// about its encoding on the right.
9struct ModeLine: View {
10 let session: DocumentSession
11 let commands: AppCommands
12 @State private var words: Int?
13 @State private var crlf = false
14
15 var body: some View {
16 let text = commands.editor?.document.text ?? ""
17 let selection = commands.selection
18 HStack(spacing: 10) {
19 if let state = commands.editorState { StateTag(state: state) }
20 if session.isDirty {
21 Image(systemName: "circle.fill")
22 .font(.system(size: 6))
23 .foregroundStyle(.secondary)
24 .help("Unsaved changes")
25 .accessibilityLabel("Unsaved changes")
26 }
27 breadcrumb(at: selection.location)
28 Spacer(minLength: 12)
29 if selection.length > 0, let region = region(text, selection) {
30 Text("\(region.lines) \(region.lines == 1 ? "line" : "lines"), \(region.words) \(region.words == 1 ? "word" : "words"), \(region.characters) chars")
31 .help("Selection (count-words-region)")
32 }
33 let position = TextStats.position(in: text, at: selection.location + selection.length)
34 Text("\(position.line):\(position.column)")
35 .help("Line \(position.line), column \(position.column)")
36 if let words {
37 Text("\(words.formatted()) \(words == 1 ? "word" : "words")")
38 .help("Words in the file (count-words)")
39 }
40 ForEach(encodingNotes, id: \.self) { note in
41 Text(note).foregroundStyle(.orange)
42 }
43 }
44 .font(.system(.caption, design: .monospaced))
45 .monospacedDigit()
46 .lineLimit(1)
47 .foregroundStyle(.secondary)
48 .padding(.horizontal, 10)
49 .frame(height: 22)
50 .background(.bar)
51 .overlay(alignment: .top) { Divider() }
52 .task(id: WordsKey(path: session.path, version: session.editVersion)) {
53 guard let text = commands.editor?.document.text ?? session.document?.text else {
54 words = nil
55 return
56 }
57 (words, crlf) = await Task.detached(priority: .utility) { (TextStats.words(in: text), text.contains("\r\n")) }.value
58 }
59 }
60
61 private struct WordsKey: Hashable {
62 var path: String?
63 var version: Int
64 }
65
66 /// The outline path of the heading containing `offset`, outermost first.
67 @ViewBuilder
68 private func breadcrumb(at offset: Int) -> some View {
69 let path = outlinePath(at: offset)
70 if !path.isEmpty {
71 Text(path.joined(separator: " › "))
72 .truncationMode(.head)
73 .foregroundStyle(.primary)
74 .help(path.joined(separator: " › "))
75 }
76 }
77
78 private func outlinePath(at offset: Int) -> [String] {
79 let outline = session.outline
80 var path: [String] = []
81 var current = outline.lastIndex { $0.start <= offset }
82 while let i = current {
83 path.insert(outline[i].title.isEmpty ? "(untitled)" : outline[i].title, at: 0)
84 current = outline[i].parent
85 }
86 return path
87 }
88
89 private func region(_ text: String, _ range: NSRange) -> (lines: Int, words: Int, characters: Int)? {
90 let ns = text as NSString
91 guard NSMaxRange(range) <= ns.length else { return nil }
92 return TextStats.region(ns.substring(with: range))
93 }
94
95 /// Only what differs from UTF-8 with LF endings.
96 private var encodingNotes: [String] {
97 guard let document = session.document else { return [] }
98 var notes: [String] = []
99 if session.isReadOnly { notes.append("Read-only") }
100 if document.source.hasBOM { notes.append("BOM") }
101 if crlf { notes.append("CRLF") }
102 return notes
103 }
104}
Sources/Orgstar/OrgstarApp.swift +2
@@ -23,6 +23,8 @@ final class AppCommands {
23 var pendingKeys: String? 23 var pendingKeys: String?
24 /// The editor's evil state in the Doom preset. 24 /// The editor's evil state in the Doom preset.
25 var editorState: String? 25 var editorState: String?
26 /// The editor's selection, for the modeline.
27 var selection = NSRange(location: 0, length: 0)
26 var keyHints: [KeyHint] = [] 28 var keyHints: [KeyHint] = []
27 /// A question a command asked, answered in the echo area. 29 /// A question a command asked, answered in the echo area.
28 private(set) var prompt: PromptRequest? 30 private(set) var prompt: PromptRequest?
Tests/OrgCoreTests/TextStatsTests.swift added +79
@@ -0,0 +1,79 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct TextStatsTests {
6 static let samples = [
7 "don't stop", "foo_bar baz", "x-y", "café au lait", "日本語 text", "*bold* /it/ =code=", "2026-10-05 Mon",
8 "https://example.com/a-b?c=1", "[[file:x.org][Desc here]]", "* TODO Heading :tag:work:", "a.b,c;d",
9 "it's 3.14 or 1e5", "naïve—dash", "emoji 😀 ok", "über_cool", "«quoted» “smart” ‘single’", "a\u{00A0}b",
10 "\tindented\ttext", "- [X] item\n - sub 2\n", "x × y ÷ z → w", "€5 £3 ©2026 °C ™ ¶ § •", "✓ done ★ star", "¶ § · µ ¬ ¨ ■ ♥ ⌘ ✂ ➜ 🎉", "line one\r\nline two\r\n", "Sony α7 III", "漢字かなカナ ひらがなカタカナ", "e\u{0301}te résumé", "Привет мир", "a\u{0080}\u{0098}b", "",
11 ]
12
13 /// `count-words`, `count-lines` and `current-column` at the end of each sample in an org buffer.
14 static func emacs(_ samples: [String]) throws -> [String] {
15 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-stats-\(UUID().uuidString)")
16 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
17 defer { try? FileManager.default.removeItem(at: folder) }
18 let input = folder.appendingPathComponent("in.json")
19 let output = folder.appendingPathComponent("out.json")
20 let script = folder.appendingPathComponent("stats.el")
21 try JSONEncoder().encode(samples).write(to: input)
22 try """
23 ;;; -*- lexical-binding: t -*-
24 (require 'org)
25 (require 'json)
26 (let ((samples (append (json-read-file (getenv "ORACLE_INPUT")) nil)) result)
27 (dolist (s samples)
28 (with-temp-buffer
29 (org-mode)
30 (insert s)
31 (push (format "%d words %d lines %d chars, %d:%d"
32 (count-words (point-min) (point-max))
33 (count-lines (point-min) (point-max))
34 (- (point-max) (point-min))
35 (line-number-at-pos) (current-column))
36 result)))
37 (let ((coding-system-for-write 'utf-8-unix))
38 (with-temp-file (getenv "ORACLE_OUTPUT")
39 (insert (json-encode (vconcat (nreverse result)))))))
40 """.write(to: script, atomically: true, encoding: .utf8)
41 let log = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["ORACLE_INPUT": input.path, "ORACLE_OUTPUT": output.path])
42 guard let data = try? Data(contentsOf: output) else { throw AgendaOracle.OracleError(log: log) }
43 return try JSONDecoder().decode([String].self, from: data)
44 }
45
46 static func ours(_ s: String) -> String {
47 let region = TextStats.region(s)
48 let position = TextStats.position(in: s, at: s.utf16.count)
49 return "\(region.words) words \(region.lines) lines \(region.characters) chars, \(position.line):\(position.column)"
50 }
51
52 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
53 func matchesEmacs() throws {
54 let emacs = try Self.emacs(Self.samples)
55 for (sample, expected) in zip(Self.samples, emacs) {
56 #expect(Self.ours(sample) == expected, "\(sample)")
57 }
58 }
59
60 /// `ORGSTAR_ORACLE_CORPUS=<folder>`: every org file under the folder.
61 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
62 func corpusMatchesEmacs() throws {
63 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
64 let files = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)!
65 .compactMap { $0 as? URL }.filter { $0.pathExtension == "org" }
66 let texts = files.compactMap { try? String(contentsOf: $0, encoding: .utf8) }
67 let emacs = try Self.emacs(texts)
68 for (text, expected) in zip(texts, emacs) {
69 #expect(Self.ours(text) == expected, "\(text.prefix(60))")
70 }
71 }
72
73 @Test func positionMidLine() {
74 let text = "* Heading\n\tab日本\n"
75 let offset = (text as NSString).range(of: "本").location
76 #expect(TextStats.position(in: text, at: offset) == (2, 12))
77 #expect(TextStats.position(in: text, at: 0) == (1, 0))
78 }
79}
scripts/update-word-syntax.sh added +43
@@ -0,0 +1,43 @@
1#!/bin/sh
2# Writes what `forward-word` needs from Emacs to Sources/OrgCore/Semantic/WordSyntax.swift:
3# the characters without word syntax in an org-mode buffer, and `char-script-table`.
4# Usage: scripts/update-word-syntax.sh [emacs]
5set -eu
6cd "$(dirname "$0")/.."
7emacs=${1:-emacs}
8out=Sources/OrgCore/Semantic/WordSyntax.swift
9{
10 echo "// Generated by scripts/update-word-syntax.sh from $("$emacs" --version | head -1) and Org $("$emacs" -Q --batch --eval '(progn (require (quote org)) (princ (org-version)))')."
11 echo
12 echo "extension TextStats {"
13 echo " /// Code points without word syntax in org-mode, sorted."
14 echo " static let nonWord: [ClosedRange<UInt32>] = ["
15 "$emacs" -Q --batch --eval '
16(progn
17 (require (quote org))
18 (with-temp-buffer
19 (org-mode)
20 (let ((c 0) start)
21 (while (<= c #x110000)
22 (let ((word (and (< c #x110000) (eq (char-syntax c) ?w))))
23 (cond ((and (not word) (not start) (< c #x110000)) (setq start c))
24 ((and (or word (= c #x110000)) start)
25 (princ (format " 0x%X...0x%X,\n" start (1- c)))
26 (setq start nil))))
27 (setq c (1+ c))))))'
28 echo " ]"
29 echo
30 echo " /// \`char-script-table\`: where each script's range starts, and its script."
31 echo " static let scripts: [(start: UInt32, script: UInt16)] = ["
32 "$emacs" -Q --batch --eval '
33(let ((c 0) (ids (make-hash-table)) prev)
34 (while (< c #x110000)
35 (let ((s (aref char-script-table c)))
36 (unless (and (> c 0) (eq s prev))
37 (unless (gethash s ids) (puthash s (hash-table-count ids) ids))
38 (princ (format " (0x%X, %d),\n" c (gethash s ids)))
39 (setq prev s)))
40 (setq c (1+ c))))'
41 echo " ]"
42 echo "}"
43} > "$out"