krz/orgstar

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

Sources/Orgstar/Keys.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Sources/Orgstar/Keys.swift history · blame · raw

221 lines · 9635 bytes

  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}