krz/orgstar

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

Commit 58136891cd

58136891cdc8257199ae9055a2432e82f81ea7ea

parent: 745599e8e9

Verified · cmc

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

Share shrunk table column state between editors

ShrunkColumns in OrgPresentation holds the startup scan, edit mapping
and the column width commands' choice; the Mac editor uses it.

Layout: unified · split

Sources/OrgEditorAppKit/OrgEditor.swift +11 −67
@@ -180,7 +180,7 @@ public final class OrgEditor: NSObject {
180180 indented = startup.indented
181181 hideStars = startup.hideStars
182182 if startup.folds != FoldState() { setFolds(startup.folds, wrappers: startup.wrappers) }
183 shrinkAtStartup()
183 shrunk = ShrunkColumns.startup(document.tree)
184184 if startup.inlineImages { showsImages = true }
185185 if startup.alignTables { _ = perform(AlignAllTables()) }
186186 if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) }
@@ -812,18 +812,13 @@ public final class OrgEditor: NSObject {
812812
813813 // MARK: - Shrunk table columns
814814
815 /// Narrowed table columns (`org-table-shrink`): by table start, each column's segments.
816 /// Recomputed as the text changes; typing into one widens its column again, as editing
817 /// org's overlays deletes them. Commands' edits keep columns narrow, as `org-table-align`
818 /// shrinks them again.
819 private var shrunk: [Int: [Int: [TableShrink.Segment]]] = [:]
815 /// Narrowed table columns (`org-table-shrink`).
816 private var shrunk = ShrunkColumns()
820817 private var commandEditing = false
821818
822819 /// Narrows `columns` of `table` and widens its others.
823820 private func setShrunk(_ columns: [Int], in table: Range<Int>) {
824 let text = textView.string
825 let segments = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) })
826 shrunk[table.lowerBound] = segments.isEmpty ? nil : segments
821 shrunk.set(columns, in: table, text: textView.string)
827822 restyleOutsideEditing(table)
828823 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
829824 textView.needsDisplay = true
@@ -832,73 +827,22 @@ public final class OrgEditor: NSObject {
832827 /// C-c TAB in a table: `org-table-toggle-column-width`; `shrink` for `org-table-shrink`,
833828 /// `expand` for `org-table-expand`.
834829 private func tableColumnWidth(_ id: String) {
835 let caret = textView.selectedRange().location
836 let text = textView.string
837 guard let table = TableShrink.table(at: caret, in: text) else {
838 onMessage?("Not in a table")
839 return
840 }
841 let current = shrunk[table.lowerBound].map { Array($0.keys) } ?? []
842 switch id {
843 case "org.table.shrink": setShrunk(TableShrink.cookieColumns(table, in: text), in: table)
844 case "org.table.expand": setShrunk([], in: table)
845 default:
846 if let column = TableShrink.column(at: caret, in: text) {
847 setShrunk(Array(Set(current).symmetricDifference([column])), in: table)
848 return
849 }
850 let line = (text as NSString).lineRange(for: NSRange(location: caret, length: 0))
830 switch shrunk.change(for: id, at: textView.selectedRange().location, in: textView.string) {
831 case .notInTable: onMessage?("Not in a table")
832 case .set(let columns, let table): setShrunk(columns, in: table)
833 case .ask(let table, let current, let max):
851834 onPrompt?(Prompt(key: "columns", message: "Column ranges (e.g. 2-4 6-):")) { [weak self] answer in
852835 guard let self, let answer else { return }
853 self.setShrunk(Array(Set(current).symmetricDifference(TableShrink.columns(answer, max: line.length / 2))), in: table)
836 self.setShrunk(ShrunkColumns.toggled(current, TableShrink.columns(answer, max: max)), in: table)
854837 }
855838 }
856839 }
857840
858 /// `#+STARTUP: shrink`: every table's columns with width cookies start narrow.
859 private func shrinkAtStartup() {
860 guard Presentation.startupOptions(document.tree).contains(where: { $0.lowercased() == "shrink" }) else { return }
861 let text = textView.string
862 var offset = 0
863 let ns = text as NSString
864 while offset < ns.length {
865 let line = ns.lineRange(for: NSRange(location: offset, length: 0))
866 defer { offset = max(NSMaxRange(line), offset + 1) }
867 guard ns.substring(with: line).range(of: "^[ \\t]*\\|", options: .regularExpression) != nil,
868 let table = TableShrink.table(at: line.location, in: text) else { continue }
869 let columns = TableShrink.cookieColumns(table, in: text)
870 if !columns.isEmpty { shrunk[table.lowerBound] = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) }
871 offset = table.upperBound
872 }
873 }
874
875 /// Moves shrunk columns through `edit`; returns the tables to restyle.
876 private func mapShrunk(through edit: TextEdit) -> [Range<Int>] {
877 guard !shrunk.isEmpty else { return [] }
878 let text = textView.string
879 let delta = edit.replacement.utf16.count - edit.range.count
880 var mapped: [Int: [Int: [TableShrink.Segment]]] = [:]
881 var tables: [Range<Int>] = []
882 for (start, columns) in shrunk {
883 let kept = columns.filter { _, segments in
884 commandEditing || !segments.contains { $0.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= $0.range.upperBound }
885 }.keys
886 let touched = columns.values.joined().contains { $0.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= $0.range.upperBound }
887 let newStart = start < edit.range.lowerBound ? start : start >= edit.range.upperBound ? start + delta : edit.range.lowerBound
888 guard let table = TableShrink.table(at: min(newStart, max(0, text.utf16.count - 1)), in: text) else { continue }
889 if touched || kept.count != columns.count { tables.append(table) }
890 guard !kept.isEmpty else { continue }
891 mapped[table.lowerBound] = Dictionary(uniqueKeysWithValues: kept.map { ($0, TableShrink.segments(table, column: $0, in: text)) })
892 }
893 shrunk = mapped
894 return tables
895 }
896
897841 /// Hides each shrunk segment's text and draws its display where the text was.
898842 private func applyShrunk(_ range: Range<Int>, in storage: NSTextStorage) {
899843 guard !shrunk.isEmpty else { return }
900844 let cell = ("0" as NSString).size(withAttributes: [.font: theme.font]).width
901 for segment in shrunk.values.flatMap({ $0.values.joined() }) where segment.range.overlaps(range) && segment.range.upperBound <= storage.length {
845 for segment in shrunk.segments where segment.range.overlaps(range) && segment.range.upperBound <= storage.length {
902846 let r = segment.range
903847 let shown = segment.display.count
904848 let kept = min(shown, r.count)
@@ -1233,7 +1177,7 @@ public final class OrgEditor: NSObject {
12331177 highlights = []
12341178 updateHidden()
12351179 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
1236 for table in mapShrunk(through: edit) { restyle(table.lowerBound..<min(table.upperBound, newText.length), in: storage) }
1180 for table in shrunk.map(through: edit, in: storage.string, command: commandEditing) { restyle(table.lowerBound..<min(table.upperBound, newText.length), in: storage) }
12371181 for range in oldHighlights where range.upperBound <= newText.length {
12381182 let lines = newText.lineRange(for: NSRange(range))
12391183 restyle(lines.location..<NSMaxRange(lines), in: storage)
Sources/OrgPresentation/ShrunkColumns.swift added +101
@@ -0,0 +1,101 @@
1import Foundation
2import OrgCore
3
4/// Narrowed table columns (`org-table-shrink`), as the Mac and iOS editors keep them: by table
5/// start, each column's segments. Recomputed as the text changes; typing into one widens its
6/// column again, as editing org's overlays deletes them, while commands' edits keep columns
7/// narrow, as `org-table-align` shrinks them again.
8public struct ShrunkColumns: Sendable, Equatable {
9 public private(set) var tables: [Int: [Int: [TableShrink.Segment]]] = [:]
10
11 public init() {}
12
13 public var isEmpty: Bool { tables.isEmpty }
14
15 /// Every shrunk field's segments.
16 public var segments: [TableShrink.Segment] { tables.values.flatMap { $0.values.joined() } }
17
18 /// The shrunk columns of the table starting at `table`.
19 public func columns(in table: Range<Int>) -> [Int] {
20 tables[table.lowerBound].map { Array($0.keys).sorted() } ?? []
21 }
22
23 /// Narrows `columns` of `table` and widens its others.
24 public mutating func set(_ columns: [Int], in table: Range<Int>, text: String) {
25 let segments = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) })
26 tables[table.lowerBound] = segments.isEmpty ? nil : segments
27 }
28
29 /// `#+STARTUP: shrink`: every table's columns with width cookies start narrow.
30 public static func startup(_ tree: OrgTree) -> ShrunkColumns {
31 var result = ShrunkColumns()
32 guard Presentation.startupOptions(tree).contains(where: { $0.lowercased() == "shrink" }) else { return result }
33 let text = tree.text
34 let ns = text as NSString
35 var offset = 0
36 while offset < ns.length {
37 let line = ns.lineRange(for: NSRange(location: offset, length: 0))
38 defer { offset = max(NSMaxRange(line), offset + 1) }
39 guard ns.substring(with: line).range(of: "^[ \\t]*\\|", options: .regularExpression) != nil,
40 let table = TableShrink.table(at: line.location, in: text) else { continue }
41 let columns = TableShrink.cookieColumns(table, in: text)
42 if !columns.isEmpty { result.set(columns, in: table, text: text) }
43 offset = table.upperBound
44 }
45 return result
46 }
47
48 /// Moves the columns through `edit`, `text` being the text after it; columns the edit
49 /// touched widen unless `command` made it. Returns the tables to restyle.
50 public mutating func map(through edit: TextEdit, in text: String, command: Bool) -> [Range<Int>] {
51 guard !tables.isEmpty else { return [] }
52 let delta = edit.replacement.utf16.count - edit.range.count
53 func touches(_ segment: TableShrink.Segment) -> Bool {
54 segment.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= segment.range.upperBound
55 }
56 var mapped: [Int: [Int: [TableShrink.Segment]]] = [:]
57 var restyle: [Range<Int>] = []
58 for (start, columns) in tables {
59 let kept = columns.filter { _, segments in command || !segments.contains(where: touches) }.keys
60 let touched = columns.values.joined().contains(where: touches)
61 let newStart = start < edit.range.lowerBound ? start : start >= edit.range.upperBound ? start + delta : edit.range.lowerBound
62 guard let table = TableShrink.table(at: min(newStart, max(0, text.utf16.count - 1)), in: text) else { continue }
63 if touched || kept.count != columns.count { restyle.append(table) }
64 guard !kept.isEmpty else { continue }
65 mapped[table.lowerBound] = Dictionary(uniqueKeysWithValues: kept.map { ($0, TableShrink.segments(table, column: $0, in: text)) })
66 }
67 tables = mapped
68 return restyle
69 }
70
71 /// What a column width command does at `caret`.
72 public enum Change: Equatable, Sendable {
73 case notInTable
74 /// Shrink these columns of the table, expanding the others.
75 case set([Int], table: Range<Int>)
76 /// Ask for column ranges, then toggle them (`toggled`).
77 case ask(table: Range<Int>, current: [Int], max: Int)
78 }
79
80 /// `org-table-toggle-column-width` (`org.table.toggle-column-width`), `org-table-shrink`
81 /// (`org.table.shrink`) and `org-table-expand` (`org.table.expand`) at `caret`.
82 public func change(for id: String, at caret: Int, in text: String) -> Change {
83 guard let table = TableShrink.table(at: caret, in: text) else { return .notInTable }
84 let current = columns(in: table)
85 switch id {
86 case "org.table.shrink": return .set(TableShrink.cookieColumns(table, in: text), table: table)
87 case "org.table.expand": return .set([], table: table)
88 default:
89 if let column = TableShrink.column(at: caret, in: text) {
90 return .set(Self.toggled(current, [column]), table: table)
91 }
92 let line = (text as NSString).lineRange(for: NSRange(location: caret, length: 0))
93 return .ask(table: table, current: current, max: line.length / 2)
94 }
95 }
96
97 /// `current` with `columns` toggled.
98 public static func toggled(_ current: [Int], _ columns: [Int]) -> [Int] {
99 Set(current).symmetricDifference(columns).sorted()
100 }
101}
Tests/OrgPresentationTests/ShrunkColumnsTests.swift added +66
@@ -0,0 +1,66 @@
1import Foundation
2import OrgCore
3import Testing
4@testable import OrgPresentation
5
6struct ShrunkColumnsTests {
7 static let table = "| <3> | b |\n|------+---|\n| abcdef | x |\n"
8
9 @Test func startupShrinksCookieColumnsOnlyWithTheWord() {
10 let plain = OrgParser.parse("intro\n" + Self.table)
11 #expect(ShrunkColumns.startup(plain).isEmpty)
12 let tree = OrgParser.parse("#+STARTUP: shrink\n" + Self.table + "\n| a | b |\n")
13 let shrunk = ShrunkColumns.startup(tree)
14 let start = ("#+STARTUP: shrink\n" as NSString).length
15 #expect(Array(shrunk.tables.keys) == [start])
16 #expect(shrunk.columns(in: start..<start + 1) == [1])
17 #expect(!shrunk.segments.isEmpty)
18 }
19
20 @Test func togglesTheColumnAtTheCaret() throws {
21 let text = Self.table
22 var shrunk = ShrunkColumns()
23 let caret = (text as NSString).range(of: "abc").location
24 guard case .set(let columns, let table) = shrunk.change(for: "org.table.toggle-column-width", at: caret, in: text) else {
25 Issue.record("expected a column")
26 return
27 }
28 #expect(columns == [1])
29 shrunk.set(columns, in: table, text: text)
30 #expect(shrunk.change(for: "org.table.toggle-column-width", at: caret, in: text) == .set([], table: table))
31 #expect(shrunk.change(for: "org.table.expand", at: caret, in: text) == .set([], table: table))
32 #expect(shrunk.change(for: "org.table.shrink", at: caret, in: text) == .set([1], table: table))
33 #expect(shrunk.change(for: "org.table.shrink", at: (text as NSString).length, in: text + "after\n") == .notInTable)
34 guard case .ask(_, let current, _) = shrunk.change(for: "org.table.toggle-column-width", at: 0, in: text) else {
35 Issue.record("expected a prompt before the first column")
36 return
37 }
38 #expect(ShrunkColumns.toggled(current, TableShrink.columns("1-2", max: 4)) == [2])
39 }
40
41 @Test func typingInAColumnWidensItButCommandsKeepIt() throws {
42 let text = Self.table
43 let table = try #require(TableShrink.table(at: 0, in: text))
44 var shrunk = ShrunkColumns()
45 shrunk.set([1], in: table, text: text)
46 let at = (text as NSString).range(of: "abc").location
47 let edit = TextEdit(range: at..<at, replacement: "z")
48 let after = (text as NSString).replacingCharacters(in: NSRange(location: at, length: 0), with: "z")
49 var byCommand = shrunk
50 #expect(byCommand.map(through: edit, in: after, command: true).count == 1)
51 #expect(byCommand.columns(in: table) == [1])
52 #expect(shrunk.map(through: edit, in: after, command: false).count == 1)
53 #expect(shrunk.isEmpty)
54 }
55
56 @Test func editsBeforeATableMoveIt() throws {
57 let text = "x\n" + Self.table
58 let table = try #require(TableShrink.table(at: 2, in: text))
59 var shrunk = ShrunkColumns()
60 shrunk.set([1], in: table, text: text)
61 let after = "xyz\n" + Self.table
62 #expect(shrunk.map(through: TextEdit(range: 1..<1, replacement: "yz"), in: after, command: false).isEmpty)
63 #expect(Array(shrunk.tables.keys) == [4])
64 #expect(shrunk.segments == TableShrink.segments(4..<4 + (Self.table as NSString).length, column: 1, in: after))
65 }
66}