krz/orgstar

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

Sources/Orgstar/FastSelectionView.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/Orgstar/FastSelectionView.swift history · blame · raw

133 lines · 5440 bytes

  1import OrgCore
  2import OrgPresentation
  3import SwiftUI
  4
  5/// Fast TODO and tag selection in the echo area: each option with its key. TODO keywords take
  6/// one key; tags toggle until Return, Tab types a tag, Escape or C-g quits.
  7struct FastSelectionView: View {
  8    let selection: FastSelection
  9    let message: String
 10    let answer: (String?) -> Void
 11    @Environment(\.orgTheme) private var theme
 12    @State private var inputs: [FastSelection.Input] = []
 13    @State private var typing = false
 14    @State private var typed = ""
 15    @FocusState private var focused: Bool
 16    @FocusState private var typingFocused: Bool
 17
 18    var body: some View {
 19        let current = selection.multiple ? selection.tags(after: inputs) ?? [] : []
 20        VStack(alignment: .leading, spacing: 6) {
 21            if selection.multiple {
 22                if !selection.inherited.isEmpty { line("Inherited:", selection.inherited) }
 23                line("Current:", current)
 24            }
 25            ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
 26                HStack(spacing: 14) {
 27                    ForEach(Array(row.enumerated()), id: \.offset) { _, cell in
 28                        switch cell {
 29                        case .text(let text):
 30                            Text(text).foregroundStyle(.secondary)
 31                        case .label(let text):
 32                            Text(text).bold()
 33                        case .option(let key, let name):
 34                            HStack(spacing: 4) {
 35                                Text("[\(String(key))]").foregroundStyle(.tint)
 36                                Text(name)
 37                                    .fontWeight(current.contains(name) ? .bold : .regular)
 38                                    .foregroundStyle(current.contains(name) ? theme.color("todo") ?? .primary : .primary)
 39                            }
 40                        }
 41                    }
 42                }
 43            }
 44            if typing {
 45                HStack(spacing: 6) {
 46                    Text("Tag:").foregroundStyle(.secondary)
 47                    TextField("", text: $typed)
 48                        .textFieldStyle(.plain)
 49                        .focused($typingFocused)
 50                        .onSubmit {
 51                            inputs.append(.typed(typed))
 52                            typed = ""
 53                            typing = false
 54                            focused = true
 55                        }
 56                        .onExitCommand {
 57                            typing = false
 58                            focused = true
 59                        }
 60                }
 61            }
 62            Text(message).foregroundStyle(.secondary)
 63        }
 64        .font(.system(.callout, design: .monospaced))
 65        .frame(maxWidth: .infinity, alignment: .leading)
 66        .padding(10)
 67        .focusable()
 68        .focusEffectDisabled()
 69        .focused($focused)
 70        .onKeyPress(phases: .down, action: press)
 71        .onAppear { focused = true }
 72        .onChange(of: typing) { _, now in if now { typingFocused = true } }
 73    }
 74
 75    private func line(_ label: String, _ tags: [String]) -> some View {
 76        HStack(spacing: 6) {
 77            Text(label).foregroundStyle(.secondary)
 78            Text(tags.isEmpty ? "" : ":" + tags.joined(separator: ":") + ":")
 79        }
 80    }
 81
 82    private func press(_ key: KeyPress) -> KeyPress.Result {
 83        guard !typing else { return .ignored }
 84        if key.key == .escape || (key.modifiers.contains(.control) && key.characters == "g") {
 85            answer(nil)
 86            return .handled
 87        }
 88        if selection.multiple {
 89            if key.key == .return {
 90                answer(FastSelection.encode(inputs))
 91            } else if key.key == .tab {
 92                typing = true
 93            } else if let c = key.characters.first, key.characters.count == 1 {
 94                if c == "q", !selection.options.contains(where: { $0.key == "q" }) {
 95                    answer(nil)
 96                } else {
 97                    inputs.append(.key(c))
 98                }
 99            } else {
100                return .ignored
101            }
102            return .handled
103        }
104        guard let c = key.characters.first, key.characters.count == 1 else { return .ignored }
105        answer(String(c))
106        return .handled
107    }
108
109    private enum Cell {
110        case text(String)
111        case label(String)
112        case option(Character, String)
113    }
114
115    /// Options in rows, braces and brackets around groups, as org lays them out.
116    private var rows: [[Cell]] {
117        var rows: [[Cell]] = [[]]
118        func newRow() { if !(rows.last?.isEmpty ?? true) { rows.append([]) } }
119        for item in selection.items {
120            switch item {
121            case .startGroup: newRow(); rows[rows.count - 1].append(.text("{"))
122            case .endGroup: rows[rows.count - 1].append(.text("}")); newRow()
123            case .startTagGroup: newRow(); rows[rows.count - 1].append(.text("["))
124            case .endTagGroup: rows[rows.count - 1].append(.text("]")); newRow()
125            case .groupTags: rows[rows.count - 1].append(.text(":"))
126            case .newline: newRow()
127            case .label(let text): newRow(); rows[rows.count - 1].append(.label(text)); newRow()
128            case .option(let key, let name): rows[rows.count - 1].append(.option(key, name))
129            }
130        }
131        return rows.filter { !$0.isEmpty }
132    }
133}