krz/orgstar

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

Sources/OrgPresentation/ShrunkColumns.swift

101 lines · 5256 bytes

9 symbols in this file
  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}