krz/orgstar

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

Commit d3bd97c631

d3bd97c63149ae50b5302ac76d0db7ffbe320d20

parent: b7b7f8ef81

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 04:06 UTC

Shrink table columns to their width cookies

- C-c TAB in a table shrinks or expands the column at point, or asks for
  column ranges before the first or after the last.
- Shrink Table Columns with Widths and Expand Table Columns.
- #+STARTUP: shrink narrows every table's cookie columns when a file opens.
- Typing into a shrunk field expands its column; commands keep it shrunk.

Layout: unified · split

Sources/OrgCore/Commands/TableShrink.swift added +153
@@ -0,0 +1,153 @@
1import Foundation
2
3/// Narrowed table columns (`org-table-shrink`, `org-table-toggle-column-width`, Org 9.8.7):
4/// where org puts its overlays, as ranges of text shown as other text. The editor hides each
5/// range and draws its display instead.
6public enum TableShrink {
7 public struct Segment: Sendable, Equatable {
8 public let range: Range<Int>
9 /// What shows instead of the range's text.
10 public let display: String
11 }
12
13 static let indicator = "…"
14
15 /// The table holding `offset`: its start and end.
16 public static func table(at offset: Int, in text: String) -> Range<Int>? {
17 let buffer = EmacsBuffer(text, point: offset)
18 guard buffer.atTable else { return nil }
19 return buffer.tableBegin()..<buffer.tableEnd()
20 }
21
22 /// The column at `offset`, or nil before the first or after the last.
23 public static func column(at offset: Int, in text: String) -> Int? {
24 let buffer = EmacsBuffer(text, point: offset)
25 let outside = buffer.saveExcursion { () -> Bool in
26 buffer.skipBackwardNot([124, 10])
27 return buffer.bolp || buffer.looking("[ \\t]*$", caseInsensitive: false, setsMatch: false) != nil
28 }
29 return outside ? nil : buffer.tableCurrentColumn()
30 }
31
32 /// The columns with a width cookie, as `org-table-shrink` finds them.
33 public static func cookieColumns(_ table: Range<Int>, in text: String) -> [Int] {
34 let buffer = EmacsBuffer(text, point: table.lowerBound)
35 var columns: [Int] = []
36 while let match = buffer.searchForward("\\|[ \\t]*<[lrc]?[0-9]+>[ \\t]*(\\||$)", bound: table.upperBound, caseInsensitive: false) {
37 buffer.point = match[1]!.lowerBound
38 let column = buffer.tableCurrentColumn()
39 if !columns.contains(column) { columns.append(column) }
40 }
41 return columns.sorted()
42 }
43
44 /// `org-table--read-column-selection`.
45 public static func columns(_ selection: String, max: Int) -> [Int] {
46 var result: Set<Int> = []
47 for part in selection.split(whereSeparator: \.isWhitespace) {
48 if let m = part.wholeMatch(of: /([0-9]+)?-([0-9]+)?/) {
49 let low = m.1.flatMap { Int($0) } ?? 1
50 let high = m.2.flatMap { Int($0) } ?? max
51 if low <= high { result.formUnion(low...high) }
52 } else if let n = Int(part) {
53 result.insert(n)
54 }
55 }
56 return result.sorted()
57 }
58
59 /// `org-table--shrink-columns` for one column.
60 public static func segments(_ table: Range<Int>, column c: Int, in text: String) -> [Segment] {
61 let buffer = EmacsBuffer(text, point: table.lowerBound)
62 var align: String?
63 var width: Int?
64 var fields: [(start: Int, end: Int, contents: String?)] = []
65 while buffer.point < table.upperBound {
66 let lineEnd = buffer.lineEnd(buffer.point)
67 let hline = buffer.lookingAt("[ \\t]*\\|-") != nil
68 let separator: unichar = hline ? 43 : 124
69 var ok = buffer.searchForward("\\|", bound: lineEnd, caseInsensitive: false) != nil
70 var remaining = c - 1
71 while ok, remaining > 0 {
72 guard let next = (buffer.point..<lineEnd).first(where: { buffer.character(at: $0) == separator }) else { ok = false; break }
73 buffer.point = next + 1
74 remaining -= 1
75 }
76 if ok {
77 let start = buffer.point
78 var end = start
79 while end < lineEnd, let ch = buffer.character(at: end), ch != 124, ch != separator { end += 1 }
80 let contents: String? = hline ? nil : buffer.substring(start, end).trimmingCharacters(in: CharacterSet(charactersIn: " \t\n\r"))
81 fields.append((start, end, contents))
82 if let contents, let m = contents.wholeMatch(of: /<([lrc])?([0-9]+)>/) {
83 if align == nil { align = m.1.map(String.init) }
84 if width == nil { width = Int(m.2) }
85 }
86 }
87 if buffer.forwardLine(1) != 0 { break }
88 }
89 return fields.flatMap { shrinkField(width: width ?? 0, align: align ?? "l", start: $0.start, end: $0.end, contents: $0.contents, buffer: buffer) }
90 }
91
92 /// `org-table--shrink-field`; nil contents for a rule.
93 static func shrinkField(width: Int, align: String, start: Int, end: Int, contents: String?, buffer: EmacsBuffer) -> [Segment] {
94 func overlay(_ s: Int, _ e: Int, _ display: String, pre: Bool = false) -> Segment {
95 Segment(range: s..<e, display: pre ? " " + display : display + indicator)
96 }
97 func stringWidth(_ s: Int, _ e: Int) -> Int { buffer.cellWidth(buffer.substring(s, e)) }
98 guard start != end else { return [] }
99 if width == 0 { return [overlay(start, end, "")] }
100 guard let contents else { return [overlay(start, end, String(repeating: "-", count: width + 1))] }
101 if contents.isEmpty {
102 let w = stringWidth(start, end)
103 let full = width + 2
104 return w <= full ? [overlay(end - 1, end, String(repeating: " ", count: full - w))] : [overlay(end - (w - full) - 1, end, "")]
105 }
106 var lead = 0
107 while start + lead < end, buffer.character(at: start + lead) == 32 { lead += 1 }
108 var trail = 0
109 while end - trail > start + lead, buffer.character(at: end - trail - 1) == 32 { trail += 1 }
110 let contentsWidth = stringWidth(start + lead, end - trail)
111 if width <= contentsWidth {
112 let pre = lead > 0 ? overlay(start, start + lead, "", pre: true) : nil
113 let begin = start + lead
114 var lower = begin
115 var upper = end - 1
116 let target = lead == 0 ? width + 1 : width
117 var cut = upper
118 while upper - lower > 1 {
119 let mean = (lower >> 1) + (upper >> 1) + (lower & upper & 1)
120 let w = stringWidth(begin, mean)
121 if w == target {
122 cut = mean
123 break
124 } else if w > target {
125 upper = mean
126 } else {
127 lower = mean
128 }
129 cut = upper
130 }
131 return [pre, overlay(cut, end, "")].compactMap { $0 }
132 }
133 let required = width - contentsWidth
134 let before: Int = lead == 0 ? -1 : align == "r" ? required : align == "c" ? required / 2 : 0
135 let after = required - before
136 var result: [Segment] = []
137 if lead != 0, lead - 1 != before {
138 if lead - 1 > before {
139 result.append(overlay(start, start + (lead - before), "", pre: true))
140 } else {
141 result.append(overlay(start, start + 1, String(repeating: " ", count: before - (lead - 1)), pre: true))
142 }
143 }
144 if trail - 1 == after {
145 result.append(overlay(end - 1, end, ""))
146 } else if trail - 1 > after {
147 result.append(overlay(after + (end - trail), end, ""))
148 } else {
149 result.append(overlay(end - 1, end, String(repeating: " ", count: after - (trail - 1))))
150 }
151 return result
152 }
153}
Sources/OrgCore/Keymap/Presets.swift +4
@@ -647,6 +647,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
647647 keys = "C-c ="
648648 command = "org.table.column-formula"
649649 [[bind]]
650 keys = "C-c TAB"
651 command = "org.table.toggle-column-width"
652 when = "table"
653 [[bind]]
650654 keys = "C-c C-x C-r"
651655 command = "org.clock.report"
652656 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +3
@@ -37,6 +37,9 @@ public enum EditorCommands {
3737 public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [
3838 EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view),
3939 EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view),
40 EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view),
41 EditorCommand(id: "org.table.shrink", title: "Shrink Table Columns with Widths", action: .view),
42 EditorCommand(id: "org.table.expand", title: "Expand Table Columns", action: .view),
4043 EditorCommand(id: "org.link.insert", title: "Insert Link…", action: .app),
4144 EditorCommand(id: "org.link.store", title: "Store Link", action: .app),
4245 EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app),
Sources/OrgEditorAppKit/OrgEditor.swift +112 −1
@@ -150,6 +150,7 @@ public final class OrgEditor: NSObject {
150150 let wrappers = Presentation.startupWrappers(document.tree, hideDrawers: options.hideDrawerStartup, hideBlocks: options.hideBlockStartup)
151151 startup.wrapperFolds = Set(wrappers.map(\.start))
152152 if startup != FoldState() { setFolds(startup, wrappers: wrappers) }
153 shrinkAtStartup()
153154 }
154155 }
155156
@@ -369,6 +370,7 @@ public final class OrgEditor: NSObject {
369370 if !isOrg, id.hasPrefix("org.") { return false }
370371 return switch EditorCommands.command(id)?.action {
371372 case .text(let command): command.applies(in: context)
373 case .view where id.hasPrefix("org.table."): KeyContext.holds("table", in: context)
372374 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
373375 || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
374376 case .edit, .mark, .app, .find: true
@@ -395,7 +397,11 @@ public final class OrgEditor: NSObject {
395397 case .text(let text):
396398 perform(text)
397399 case .view:
398 if id == "org.cycle" { cycleAtCaret() } else { cycleGlobal() }
400 switch id {
401 case "org.cycle": cycleAtCaret()
402 case "org.cycle-global": cycleGlobal()
403 default: tableColumnWidth(id)
404 }
399405 case .edit(let selector, let extending):
400406 if markActive, let extending {
401407 keepMark = true
@@ -436,7 +442,9 @@ public final class OrgEditor: NSObject {
436442 onMessage?("This file is read-only.")
437443 return .failed("read-only")
438444 }
445 commandEditing = true
439446 applyEdits(result.edits)
447 commandEditing = false
440448 if let selection = result.selection?.first {
441449 setCaret(selection.lowerBound)
442450 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
@@ -510,6 +518,107 @@ public final class OrgEditor: NSObject {
510518 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds, wrapperRanges: Array(wrapperHidden.values)))
511519 }
512520
521 // MARK: - Shrunk table columns
522
523 /// Narrowed table columns (`org-table-shrink`): by table start, each column's segments.
524 /// Recomputed as the text changes; typing into one widens its column again, as editing
525 /// org's overlays deletes them. Commands' edits keep columns narrow, as `org-table-align`
526 /// shrinks them again.
527 private var shrunk: [Int: [Int: [TableShrink.Segment]]] = [:]
528 private var commandEditing = false
529
530 /// Narrows `columns` of `table` and widens its others.
531 private func setShrunk(_ columns: [Int], in table: Range<Int>) {
532 let text = textView.string
533 let segments = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) })
534 shrunk[table.lowerBound] = segments.isEmpty ? nil : segments
535 restyleOutsideEditing(table)
536 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
537 textView.needsDisplay = true
538 }
539
540 /// C-c TAB in a table: `org-table-toggle-column-width`; `shrink` for `org-table-shrink`,
541 /// `expand` for `org-table-expand`.
542 private func tableColumnWidth(_ id: String) {
543 let caret = textView.selectedRange().location
544 let text = textView.string
545 guard let table = TableShrink.table(at: caret, in: text) else {
546 onMessage?("Not in a table")
547 return
548 }
549 let current = shrunk[table.lowerBound].map { Array($0.keys) } ?? []
550 switch id {
551 case "org.table.shrink": setShrunk(TableShrink.cookieColumns(table, in: text), in: table)
552 case "org.table.expand": setShrunk([], in: table)
553 default:
554 if let column = TableShrink.column(at: caret, in: text) {
555 setShrunk(Array(Set(current).symmetricDifference([column])), in: table)
556 return
557 }
558 let line = (text as NSString).lineRange(for: NSRange(location: caret, length: 0))
559 onPrompt?(Prompt(key: "columns", message: "Column ranges (e.g. 2-4 6-):")) { [weak self] answer in
560 guard let self, let answer else { return }
561 self.setShrunk(Array(Set(current).symmetricDifference(TableShrink.columns(answer, max: line.length / 2))), in: table)
562 }
563 }
564 }
565
566 /// `#+STARTUP: shrink`: every table's columns with width cookies start narrow.
567 private func shrinkAtStartup() {
568 guard Presentation.startupOptions(document.tree).contains(where: { $0.lowercased() == "shrink" }) else { return }
569 let text = textView.string
570 var offset = 0
571 let ns = text as NSString
572 while offset < ns.length {
573 let line = ns.lineRange(for: NSRange(location: offset, length: 0))
574 defer { offset = max(NSMaxRange(line), offset + 1) }
575 guard ns.substring(with: line).range(of: "^[ \\t]*\\|", options: .regularExpression) != nil,
576 let table = TableShrink.table(at: line.location, in: text) else { continue }
577 let columns = TableShrink.cookieColumns(table, in: text)
578 if !columns.isEmpty { shrunk[table.lowerBound] = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) }
579 offset = table.upperBound
580 }
581 }
582
583 /// Moves shrunk columns through `edit`; returns the tables to restyle.
584 private func mapShrunk(through edit: TextEdit) -> [Range<Int>] {
585 guard !shrunk.isEmpty else { return [] }
586 let text = textView.string
587 let delta = edit.replacement.utf16.count - edit.range.count
588 var mapped: [Int: [Int: [TableShrink.Segment]]] = [:]
589 var tables: [Range<Int>] = []
590 for (start, columns) in shrunk {
591 let kept = columns.filter { _, segments in
592 commandEditing || !segments.contains { $0.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= $0.range.upperBound }
593 }.keys
594 let touched = columns.values.joined().contains { $0.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= $0.range.upperBound }
595 let newStart = start < edit.range.lowerBound ? start : start >= edit.range.upperBound ? start + delta : edit.range.lowerBound
596 guard let table = TableShrink.table(at: min(newStart, max(0, text.utf16.count - 1)), in: text) else { continue }
597 if touched || kept.count != columns.count { tables.append(table) }
598 guard !kept.isEmpty else { continue }
599 mapped[table.lowerBound] = Dictionary(uniqueKeysWithValues: kept.map { ($0, TableShrink.segments(table, column: $0, in: text)) })
600 }
601 shrunk = mapped
602 return tables
603 }
604
605 /// Hides each shrunk segment's text and draws its display where the text was.
606 private func applyShrunk(_ range: Range<Int>, in storage: NSTextStorage) {
607 guard !shrunk.isEmpty else { return }
608 let cell = ("0" as NSString).size(withAttributes: [.font: theme.font]).width
609 for segment in shrunk.values.flatMap({ $0.values.joined() }) where segment.range.overlaps(range) && segment.range.upperBound <= storage.length {
610 let r = segment.range
611 let shown = segment.display.count
612 let kept = min(shown, r.count)
613 if r.count > kept { storage.addAttributes(theme.hidden, range: NSRange(location: r.lowerBound, length: r.count - kept)) }
614 guard kept > 0 else { continue }
615 let visible = NSRange(location: r.upperBound - kept, length: kept)
616 storage.addAttribute(.foregroundColor, value: NSColor.clear, range: visible)
617 storage.addAttribute(.orgEntityDisplay, value: segment.display, range: NSRange(location: visible.location, length: 1))
618 if shown > kept { storage.addAttribute(.kern, value: CGFloat(shown - kept) * cell, range: NSRange(location: NSMaxRange(visible) - 1, length: 1)) }
619 }
620 }
621
513622 /// Moves drawer and block folds through `edit`, checking again only those near it.
514623 private func mapWrapperFolds(through edit: TextEdit, near affected: Range<Int>) {
515624 guard !wrapperHidden.isEmpty else { return }
@@ -647,6 +756,7 @@ public final class OrgEditor: NSObject {
647756 }
648757 }
649758 if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
759 applyShrunk(range, in: storage)
650760 }
651761
652762 /// `org-pretty-entities`: entities drawn as their character over the entity's last
@@ -836,6 +946,7 @@ public final class OrgEditor: NSObject {
836946 mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound))
837947 updateHidden()
838948 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
949 for table in mapShrunk(through: edit) { restyle(table.lowerBound..<min(table.upperBound, newText.length), in: storage) }
839950 let oldShaded = shaded.all
840951 if isOrg {
841952 // Walking the tree for blocks costs more than typing; only an edit on a line that
Tests/OrgCoreTests/TableShrinkTests.swift added +33
@@ -0,0 +1,33 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct TableShrinkTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 @Test(.enabled(if: oracle))
9 func placesOverlaysLikeOrg() throws {
10 let tables = [
11 "| <5> | <r3> | x | <c6> |\n|--------+------+--------+----------|\n| abcdefghij | ab | long text | mid |\n| | abcd | y | abcdefghi |\n| lead | z | | q |\n",
12 "| a | b |\n|---+---|\n| hello world | 1 |\n",
13 ]
14 for text in tables {
15 let table = try #require(TableShrink.table(at: 2, in: text))
16 let columns = TableShrink.cookieColumns(table, in: text)
17 var ours: [String] = []
18 for c in columns.isEmpty ? [1, 2] : columns {
19 ours += TableShrink.segments(table, column: c, in: text).map { "\($0.range.lowerBound + 1) \($0.range.upperBound + 1) \($0.display)" }
20 }
21 let shrink = columns.isEmpty ? "(org-table--shrink-columns '(1 2) (org-table-begin) (org-table-end))" : "(org-table-shrink)"
22 let emacs = try EmacsOracle.evaluate(text, """
23 (with-current-buffer (find-file-noselect "oracle.org") (goto-char 3) \(shrink)
24 (mapcar (lambda (o) (format "%d %d %s" (overlay-start o) (overlay-end o) (substring-no-properties (overlay-get o 'display))))
25 (sort (seq-filter (lambda (o) (eq (overlay-get o 'org-overlay-type) 'table-column-hide)) (overlays-in (point-min) (point-max)))
26 (lambda (a b) (< (overlay-start a) (overlay-start b))))))
27 """)
28 #expect(emacs.count > 4)
29 #expect(ours.sorted { Int($0.split(separator: " ")[0])! < Int($1.split(separator: " ")[0])! } == emacs)
30 }
31 #expect(TableShrink.columns("2-4 6- 1", max: 7) == [1, 2, 3, 4, 6, 7])
32 }
33}