Sources/Orgstar/FastSelectionView.swift
133 lines · 5440 bytes
5 symbols in this file
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}