Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions Sources/Mojito/App/Engine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down
43 changes: 43 additions & 0 deletions Sources/Mojito/KeyMonitor/TriggerStateMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -769,6 +794,7 @@ struct TriggerStateMachine {
mutating func enterBrowsing(query: String) {
state = .browsing(query: query)
emptyPickerActive = false
listQuickPickCount = 0
konamiProgress = 0
}

Expand All @@ -779,6 +805,7 @@ struct TriggerStateMachine {
state = .stickyPicking(query: query)
stickyPickJustFired = true
emptyPickerActive = false
listQuickPickCount = 0
openBuffer = []
closeProgress = 0
konamiProgress = 0
Expand All @@ -800,6 +827,7 @@ struct TriggerStateMachine {
closeProgress = 0
emptyPickerActive = false
pillEmojiCount = 0
listQuickPickCount = 0
lastWasWordChar = false
konamiProgress = 0
idleWord = ""
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
29 changes: 19 additions & 10 deletions Sources/Mojito/Picker/HintsFooter.swift
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
}
Expand Down
18 changes: 16 additions & 2 deletions Sources/Mojito/Picker/PickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions Sources/Mojito/Picker/PickerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -65,6 +68,7 @@ final class PickerViewModel: ObservableObject {
isVisible = false
compact = false
expanded = false
quickPickCount = 0
browser = nil
}
}
Loading