diff --git a/Sources/Mojito/App/Engine.swift b/Sources/Mojito/App/Engine.swift index c2852ea..5697b03 100644 --- a/Sources/Mojito/App/Engine.swift +++ b/Sources/Mojito/App/Engine.swift @@ -69,6 +69,10 @@ final class Engine: ObservableObject, KeyMonitorDelegate { private var useFrequencyBoost = true private var gifBypassExclusions = true private var emoticonsEnabled = true + /// The live trigger config, mirrored off the state machine so `corpusFor` + /// can run inside `stateMachine.handle` (the quick-pick probe) without an + /// overlapping access to `stateMachine`. + private var triggerConfig = TriggerConfig.default /// Most-recent emoticon insertion still inside its undo window. Cleared on /// successful undo, timeout, focus change, any text-mutating keystroke, @@ -94,6 +98,19 @@ final class Engine: ObservableObject, KeyMonitorDelegate { self.usage = (UserDefaults.standard.dictionary(forKey: PrefsKey.usageCounts) as? [String: Int]) ?? [:] refreshPreferences() + // Collision guard for list quick-pick: a digit that keeps the query + // matching (`:clock1` β†’ πŸ•) is typing, not picking. Only runs on a + // digit while the list is up. It runs inside `stateMachine.handle`, + // so it must not touch `stateMachine` (a runtime exclusivity trap). + stateMachine.queryHasMatches = { [weak self] query, scope in + guard let self else { return true } + return !FuzzyMatcher.search( + query: query, in: self.database, usage: [:], + corpus: self.corpusFor(scope: scope), + useFrequencyBoost: false, limit: 1 + ).isEmpty + } + // Click-away behaves like Esc but doesn't consume the click. pickerWindow.onClickAway = { [weak self] in self?.cancelCapture() @@ -247,7 +264,8 @@ final class Engine: ObservableObject, KeyMonitorDelegate { // The trigger config (incl. symbols mode) lives entirely in the // user-editable config. Cache it so `corpusFor` can read it off the // keystroke path. - stateMachine.setConfig(TriggerConfigStore.load()) + triggerConfig = TriggerConfigStore.load() + stateMachine.setConfig(triggerConfig) // Gating arrows in the SM means a disabled arrow never defers or // consumes a following char (which would otherwise be dropped when // the insert is suppressed downstream). @@ -600,6 +618,10 @@ final class Engine: ObservableObject, KeyMonitorDelegate { if case .insertEmoji(_, .fromPicker, _) = output.action, viewModel.topResult == nil { consumesKey = false } + // Never swallow a quick-pick digit without a row to insert. + if case .pickListIndex(let index, _, _) = output.action, !isListPickable(index) { + consumesKey = false + } // Same guard for the sticky entering pick (Shift+Return): if the // picker never surfaced there's no row to pick β€” let the key through. // Once a session is live the keyboard is owned, so keep consuming. @@ -647,6 +669,7 @@ final class Engine: ObservableObject, KeyMonitorDelegate { viewModel.reset() pickerWindow.hide() stateMachine.emptyPickerActive = false + setListQuickPick(0) // Backspacing below the picker threshold (or demoting `::` symbols // scope) emits .closePicker while the SM stays in .capturing. The // capture metadata β€” exclusion flag, focus snapshot β€” must survive @@ -872,6 +895,17 @@ final class Engine: ObservableObject, KeyMonitorDelegate { viewModel.selectedIndex = index insert(query: "", mode: .fromPicker, scope: .normal, openLen: max(1, stateMachine.captureOpenLen), closeLen: 0) + case .pickListIndex(let index, let q, let scope): + if captureExcluded { break } + // The state machine already went idle; tear down rather than + // leave an orphaned list up. + guard isListPickable(index) else { + cancelCapture() + break + } + viewModel.selectedIndex = index + insert(query: q, mode: .fromPicker, scope: scope, openLen: stateMachine.lastInsertOpenLen, closeLen: 0) + case .stickyPick(let scope, let deleteCount, let keepOpen): guard !captureExcluded, let top = viewModel.topResult else { // The state machine moved to `.stickyPicking`, which owns the @@ -970,6 +1004,20 @@ final class Engine: ObservableObject, KeyMonitorDelegate { } } + /// A typed-query list row a digit may pick: in range, and not the + /// trailing Browse row. + private func isListPickable(_ index: Int) -> Bool { + guard !viewModel.compact, index >= 0, index < viewModel.results.count else { return false } + return viewModel.results[index].emoji.hexcode != EmojiBrowser.sentinelHexcode + } + + /// Arms or disarms list quick-pick. The state machine's gate and the + /// picker's number badges always move together. + private func setListQuickPick(_ count: Int) { + stateMachine.listQuickPickCount = count + viewModel.quickPickCount = count + } + /// Bare-`:` corpus: the 8 Quick Access slots (pinned glyphs + most-used /// auto-fill), then a trailing "Browse all emojis…" row. private func emptyQueryResults() -> [ScoredEmoji] { @@ -1101,6 +1149,7 @@ final class Engine: ObservableObject, KeyMonitorDelegate { // vertical shortcode list. viewModel.compact = query.isEmpty guard !query.isEmpty else { + setListQuickPick(0) viewModel.update(query: "", results: emptyQueryResults()) return } @@ -1123,6 +1172,12 @@ final class Engine: ObservableObject, KeyMonitorDelegate { results.append(EmojiBrowser.browseRow) } viewModel.update(query: query, results: results) + // Digits pick from the rows the list draws without scrolling (never + // the trailing Browse row) while a capture shows it; a sticky session + // types them into its search instead. + let capturing: Bool = { if case .capturing = stateMachine.state { return true } else { return false } }() + let drawn = min(9, PickerLayout.maxVisibleRows) + setListQuickPick(capturing ? min(drawn, max(0, results.count - 1)) : 0) } private func corpusFor(scope: CaptureScope) -> SearchCorpus { @@ -1133,7 +1188,7 @@ final class Engine: ObservableObject, KeyMonitorDelegate { case .normal: // When symbols are on and set to follow emoji, blend them into the // normal `:fire:` results; otherwise emoji only. - let config = stateMachine.config + let config = triggerConfig return config.symbols.enabled && config.symbolsFollowEmoji ? .emojiAndSymbols : .emojiOnly @@ -1225,7 +1280,10 @@ final class Engine: ObservableObject, KeyMonitorDelegate { defer { // Any text-modifying action drops the pending emoticon undo. pendingEmoticonUndo = nil - if !sessionContinues { + if sessionContinues { + // Now a sticky session, which types digits into its search. + setListQuickPick(0) + } else { stateMachine.reset() viewModel.reset() pickerWindow.hide() diff --git a/Sources/Mojito/KeyMonitor/TriggerStateMachine.swift b/Sources/Mojito/KeyMonitor/TriggerStateMachine.swift index 79e3d40..4ee30b5 100644 --- a/Sources/Mojito/KeyMonitor/TriggerStateMachine.swift +++ b/Sources/Mojito/KeyMonitor/TriggerStateMachine.swift @@ -127,6 +127,9 @@ enum TriggerAction: Equatable { case expandBrowser /// Pill quick-pick: insert the row at this index (digit 1–8 β†’ 0–7). case pickIndex(Int) + /// Typed-query list quick-pick: insert the row at this index (digit + /// 1–9 β†’ 0–8), erasing the typed `:query` like Return does. + case pickListIndex(Int, query: String, scope: CaptureScope) /// Close the pill and type back the swallowed trigger char so e.g. `:?`+Esc /// leaves the literal `:?` (the char is the trigger's last, customizable). case closePickerRestoringTrigger(Character) @@ -273,6 +276,17 @@ struct TriggerStateMachine { /// instead of being swallowed. var pillEmojiCount: Int = 0 + /// Rows of the typed-query list a digit can quick-pick (the first 9; + /// never the trailing Browse row). The Engine arms it from the results + /// it shows; any edit to the query disarms it until the next refresh, + /// so a digit only ever picks a row from the list on screen. + var listQuickPickCount: Int = 0 + + /// Engine-supplied search probe: does this query match anything in the + /// scope? A digit that would continue a real shortcode (`:1` β†’ `:100:`) + /// types through instead of picking. Nil disables list quick-pick. + var queryHasMatches: ((String, CaptureScope) -> Bool)? + /// The active capture's scope. Read by the Engine's global-hotkey browser /// entry, which synthesizes a pick outside the normal action flow and needs /// to know whether the field holds `:query` or `::query`. Derived from the @@ -608,6 +622,15 @@ struct TriggerStateMachine { if let close = closeChar(query: q), close == c { return handleCloseChar(c, query: q) } + // List quick-pick: `:smil` + `2` inserts the 2nd row. Swallowed, + // and the typed `:query` is erased as on Return. + if let index = listQuickPickIndex(for: c, query: q) { + let scope = captureScope + state = .idle + lastInsertOpenLen = capturedOpenLen + lastInsertCloseLen = 0 + return TriggerOutput(action: .pickListIndex(index, query: q, scope: scope), consumesKey: true) + } return appendQueryChar(c, query: q) // MARK: capturing β€” backspace @@ -625,6 +648,7 @@ struct TriggerStateMachine { state = .idle return TriggerOutput(action: .closePicker, consumesKey: false) } + listQuickPickCount = 0 let next = String(q.dropLast()) state = .capturing(query: next) let threshold = pickerThreshold(for: captureScope, query: next) @@ -703,6 +727,7 @@ struct TriggerStateMachine { // keyboard. let deleteCount = q.count + capturedOpenLen + closeProgress closeProgress = 0 + listQuickPickCount = 0 stickyPickJustFired = true state = .stickyPicking(query: q) return TriggerOutput( @@ -769,6 +794,7 @@ struct TriggerStateMachine { mutating func enterBrowsing(query: String) { state = .browsing(query: query) emptyPickerActive = false + listQuickPickCount = 0 konamiProgress = 0 } @@ -779,6 +805,7 @@ struct TriggerStateMachine { state = .stickyPicking(query: query) stickyPickJustFired = true emptyPickerActive = false + listQuickPickCount = 0 openBuffer = [] closeProgress = 0 konamiProgress = 0 @@ -800,6 +827,7 @@ struct TriggerStateMachine { closeProgress = 0 emptyPickerActive = false pillEmojiCount = 0 + listQuickPickCount = 0 lastWasWordChar = false konamiProgress = 0 idleWord = "" @@ -840,6 +868,7 @@ struct TriggerStateMachine { capturedOpenLen = 0 // Not visible yet β€” the Engine flips this on after the debounced show. emptyPickerActive = false + listQuickPickCount = 0 return handleOpening(char: c, input: input, now: now) } @@ -954,6 +983,7 @@ struct TriggerStateMachine { private mutating func appendQueryChar(_ c: Character, query q: String) -> TriggerOutput { let wasEmptyPicker = emptyPickerActive emptyPickerActive = false + listQuickPickCount = 0 let next = q + String(c) state = .capturing(query: next) let scope = captureScope @@ -972,6 +1002,19 @@ struct TriggerStateMachine { return TriggerOutput(action: action, consumesKey: false) } + /// The 0-based list row digit `c` quick-picks, or nil when it should type + /// through: no list armed, not an ASCII 1–9 in range, mid-close, or the + /// digit would extend the query into something that still matches. + private func listQuickPickIndex(for c: Character, query q: String) -> Int? { + guard listQuickPickCount > 0, !q.isEmpty, closeProgress == 0, + c.isASCII, let digit = c.wholeNumberValue, + digit >= 1, digit <= min(9, listQuickPickCount), + let hasMatches = queryHasMatches, + !hasMatches(q + String(c), captureScope) + else { return nil } + return digit - 1 + } + /// Drive the multi-char close. `c` is known to be a close-string char (the /// caller checked) or a colon in a locked capture. Completing the close /// fires an exact-match insert; a partial match holds (the picker may drop); diff --git a/Sources/Mojito/Picker/HintsFooter.swift b/Sources/Mojito/Picker/HintsFooter.swift index 58c40c2..0134ffa 100644 --- a/Sources/Mojito/Picker/HintsFooter.swift +++ b/Sources/Mojito/Picker/HintsFooter.swift @@ -1,17 +1,22 @@ import SwiftUI -/// One keyboard hint for a picker footer: a keycap glyph plus an optional -/// localized label. +/// One keyboard hint for a picker footer: one or more keycap glyphs plus an +/// optional localized label. struct KeyHint: Identifiable { - let key: String + let keys: [String] let label: LocalizedStringKey? init(_ key: String, _ label: LocalizedStringKey? = nil) { - self.key = key + self.init(keys: [key], label) + } + + /// Several keys sharing one label (`↡` `1–9` insert). + init(keys: [String], _ label: LocalizedStringKey? = nil) { + self.keys = keys self.label = label } - var id: String { key } + var id: String { keys.joined(separator: " ") } } /// A single keycap + label pair, styled like the macOS menu hint text. @@ -20,11 +25,15 @@ struct KeyHintLabel: View { var body: some View { HStack(spacing: 5) { - Text(verbatim: hint.key) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(Capsule().fill(Color.primary.opacity(0.08))) + HStack(spacing: 3) { + ForEach(hint.keys, id: \.self) { key in + Text(verbatim: key) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(Color.primary.opacity(0.08))) + } + } if let label = hint.label { Text(label) } diff --git a/Sources/Mojito/Picker/PickerView.swift b/Sources/Mojito/Picker/PickerView.swift index 7181be5..b0435d4 100644 --- a/Sources/Mojito/Picker/PickerView.swift +++ b/Sources/Mojito/Picker/PickerView.swift @@ -77,8 +77,15 @@ struct PickerView: View { private var footer: some View { // Bare `esc` keycap: the label had to go to make room for the - // keep-open hint in the 280 pt panel. - HintsFooter([ + // keep-open hint in the 280 pt panel. The digit keycap takes the + // room of the arrows' label, so it shares `insert` with `↡`. + let count = viewModel.quickPickCount + return HintsFooter(count > 0 ? [ + KeyHint("↑↓"), + KeyHint(keys: ["↡", count == 1 ? "1" : "1–\(count)"], "insert"), + KeyHint("⇧↡", "more"), + KeyHint("esc"), + ] : [ KeyHint("↑↓", "select"), KeyHint("↡", "insert"), KeyHint("⇧↡", "more"), @@ -117,6 +124,13 @@ private struct PickerRow: View { .frame(width: 24) label Spacer(minLength: 0) + if index < viewModel.quickPickCount { + // The digit that inserts this row β€” reads like a menu's + // key equivalent. + Text(verbatim: "\(index + 1)") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.tertiary) + } } .padding(.horizontal, 10) .padding(.vertical, 3) diff --git a/Sources/Mojito/Picker/PickerViewModel.swift b/Sources/Mojito/Picker/PickerViewModel.swift index fb10fe7..d1bd3a7 100644 --- a/Sources/Mojito/Picker/PickerViewModel.swift +++ b/Sources/Mojito/Picker/PickerViewModel.swift @@ -12,6 +12,9 @@ final class PickerViewModel: ObservableObject { @Published var compact: Bool = false /// True once the pill has grown into the full-library grid (same panel). @Published var expanded: Bool = false + /// Leading list rows that a digit 1–9 inserts directly (0 = off). Drives + /// the row number badges and the footer's digit hint. + @Published var quickPickCount: Int = 0 /// Drives the grid while `expanded`. Set by the Engine on expand. var browser: EmojiBrowserViewModel? @@ -65,6 +68,7 @@ final class PickerViewModel: ObservableObject { isVisible = false compact = false expanded = false + quickPickCount = 0 browser = nil } } diff --git a/Tests/MojitoTests/TriggerStateMachineTests.swift b/Tests/MojitoTests/TriggerStateMachineTests.swift index 06e21fa..541ce2c 100644 --- a/Tests/MojitoTests/TriggerStateMachineTests.swift +++ b/Tests/MojitoTests/TriggerStateMachineTests.swift @@ -945,3 +945,145 @@ struct StickyPickingTests { #expect(out.action == .openPicker(query: "fo", scope: .normal)) } } + +/// Digit quick-pick on the typed-query list (`:smil` + `2` β†’ 2nd row). The +/// Engine arms `listQuickPickCount` from the rows it shows and supplies the +/// `queryHasMatches` probe; these pin the state machine's half of the gate. +struct ListQuickPickTests { + + /// `:smil` captured with `rows` pickable rows and a probe that reports + /// `matching` queries as live. + private func capturingSmil(rows: Int = 6, matching: Set = []) -> TriggerStateMachine { + var sm = TriggerStateMachine() + _ = sm.handle(.colon) + for ch in "smil" { _ = sm.handle(.nameChar(ch)) } + sm.listQuickPickCount = rows + sm.queryHasMatches = { query, _ in matching.contains(query) } + return sm + } + + @Test func digitPicksRowWhenItWouldDeadEndTheQuery() { + var sm = capturingSmil() + let out = sm.handle(.nameChar("2")) + #expect(out.action == .pickListIndex(1, query: "smil", scope: .normal)) + #expect(out.consumesKey == true) + #expect(sm.state == .idle) + // Erases `:smil` like Return: the open delimiter, no close. + #expect(sm.lastInsertOpenLen == 1) + #expect(sm.lastInsertCloseLen == 0) + } + + @Test func digitThatContinuesAShortcodeKeepsTyping() { + var sm = TriggerStateMachine() + _ = sm.handle(.colon) + for ch in "clock" { _ = sm.handle(.nameChar(ch)) } + sm.listQuickPickCount = 6 + sm.queryHasMatches = { query, _ in query == "clock1" } + let out = sm.handle(.nameChar("1")) + #expect(out.action == .refreshPicker(query: "clock1", scope: .normal)) + #expect(out.consumesKey == false) + #expect(sm.state == .capturing(query: "clock1")) + } + + @Test func probeSeesTheExtendedQueryAndScope() { + var sm = capturingSmil() + var probed: [(String, CaptureScope)] = [] + sm.queryHasMatches = { query, scope in + probed.append((query, scope)) + return false + } + _ = sm.handle(.nameChar("3")) + #expect(probed.count == 1) + #expect(probed.first?.0 == "smil3") + #expect(probed.first?.1 == .normal) + } + + @Test func zeroNeverPicks() { + var sm = capturingSmil() + let out = sm.handle(.nameChar("0")) + #expect(out.action == .refreshPicker(query: "smil0", scope: .normal)) + #expect(sm.state == .capturing(query: "smil0")) + } + + @Test func digitPastTheVisibleRowsKeepsTyping() { + var sm = capturingSmil(rows: 3) + let out = sm.handle(.nameChar("4")) + #expect(out.action == .refreshPicker(query: "smil4", scope: .normal)) + #expect(sm.state == .capturing(query: "smil4")) + } + + @Test func nineIsTheLastPickableRow() { + var sm = capturingSmil(rows: 12) + let out = sm.handle(.nameChar("9")) + #expect(out.action == .pickListIndex(8, query: "smil", scope: .normal)) + } + + @Test func unarmedListNeverPicks() { + var sm = capturingSmil(rows: 0) + var probed = false + sm.queryHasMatches = { _, _ in probed = true; return false } + let out = sm.handle(.nameChar("1")) + #expect(out.action == .refreshPicker(query: "smil1", scope: .normal)) + #expect(probed == false) // no list, no search + } + + @Test func missingProbeNeverPicks() { + var sm = capturingSmil() + sm.queryHasMatches = nil + let out = sm.handle(.nameChar("1")) + #expect(out.action == .refreshPicker(query: "smil1", scope: .normal)) + } + + @Test func editingTheQueryDisarmsUntilTheEngineReArms() { + var sm = capturingSmil() + _ = sm.handle(.nameChar("e")) + #expect(sm.listQuickPickCount == 0) + let typed = sm.handle(.nameChar("1")) + #expect(typed.action == .refreshPicker(query: "smile1", scope: .normal)) + + var back = capturingSmil() + _ = back.handle(.backspace) + #expect(back.listQuickPickCount == 0) + } + + @Test func symbolsScopeErasesTheDoubleColonOpen() { + var sm = TriggerStateMachine() + sm.symbolsDoubleColonEnabled = true + _ = sm.handle(.colon) + _ = sm.handle(.colon) + for ch in "star" { _ = sm.handle(.nameChar(ch)) } + sm.listQuickPickCount = 6 + sm.queryHasMatches = { _, _ in false } + let out = sm.handle(.nameChar("1")) + #expect(out.action == .pickListIndex(0, query: "star", scope: .symbolsOnly)) + #expect(sm.lastInsertOpenLen == 2) + } + + @Test func stickySessionTypesDigitsIntoItsSearch() { + var sm = capturingSmil() + _ = sm.handle(.returnKey(shift: true)) + #expect(sm.listQuickPickCount == 0) + sm.listQuickPickCount = 6 // even if re-armed, sticky owns the keys + let out = sm.handle(.nameChar("1")) + #expect(out.action == .refreshPicker(query: "1", scope: .normal)) + #expect(sm.state == .stickyPicking(query: "1")) + } + + @Test func pillDigitPickIsUnchanged() { + var sm = TriggerStateMachine() + sm.quickAccessTrigger = "?" + _ = sm.handle(.colon) + _ = sm.handle(.cancelChar("?")) + sm.emptyPickerActive = true + sm.pillEmojiCount = 8 + sm.listQuickPickCount = 6 + sm.queryHasMatches = { _, _ in false } + #expect(sm.handle(.nameChar("2")).action == .pickIndex(1)) + } + + @Test func resetDisarms() { + var sm = capturingSmil() + sm.reset() + #expect(sm.listQuickPickCount == 0) + } +}