Skip to content
Merged
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
6 changes: 4 additions & 2 deletions Sources/Mojito/KeyMonitor/TriggerConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ struct TriggerConfig: Equatable, Codable {
/// When symbols is enabled and this is true, symbols aren't a separate
/// trigger — they blend into the emoji results (the emoji trigger searches
/// both corpora). When false, symbols are a scoped trigger via their own
/// open (`::symbol::`). Defaults true.
/// open (`::symbol::`). `default` (fresh installs) is false: `::` is the
/// symbol trigger. The `true` here and in the tolerant decode keeps
/// configs saved before this flag existed blending, as they always did.
var symbolsFollowEmoji: Bool = true
/// When true, the Quick Access open follows the emoji trigger (`:` → `:?`).
/// When false, `quickAccess.open` is the user's own (preset or custom)
Expand Down Expand Up @@ -64,7 +66,7 @@ struct TriggerConfig: Equatable, Codable {
symbols: Trigger(mode: .symbols, open: "::", enabled: true),
gif: Trigger(mode: .gif, open: ":::", enabled: true),
quickAccess: Trigger(mode: .quickAccess, open: ":?", enabled: true),
symbolsFollowEmoji: true,
symbolsFollowEmoji: false,
quickAccessFollowEmoji: true
)

Expand Down
14 changes: 11 additions & 3 deletions Sources/Mojito/KeyMonitor/TriggerConfigStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,17 @@ enum TriggerConfigStore {
// enabled + requireDoubleColon → scoped (`::`, follow=false)
// enabled + !requireDoubleColon → blended (follow=true)
// !enabled → off
let symbolsEnabled = defaults.object(forKey: PrefsKey.symbolsEnabled) as? Bool ?? true
let requireDoubleColon = defaults.object(forKey: PrefsKey.symbolsRequireDoubleColon) as? Bool ?? false
let symbolsFollowEmoji = symbolsEnabled && !requireDoubleColon
// A fresh install has neither legacy key and gets the default: the
// scoped `::` trigger.
let legacyEnabled = defaults.object(forKey: PrefsKey.symbolsEnabled) as? Bool
let legacyDoubleColon = defaults.object(forKey: PrefsKey.symbolsRequireDoubleColon) as? Bool
let symbolsEnabled = legacyEnabled ?? true
let symbolsFollowEmoji: Bool
if legacyEnabled == nil && legacyDoubleColon == nil {
symbolsFollowEmoji = TriggerConfig.default.symbolsFollowEmoji
} else {
symbolsFollowEmoji = symbolsEnabled && !(legacyDoubleColon ?? false)
}
let gifEnabled = defaults.object(forKey: PrefsKey.gifSearchEnabled) as? Bool ?? true
let qaEnabled = defaults.object(forKey: PrefsKey.quickAccessEnabled) as? Bool ?? true

Expand Down
2 changes: 1 addition & 1 deletion Sources/Mojito/Onboarding/OnboardingScreens.swift
Original file line number Diff line number Diff line change
Expand Up @@ -517,7 +517,7 @@ struct FeaturesStep: View {
takenOpens: takenOpens(excluding: .symbols),
defaultOpen: TriggerConfig.default.symbols.open,
sameAsEmoji: $triggers.symbolsFollowEmoji,
defaultFollowsEmoji: true
defaultFollowsEmoji: TriggerConfig.default.symbolsFollowEmoji
)
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Mojito/Settings/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ struct GeneralSettingsView: View {
takenOpens: takenOpens(excluding: .symbols),
defaultOpen: TriggerConfig.default.symbols.open,
sameAsEmoji: $triggers.symbolsFollowEmoji,
defaultFollowsEmoji: true
defaultFollowsEmoji: TriggerConfig.default.symbolsFollowEmoji
)
}
}
Expand Down
42 changes: 32 additions & 10 deletions Tests/MojitoTests/TriggerConfigStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,17 @@ struct TriggerConfigStoreTests {
"quickAccess":{"mode":"quickAccess","open":":?","enabled":true}}
"""
let decoded = try JSONDecoder().decode(TriggerConfig.self, from: Data(legacy.utf8))
#expect(decoded == .default)
// No `symbolsFollowEmoji` key → it keeps blending (see below); every
// other field matches the default.
var expected = TriggerConfig.default
expected.symbolsFollowEmoji = true
#expect(decoded == expected)
}

@Test func blobMissingSymbolsFollowEmojiDefaultsTrue() throws {
// A blob written before `symbolsFollowEmoji` existed must decode with it
// defaulting true (and adding fields must never nuke the rest).
// true, as it behaved then, even though fresh installs now default to
// the scoped `::` trigger (and adding fields must never nuke the rest).
let old = """
{"emoji":{"mode":"emoji","open":":","enabled":true},
"symbols":{"mode":"symbols","open":"::","enabled":true},
Expand All @@ -57,13 +62,19 @@ struct TriggerConfigStoreTests {
}

@Test func blobMissingEverythingDecodesToDefault() throws {
// An empty object falls back to defaults for every field.
// An empty object falls back to defaults for every field, except that a
// saved config without `symbolsFollowEmoji` keeps blending.
let decoded = try JSONDecoder().decode(TriggerConfig.self, from: Data("{}".utf8))
#expect(decoded == .default)
var expected = TriggerConfig.default
expected.symbolsFollowEmoji = true
#expect(decoded == expected)
}

@Test func symbolsFollowEmojiDefaultsTrue() {
#expect(TriggerConfig.default.symbolsFollowEmoji == true)
@Test func symbolsDefaultToScopedDoubleColon() {
// Fresh installs: `::` is the symbol trigger, not blended into `:`.
#expect(TriggerConfig.default.symbolsFollowEmoji == false)
#expect(TriggerConfig.default.symbols.open == "::")
#expect(TriggerConfig.default.active.contains { $0.mode == .symbols })
}

// MARK: save / load
Expand Down Expand Up @@ -102,11 +113,11 @@ struct TriggerConfigStoreTests {
@Test func migrationOnEmptyDefaultsEnablesAllFeatures() {
let suite = freshSuite()
let config = TriggerConfigStore.load(defaults: suite)
// No legacy prefs set → every feature on; symbols blends into emoji
// search (follow=true), so its open mirrors the emoji open.
// No legacy prefs set → a fresh install: every feature on, symbols on
// their own scoped `::` trigger.
#expect(config.emoji == Trigger(mode: .emoji, open: ":", enabled: true))
#expect(config.symbols.enabled == true)
#expect(config.symbolsFollowEmoji == true)
#expect(config.symbols == Trigger(mode: .symbols, open: "::", enabled: true))
#expect(config.symbolsFollowEmoji == false)
#expect(config.gif == Trigger(mode: .gif, open: ":::", enabled: true))
#expect(config.quickAccess == Trigger(mode: .quickAccess, open: ":?", enabled: true))
}
Expand All @@ -115,6 +126,17 @@ struct TriggerConfigStoreTests {
#expect(TriggerConfig.default.symbols.enabled == true)
}

@Test func migrationKeepsLegacyBlendedSymbols() {
// An old install that turned symbols on before triggers were
// configurable (no requireDoubleColon key) blended them into `:`;
// migrating keeps that.
let legacy = freshSuite()
legacy.set(true, forKey: PrefsKey.symbolsEnabled)
let config = TriggerConfigStore.load(defaults: legacy)
#expect(config.symbols.enabled == true)
#expect(config.symbolsFollowEmoji == true)
}

@Test func migrationMapsSymbolsEnableOntoTrigger() {
// Legacy symbolsEnabled gates the symbols trigger.
let on = freshSuite()
Expand Down
11 changes: 6 additions & 5 deletions Tests/MojitoTests/TriggerMatcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,11 @@ struct TriggerMatcherTests {
#expect(m.close(for: .quickAccess) == nil)
}

@Test func defaultSymbolsDisabledSoNotTerminal() {
// Symbols is disabled by default → `::` resolves to nothing terminal,
// but is still a viable prefix on the way to `:::`.
@Test func defaultDoubleColonIsSymbols() {
// Symbols default to their own scoped `::` trigger, which still
// extends on toward `:::` (gif).
let m = TriggerMatcher(config: .default)
#expect(m.terminalMode(for: chars("::")) == nil)
#expect(m.terminalMode(for: chars("::")) == .symbols)
#expect(m.canExtend(chars("::")) == true) // `:::`
}

Expand Down Expand Up @@ -154,7 +154,8 @@ struct TriggerMatcherTests {
cfg.emoji = Trigger(mode: .emoji, open: ";", enabled: true)
cfg.gif = Trigger(mode: .gif, open: ";;;", enabled: true)
cfg.quickAccess = Trigger(mode: .quickAccess, open: ";?", enabled: true)
// symbols disabled by default; nothing starts with `:`.
cfg.symbols = Trigger(mode: .symbols, open: ";;", enabled: true)
// Nothing starts with `:`.
let m = TriggerMatcher(config: cfg)
#expect(m.colonStartsATrigger == false)
}
Expand Down
28 changes: 15 additions & 13 deletions Tests/MojitoTests/TriggerStateMachineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,22 +225,11 @@ struct TriggerStateMachineTests {
#expect(next.action == .openPicker(query: "a", scope: .symbolsOnly))
}

@Test func symbolsTriggerOffByDefaultButResolvesWhenEnabled() {
// Default config: symbols off → `::` cancels (no symbols scope).
@Test func doubleColonOpensSymbolsScopeByDefault() {
// Default config: `::star::` resolves in symbols scope.
var sm = TriggerStateMachine()
sm.setConfig(.default)
_ = sm.handle(.colon)
#expect(sm.handle(.colon).action == .closePicker)
#expect(sm.state == .idle)

// Enable the scoped symbols trigger → `::star::` resolves in symbols
// scope. (Blended symbols, `symbolsFollowEmoji`, isn't a `::` opener.)
var cfg = TriggerConfig.default
cfg.symbols.enabled = true
cfg.symbolsFollowEmoji = false
sm = TriggerStateMachine()
sm.setConfig(cfg)
_ = sm.handle(.colon)
_ = sm.handle(.colon) // `::` → symbolsOnly capture
for ch in "star" { _ = sm.handle(.nameChar(ch)) }
_ = sm.handle(.colon) // first close char (mirror `::`)
Expand All @@ -250,6 +239,19 @@ struct TriggerStateMachineTests {
#expect(sm.lastInsertCloseLen == 2)
}

@Test func blendedSymbolsMakeDoubleColonCancel() {
// Symbols following emoji (blended into `:`) aren't a `::` opener, so
// `::` cancels the capture.
var cfg = TriggerConfig.default
cfg.symbolsFollowEmoji = true
cfg.normalize()
var sm = TriggerStateMachine()
sm.setConfig(cfg)
_ = sm.handle(.colon)
#expect(sm.handle(.colon).action == .closePicker)
#expect(sm.state == .idle)
}

// MARK: konami (state-machine-driven payoff)

@Test func konamiSequenceTriggersAfterColon() {
Expand Down
Loading