Sources/Orgstar/Keys.swift
175 lines · 6761 bytes
1import OrgApp
2import OrgCore
3import OrgEditorAppKit
4import SwiftUI
5
6struct KeyHint: Hashable {
7 let key: String
8 let title: String
9}
10
11/// The minibuffer line: a pending prefix, or the last message for a few seconds. After a
12/// short pause on a prefix, the keys that can follow it.
13struct EchoArea: View {
14 let commands: AppCommands
15 @State private var showingHints = false
16 @State private var answer = ""
17 @FocusState private var answerFocused: Bool
18
19 var body: some View {
20 VStack(alignment: .leading, spacing: 0) {
21 if showingHints, commands.pendingKeys != nil, !commands.keyHints.isEmpty {
22 Divider()
23 LazyVGrid(columns: [GridItem(.adaptive(minimum: 220), alignment: .leading)], alignment: .leading, spacing: 4) {
24 ForEach(commands.keyHints, id: \.self) { hint in
25 HStack(spacing: 8) {
26 Text(hint.key).font(.system(.body, design: .monospaced)).foregroundStyle(.tint)
27 Text(hint.title).foregroundStyle(hint.title == "+prefix" ? .secondary : .primary).lineLimit(1)
28 }
29 }
30 }
31 .padding(10)
32 }
33 if let request = commands.prompt {
34 if !request.prompt.choices.isEmpty {
35 Divider()
36 let matches = filteredChoices(request.prompt.choices)
37 ScrollView {
38 VStack(alignment: .leading, spacing: 2) {
39 ForEach(matches.prefix(12), id: \.self) { choice in
40 Button(choice) { commands.answer(choice) }
41 .buttonStyle(.plain)
42 .font(.system(.callout, design: .monospaced))
43 .foregroundStyle(choice == matches.first ? .primary : .secondary)
44 }
45 }
46 .frame(maxWidth: .infinity, alignment: .leading)
47 .padding(10)
48 }
49 .frame(maxHeight: 200)
50 }
51 Divider()
52 HStack(spacing: 6) {
53 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
54 TextField("", text: $answer)
55 .textFieldStyle(.plain)
56 .font(.system(.callout, design: .monospaced))
57 .focused($answerFocused)
58 .onSubmit {
59 if request.prompt.choices.isEmpty {
60 commands.answer(answer)
61 } else if let first = filteredChoices(request.prompt.choices).first {
62 commands.answer(first)
63 }
64 }
65 .onExitCommand { commands.answer(nil) }
66 }
67 .padding(.horizontal, 10)
68 .padding(.vertical, 4)
69 .onAppear {
70 answer = request.prompt.initial
71 answerFocused = true
72 }
73 .id(request.id)
74 } else if let text = commands.pendingKeys.map({ $0 + "-" }) ?? commands.message {
75 Text(text)
76 .font(.system(.callout, design: .monospaced))
77 .lineLimit(1)
78 .padding(.horizontal, 10)
79 .padding(.vertical, 4)
80 .frame(maxWidth: .infinity, alignment: .leading)
81 }
82 }
83 .background(.bar)
84 .task(id: commands.pendingKeys) {
85 showingHints = false
86 guard commands.pendingKeys != nil else { return }
87 try? await Task.sleep(for: .milliseconds(600))
88 if !Task.isCancelled { showingHints = true }
89 }
90 }
91}
92
93/// The evil state, as Doom's modeline shows it.
94struct StateTag: View {
95 let state: String
96
97 var body: some View {
98 Text(label)
99 .font(.system(.caption, design: .monospaced).bold())
100 .foregroundStyle(.white)
101 .padding(.horizontal, 6)
102 .padding(.vertical, 1)
103 .background(color, in: RoundedRectangle(cornerRadius: 4))
104 .accessibilityLabel("\(label.capitalized) state")
105 }
106
107 private var label: String {
108 switch state {
109 case "visual-line": "V-LINE"
110 default: state.uppercased()
111 }
112 }
113
114 private var color: Color {
115 switch state {
116 case "insert": .green
117 case "visual", "visual-line": .orange
118 default: .blue
119 }
120 }
121}
122
123extension EchoArea {
124 /// Choices matching what's typed, best first.
125 func filteredChoices(_ choices: [String]) -> [String] {
126 guard !answer.isEmpty else { return choices }
127 return choices.compactMap { choice in FuzzyMatch.score(answer, choice).map { (choice, $0) } }
128 .sorted { $0.1 > $1.1 }
129 .map(\.0)
130 }
131}
132
133/// M-x and ⇧⌘P: every command by title, with its keys.
134struct PaletteView: View {
135 let keymap: Keymap
136 let run: (String) -> Void
137 @State private var query = ""
138 @Environment(\.dismiss) private var dismiss
139
140 private var matches: [EditorCommand] {
141 let scored: [(command: EditorCommand, score: Int)] = EditorCommands.all.filter(\.inPalette).compactMap { command in
142 guard let score = FuzzyMatch.score(query, command.title) else { return nil }
143 return (command, score)
144 }
145 return scored.sorted { $0.score != $1.score ? $0.score > $1.score : $0.command.title < $1.command.title }.map(\.command)
146 }
147
148 var body: some View {
149 let matches = matches
150 VStack(spacing: 0) {
151 TextField("Run command", text: $query)
152 .textFieldStyle(.plain)
153 .font(.title3)
154 .padding(12)
155 .onSubmit { if let first = matches.first { run(first.id) } }
156 Divider()
157 List(matches, id: \.id) { command in
158 Button {
159 run(command.id)
160 } label: {
161 HStack {
162 Text(command.title)
163 Spacer()
164 if let keys = keymap.keys(for: command.id) {
165 Text(KeySequence.format(keys)).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary)
166 }
167 }
168 }
169 .buttonStyle(.plain)
170 }
171 }
172 .frame(width: 520, height: 360)
173 .onExitCommand { dismiss() }
174 }
175}