krz/orgstar

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

Sources/Orgstar/Keys.swift

b8d091bdcbf2889c220b12b2d0640d50cea17c24
orgstar/Sources/Orgstar/Keys.swift history · blame · raw

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}