From 33c9033e13a73ff44514fe6d7f71d4cf20971225 Mon Sep 17 00:00:00 2001 From: Aryan-Techie <76107940+Aryan-Techie@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:40:43 +0530 Subject: [PATCH 1/2] =?UTF-8?q?W-886:=20digit=20quick-pick=20(1=E2=80=939)?= =?UTF-8?q?=20for=20the=20typed-query=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the shortcode list is up, 1–9 inserts that row, the way 1–8 already does on the Quick Access pill. The first nine rows show their digit at the trailing edge, and the footer shows a `1–9` keycap next to ↵ (the arrows lose their "select" label to make room in the 280 pt panel). A digit only picks when appending it would leave the query with no matches, so `:smil` + `2` picks the second row while `:clock1`, `:+1:` and `:100:` still type normally. The state machine asks the Engine through a search probe that runs only on a digit while the list is armed. The Engine arms the row count from the results it shows, and any query edit disarms it until the next refresh, so a digit never picks from a stale list. The trailing Browse row is never a target, and a sticky (Shift+Return) session keeps typing digits into its search. Refs: W-886 --- Sources/Mojito/App/Engine.swift | 53 ++++++- .../KeyMonitor/TriggerStateMachine.swift | 43 ++++++ Sources/Mojito/Picker/HintsFooter.swift | 29 ++-- Sources/Mojito/Picker/PickerView.swift | 18 ++- Sources/Mojito/Picker/PickerViewModel.swift | 4 + .../TriggerStateMachineTests.swift | 142 ++++++++++++++++++ 6 files changed, 276 insertions(+), 13 deletions(-) diff --git a/Sources/Mojito/App/Engine.swift b/Sources/Mojito/App/Engine.swift index c2852ea..827cb37 100644 --- a/Sources/Mojito/App/Engine.swift +++ b/Sources/Mojito/App/Engine.swift @@ -94,6 +94,18 @@ 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. + 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() @@ -600,6 +612,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 +663,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 +889,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 +998,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 +1143,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 +1166,11 @@ final class Engine: ObservableObject, KeyMonitorDelegate { results.append(EmojiBrowser.browseRow) } viewModel.update(query: query, results: results) + // Digits pick from the first 9 rows (never the trailing Browse row) + // while a capture shows the list; a sticky session types them into + // its search instead. + let capturing: Bool = { if case .capturing = stateMachine.state { return true } else { return false } }() + setListQuickPick(capturing ? min(9, max(0, results.count - 1)) : 0) } private func corpusFor(scope: CaptureScope) -> SearchCorpus { @@ -1225,7 +1273,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) + } +} From 7005f8a175f10ea32727a4e7bcfd3f04a1a15ea8 Mon Sep 17 00:00:00 2001 From: Aryan-Techie <76107940+Aryan-Techie@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:02:44 +0530 Subject: [PATCH 2/2] W-886: keep the quick-pick probe off the state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collision probe runs inside `stateMachine.handle`, a mutating call on an Engine property, and it read `stateMachine.config` through `corpusFor`. That overlapping access fails Swift's runtime exclusivity check, so the first digit typed with the list armed (`:smil` + `1`) would trap. Engine now mirrors the trigger config it loads, and `corpusFor` reads that copy. Digits also stop at the rows the list draws without scrolling (`PickerLayout.maxVisibleRows`, 6), so a digit never inserts a row the user can't see; the footer reads `1–6`. Refs: W-886 --- Sources/Mojito/App/Engine.swift | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/Sources/Mojito/App/Engine.swift b/Sources/Mojito/App/Engine.swift index 827cb37..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, @@ -96,7 +100,8 @@ final class Engine: ObservableObject, KeyMonitorDelegate { // 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. + // 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( @@ -259,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). @@ -1166,11 +1172,12 @@ final class Engine: ObservableObject, KeyMonitorDelegate { results.append(EmojiBrowser.browseRow) } viewModel.update(query: query, results: results) - // Digits pick from the first 9 rows (never the trailing Browse row) - // while a capture shows the list; a sticky session types them into - // its search instead. + // 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 } }() - setListQuickPick(capturing ? min(9, max(0, results.count - 1)) : 0) + let drawn = min(9, PickerLayout.maxVisibleRows) + setListQuickPick(capturing ? min(drawn, max(0, results.count - 1)) : 0) } private func corpusFor(scope: CaptureScope) -> SearchCorpus { @@ -1181,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