Sources/Orgstar/Keys.swift
113 lines · 4464 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 Divider()
35 HStack(spacing: 6) {
36 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
37 TextField("", text: $answer)
38 .textFieldStyle(.plain)
39 .font(.system(.callout, design: .monospaced))
40 .focused($answerFocused)
41 .onSubmit { commands.answer(answer) }
42 .onExitCommand { commands.answer(nil) }
43 }
44 .padding(.horizontal, 10)
45 .padding(.vertical, 4)
46 .onAppear {
47 answer = request.prompt.initial
48 answerFocused = true
49 }
50 .id(request.id)
51 } else if let text = commands.pendingKeys.map({ $0 + "-" }) ?? commands.message {
52 Divider()
53 Text(text)
54 .font(.system(.callout, design: .monospaced))
55 .lineLimit(1)
56 .padding(.horizontal, 10)
57 .padding(.vertical, 4)
58 .frame(maxWidth: .infinity, alignment: .leading)
59 }
60 }
61 .background(.bar)
62 .task(id: commands.pendingKeys) {
63 showingHints = false
64 guard commands.pendingKeys != nil else { return }
65 try? await Task.sleep(for: .milliseconds(600))
66 if !Task.isCancelled { showingHints = true }
67 }
68 }
69}
70
71/// M-x and ⇧⌘P: every command by title, with its keys.
72struct PaletteView: View {
73 let keymap: Keymap
74 let run: (String) -> Void
75 @State private var query = ""
76 @Environment(\.dismiss) private var dismiss
77
78 private var matches: [EditorCommand] {
79 let scored: [(command: EditorCommand, score: Int)] = EditorCommands.all.filter(\.inPalette).compactMap { command in
80 guard let score = FuzzyMatch.score(query, command.title) else { return nil }
81 return (command, score)
82 }
83 return scored.sorted { $0.score != $1.score ? $0.score > $1.score : $0.command.title < $1.command.title }.map(\.command)
84 }
85
86 var body: some View {
87 let matches = matches
88 VStack(spacing: 0) {
89 TextField("Run command", text: $query)
90 .textFieldStyle(.plain)
91 .font(.title3)
92 .padding(12)
93 .onSubmit { if let first = matches.first { run(first.id) } }
94 Divider()
95 List(matches, id: \.id) { command in
96 Button {
97 run(command.id)
98 } label: {
99 HStack {
100 Text(command.title)
101 Spacer()
102 if let keys = keymap.keys(for: command.id) {
103 Text(KeySequence.format(keys)).font(.system(.caption, design: .monospaced)).foregroundStyle(.secondary)
104 }
105 }
106 }
107 .buttonStyle(.plain)
108 }
109 }
110 .frame(width: 520, height: 360)
111 .onExitCommand { dismiss() }
112 }
113}