Sources/OrgPresentation/ShrunkColumns.swift
101 lines · 5256 bytes
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}