krz/orgstar

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

Sources/OrgCore/Commands/TableShrink.swift

153 lines · 7285 bytes

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