Sources/Orgstar/Keys.swift
221 lines · 9635 bytes
7 symbols in this file
1import OrgApp
2import OrgCore
3import OrgEditorAppKit
4import OrgPresentation
5import SwiftUI
6
7struct KeyHint: Hashable {
8 let key: String
9 let title: String
10}
11
12/// The minibuffer line: a pending prefix, or the last message for a few seconds. After a
13/// short pause on a prefix, the keys that can follow it.
14struct EchoArea: View {
15 let commands: AppCommands
16 @Environment(\.orgTheme) private var theme
17 @State private var showingHints = false
18 @State private var answer = ""
19 @FocusState private var answerFocused: Bool
20
21 var body: some View {
22 VStack(alignment: .leading, spacing: 0) {
23 if showingHints, commands.pendingKeys != nil, !commands.keyHints.isEmpty {
24 Divider()
25 LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), alignment: .leading)], alignment: .leading, spacing: 4) {
26 ForEach(commands.keyHints, id: \.self) { hint in
27 HStack(spacing: 8) {
28 Text(hint.key).font(.system(.body, design: .monospaced)).foregroundStyle(.tint)
29 Text(hint.title).foregroundStyle(hint.title == "+prefix" ? .secondary : .primary).lineLimit(1)
30 }
31 }
32 }
33 .padding(10)
34 }
35 if let request = commands.prompt, let fast = request.prompt.fast {
36 Divider()
37 FastSelectionView(selection: fast, message: request.prompt.message) { commands.answer($0) }
38 .id(request.id)
39 } else if let request = commands.prompt {
40 if !request.prompt.choices.isEmpty {
41 Divider()
42 let matches = filteredChoices(request.prompt.choices)
43 ScrollView {
44 VStack(alignment: .leading, spacing: 2) {
45 ForEach(matches.prefix(12), id: \.self) { choice in
46 Button(choice) {
47 if request.prompt.completionSeparator != nil { complete(choice, request.prompt) } else { commands.answer(choice) }
48 }
49 .buttonStyle(.plain)
50 .font(.system(.callout, design: .monospaced))
51 .foregroundStyle(choice == matches.first ? .primary : .secondary)
52 }
53 }
54 .frame(maxWidth: .infinity, alignment: .leading)
55 .padding(10)
56 }
57 .frame(maxHeight: 200)
58 }
59 if let read = request.prompt.date {
60 Divider()
61 DatePickerView(answer: answer, read: read) { commands.answer($0) }
62 }
63 Divider()
64 HStack(spacing: 6) {
65 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
66 .lineLimit(1)
67 .truncationMode(.middle)
68 TextField("", text: $answer)
69 .frame(minWidth: 240)
70 .textFieldStyle(.plain)
71 .font(.system(.callout, design: .monospaced))
72 .focused($answerFocused)
73 .onSubmit {
74 if request.prompt.choices.isEmpty || !request.prompt.requireMatch {
75 commands.answer(answer)
76 } else if let first = filteredChoices(request.prompt.choices).first {
77 commands.answer(first)
78 }
79 }
80 .onKeyPress(.tab) {
81 guard let first = filteredChoices(request.prompt.choices).first else { return .ignored }
82 complete(first, request.prompt)
83 return .handled
84 }
85 .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow]) { press in
86 guard let read = request.prompt.date, press.modifiers == .shift else { return .ignored }
87 let days = [KeyEquivalent.leftArrow: -1, .rightArrow: 1, .upArrow: -7, .downArrow: 7][press.key]!
88 answer = DatePickerView.shifted(answer, read, by: days)
89 return .handled
90 }
91 .onExitCommand { commands.answer(nil) }
92 }
93 .padding(.horizontal, 10)
94 .padding(.vertical, 4)
95 .onAppear {
96 answer = request.prompt.initial
97 answerFocused = true
98 }
99 .id(request.id)
100 } else if let text = commands.pendingKeys.map({ $0 + "-" }) ?? commands.message {
101 Text(text)
102 .font(.system(.callout, design: .monospaced))
103 .lineLimit(1)
104 .padding(.horizontal, 10)
105 .padding(.vertical, 4)
106 .frame(maxWidth: .infinity, alignment: .leading)
107 }
108 }
109 .foregroundStyle(theme.color("modeline-foreground") ?? .primary)
110 .background { if let bg = theme.color("modeline-background") { bg } else { Rectangle().fill(.bar) } }
111 .task(id: commands.pendingKeys) {
112 showingHints = false
113 guard commands.pendingKeys != nil else { return }
114 try? await Task.sleep(for: .milliseconds(600))
115 if !Task.isCancelled { showingHints = true }
116 }
117 }
118}
119
120/// The evil state, as Doom's modeline shows it.
121struct StateTag: View {
122 let state: String
123 @Environment(\.orgTheme) private var theme
124
125 var body: some View {
126 Text(label)
127 .font(.system(.caption, design: .monospaced).bold())
128 .foregroundStyle(.white)
129 .padding(.horizontal, 6)
130 .padding(.vertical, 1)
131 .background(color, in: RoundedRectangle(cornerRadius: 4))
132 .accessibilityLabel("\(label.capitalized) state")
133 }
134
135 private var label: String {
136 switch state {
137 case "visual-line": "V-LINE"
138 case "visual-block": "V-BLOCK"
139 default: state.uppercased()
140 }
141 }
142
143 private var color: Color {
144 switch state {
145 case "insert": theme.color("state-insert") ?? .green
146 case "visual", "visual-line", "visual-block": theme.color("state-visual") ?? .orange
147 default: theme.color("state-normal") ?? .blue
148 }
149 }
150}
151
152extension EchoArea {
153 /// What completion matches: the text after the last separator, or all of it.
154 private func completing(_ separator: Character?) -> Substring {
155 guard let separator, let last = answer.lastIndex(of: separator) else { return answer[...] }
156 return answer[answer.index(after: last)...]
157 }
158
159 /// Puts `choice` in place of what's being completed; with a separator, adds one after it.
160 func complete(_ choice: String, _ prompt: Prompt) {
161 guard let separator = prompt.completionSeparator else {
162 answer = choice
163 return
164 }
165 let start = answer.lastIndex(of: separator).map { answer.index(after: $0) } ?? answer.startIndex
166 answer = String(answer[..<start]) + (start == answer.startIndex ? String(separator) : "") + choice + String(separator)
167 }
168
169 /// Choices matching what's typed, best first.
170 func filteredChoices(_ choices: [String]) -> [String] {
171 let typed = String(completing(commands.prompt?.prompt.completionSeparator))
172 guard !typed.isEmpty else { return choices }
173 return choices.compactMap { choice in FuzzyMatch.score(typed, choice).map { (choice, $0) } }
174 .sorted { $0.1 > $1.1 }
175 .map(\.0)
176 }
177}
178
179/// M-x and ⇧⌘P: every command by title, with its keys.
180struct PaletteView: View {
181 let commands: AppCommands
182 let run: (String) -> Void
183 @State private var query = ""
184 @Environment(\.dismiss) private var dismiss
185
186 private var matches: [EditorCommand] {
187 let scored: [(command: EditorCommand, score: Int)] = EditorCommands.all.filter(\.inPalette).compactMap { command in
188 guard let score = FuzzyMatch.score(query, command.title) else { return nil }
189 return (command, score)
190 }
191 return scored.sorted { $0.score != $1.score ? $0.score > $1.score : $0.command.title < $1.command.title }.map(\.command)
192 }
193
194 var body: some View {
195 let matches = matches
196 VStack(spacing: 0) {
197 TextField("Run command", text: $query)
198 .textFieldStyle(.plain)
199 .font(.title3)
200 .padding(12)
201 .onSubmit { if let first = matches.first { run(first.id) } }
202 Divider()
203 List(matches, id: \.id) { command in
204 Button {
205 run(command.id)
206 } label: {
207 HStack {
208 Text(command.title)
209 Spacer()
210 if let keys = commands.keyLabel(for: command.id) {
211 Text(keys).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary)
212 }
213 }
214 }
215 .buttonStyle(.plain)
216 }
217 }
218 .frame(width: 520, height: 360)
219 .onExitCommand { dismiss() }
220 }
221}