krz/orgstar

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

Sources/OrgCore/Commands/VisibleText.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgCore/Commands/VisibleText.swift history · blame · raw

112 lines · 5540 bytes

  1import Foundation
  2
  3/// How wide each character of a line is as Emacs displays Org text: bracket links show only
  4/// their description (or path), and with the options, emphasis markers are hidden and entities
  5/// and sub/superscripts show as their characters. Emacs's `current-column` counts only what is
  6/// visible, so tag alignment, filling and table widths all depend on this.
  7struct VisibleLine {
  8    /// Display width of the character starting at each UTF-16 offset; -1 for a tab.
  9    private var widths: [Int]
 10    /// Whether a character starts at each offset (false for the second half of a pair).
 11    private var starts: [Bool]
 12    private var hidden: [Bool]
 13
 14    /// The line at `range` (UTF-16) inside `context`, which holds the lines around it so that
 15    /// links spanning lines are seen whole.
 16    init(_ context: String, line range: Range<Int>, options: EditingOptions, settings: OrgSettings = .default) {
 17        let whole = VisibleLine(context, options: options, settings: settings)
 18        widths = Array(whole.widths[range])
 19        starts = Array(whole.starts[range])
 20        hidden = Array(whole.hidden[range])
 21    }
 22
 23    init(_ line: String, options: EditingOptions, settings: OrgSettings = .default) {
 24        let count = line.utf16.count
 25        widths = [Int](repeating: 0, count: count)
 26        starts = [Bool](repeating: false, count: count)
 27        hidden = [Bool](repeating: false, count: count)
 28        var index = 0
 29        for character in line {
 30            widths[index] = character == "\t" ? -1 : displayWidth(of: character)
 31            starts[index] = true
 32            index += String(character).utf16.count
 33        }
 34        let needsLinks = line.contains("[[")
 35        let needsEmphasis = options.hideEmphasisMarkers && line.contains(where: { "*/_+=~".contains($0) })
 36        if needsLinks || needsEmphasis {
 37            let tree = OrgParser.parse(line + "\n", defaults: settings)
 38            for node in tree.root.descendants() {
 39                switch node.kind {
 40                case .link where node.text.hasPrefix("[["):
 41                    if let description = node.firstChild(.linkDescription) {
 42                        hide(node.range.lowerBound..<description.range.lowerBound)
 43                        hide(description.range.upperBound..<node.range.upperBound)
 44                    } else {
 45                        hide(node.range.lowerBound..<(node.range.lowerBound + 2))
 46                        hide((node.range.upperBound - 2)..<node.range.upperBound)
 47                    }
 48                case .bold, .italic, .underline, .strikeThrough, .verbatim, .code:
 49                    guard options.hideEmphasisMarkers else { break }
 50                    hide(node.range.lowerBound..<(node.range.lowerBound + 1))
 51                    hide((node.range.upperBound - 1)..<node.range.upperBound)
 52                default:
 53                    break
 54                }
 55            }
 56        }
 57        if options.prettyEntities, line.contains(where: { $0 == "\\" || $0 == "_" || $0 == "^" }) {
 58            let ns = line as NSString
 59            let all = NSRange(location: 0, length: ns.length)
 60            // `org-fontify-entities`: a single-character entity is composed into one glyph.
 61            // Positions inside it stay at its starting column; its width counts at its end.
 62            let entity = try! NSRegularExpression(pattern: "\\\\(there4|sup[123]|frac[13][24]|[a-zA-Z]+)($|\\{\\}|[^\\p{L}\\n])")
 63            for match in entity.matches(in: line, range: all) {
 64                guard let display = OrgEntities.display[ns.substring(with: match.range(at: 1))], display.count == 1 else { continue }
 65                let end = ns.substring(with: match.range(at: 2)) == "{}" ? NSMaxRange(match.range(at: 2)) : NSMaxRange(match.range(at: 1))
 66                for i in match.range.location..<end where i < widths.count { widths[i] = 0 }
 67                widths[end - 1] = displayWidth(display)
 68            }
 69            let pattern = options.subSuperscriptsNeedBraces
 70                ? "\\S([_^])(\\{)[^{}\\n]*(\\})"
 71                : "\\S([_^])(?:(\\{)[^{}\\n]*(\\})|[*]|[-+]?[^-+*!@#$%^_ \\t\\r\\n,:\"?<>~;./{}=()]+)"
 72            for match in try! NSRegularExpression(pattern: pattern).matches(in: line, range: all) {
 73                for group in 1...3 where match.range(at: group).location != NSNotFound {
 74                    hide(match.range(at: group).location..<NSMaxRange(match.range(at: group)))
 75                }
 76            }
 77        }
 78    }
 79
 80    private mutating func hide(_ range: Range<Int>) {
 81        for i in range where i >= 0 && i < hidden.count { hidden[i] = true }
 82    }
 83
 84    private func advance(_ column: Int, _ i: Int) -> Int {
 85        widths[i] < 0 ? (column / 8 + 1) * 8 : column + widths[i]
 86    }
 87
 88    /// Display column at UTF-16 `offset`.
 89    func column(at offset: Int) -> Int {
 90        var column = 0
 91        for i in 0..<min(offset, widths.count) where starts[i] && !hidden[i] {
 92            column = advance(column, i)
 93        }
 94        return column
 95    }
 96
 97    var width: Int { column(at: widths.count) }
 98
 99    /// `move-to-column`: hidden text is skipped before the goal test, so point ends past
100    /// markup that sits at the goal column.
101    func offset(ofColumn target: Int) -> Int {
102        var column = 0
103        var i = 0
104        while true {
105            while i < widths.count, hidden[i] || !starts[i] { i += 1 }
106            if column >= target || i >= widths.count { break }
107            column = advance(column, i)
108            i += 1
109        }
110        return i
111    }
112}