Sources/OrgCore/Agenda/PrefixFormat.swift
83 lines · 4018 bytes
4 symbols in this file
1import Foundation
2
3/// `org-agenda-prefix-format` for one view, compiled as `org-compile-prefix-format` does:
4/// `%c` category, `%t` time, `%l` level, `%s` extra, `%e` effort, `%b` breadcrumbs, `%T` last
5/// tag (Org's regexp lists only lower-case keys but matches `T` under `case-fold-search`),
6/// `%i` icon (none in Orgstar), each with an optional `?` (empty when the value is), a `%-12`-style
7/// width with an optional `.N` limit on categories, and a punctuation character added after a
8/// non-empty value. `%(…)` forms run Lisp; they are empty here.
9public struct PrefixFormat: Sendable, Equatable {
10 enum Piece: Sendable, Equatable {
11 case literal(String)
12 case field(key: Character, optional: Bool, width: String, punctuation: String)
13 }
14
15 let pieces: [Piece]
16 /// `org-prefix-has-time`: times move out of the heading into the prefix.
17 let hasTime: Bool
18 /// `.N` in the category width: categories this long or longer are cut to N - 1.
19 let categoryMaxLength: Int?
20
21 public init(_ format: String) {
22 let regex = try! NSRegularExpression(pattern: "%(\\?)?([-+]?[0-9.]*)([ .;,:!?=|/<>]?)([cltseibT]|\\(.+?\\))")
23 let ns = format as NSString
24 var pieces: [Piece] = []
25 var hasTime = false
26 var maxLength: Int?
27 var at = 0
28 for m in regex.matches(in: format, range: NSRange(location: 0, length: ns.length)) {
29 if m.range.location > at { pieces.append(.literal(ns.substring(with: NSRange(location: at, length: m.range.location - at)))) }
30 let key = ns.substring(with: m.range(at: 4))
31 let width = ns.substring(with: m.range(at: 2))
32 if key.hasPrefix("(") {
33 pieces.append(.field(key: "(", optional: false, width: width, punctuation: ""))
34 } else {
35 let k = key.first!
36 if k == "t" { hasTime = true }
37 if k == "c", let dot = width.firstIndex(of: ".") { maxLength = Int(width[width.index(after: dot)...].prefix { $0.isNumber }) }
38 pieces.append(.field(key: k, optional: m.range(at: 1).location != NSNotFound, width: width, punctuation: ns.substring(with: m.range(at: 3))))
39 }
40 at = NSMaxRange(m.range)
41 }
42 if at < ns.length { pieces.append(.literal(ns.substring(from: at))) }
43 self.pieces = pieces
44 self.hasTime = hasTime
45 self.categoryMaxLength = maxLength
46 }
47
48 /// The prefix for these values, by field key.
49 func render(_ values: [Character: String]) -> String {
50 var out = ""
51 for piece in pieces {
52 switch piece {
53 case .literal(let s): out += s
54 case .field(let key, let optional, let width, let punctuation):
55 var value = values[key] ?? ""
56 if key == "c", let max = categoryMaxLength, value.count >= max { value = String(value.prefix(max - 1)) }
57 if value.isEmpty {
58 out += optional ? "" : Self.format("", width)
59 } else {
60 out += Self.format(value + punctuation, width)
61 }
62 }
63 }
64 return out
65 }
66
67 /// Emacs `format` with `%WIDTHs` or `%-WIDTH.PRECISIONs`.
68 static func format(_ s: String, _ spec: String) -> String {
69 var spec = Substring(spec)
70 let left = spec.first == "-"
71 if left || spec.first == "+" { spec = spec.dropFirst() }
72 let parts = spec.split(separator: ".", omittingEmptySubsequences: false)
73 let width = Int(parts.first ?? "") ?? 0
74 var value = s
75 if parts.count > 1, let precision = Int(parts[1]) { value = String(value.prefix(precision)) }
76 let padding = String(repeating: " ", count: max(0, width - displayWidth(value)))
77 return left ? value + padding : padding + value
78 }
79
80 public static let defaults: [String: String] = [
81 "agenda": " %i %-12:c%?-12t% s", "todo": " %i %-12:c", "tags": " %i %-12:c", "search": " %i %-12:c",
82 ]
83}