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
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,13 @@ Either way, the app keeps itself up to date via Sparkle.
- **Lock or switch** — per-app and per-URL rules can *lock* an input source
(re-applied whenever it drifts) or just *switch* to it once when you focus the
app or page, then step out of the way and let you change it freely.
- **Lock globally, or just switch** — set the global default to one input
source to pin it everywhere, or set it to **None** to use LockIME as a pure
per-app/per-site switcher — it switches you in, then leaves you free, pinning
nothing.
- **Lock globally, or just switch** — give the global default input source one of
three behaviors: *lock* to pin it everywhere; *switch* to switch you into it
once whenever an app falls back to the global default (it has no rule of its
own, or its rule is *Use default*), then leave you free; or **None** for no
global behavior at all — LockIME then acts only through your per-app and per-URL
rules. *Switch* actively switches you in whenever the default applies; **None**
does nothing globally.
- **Flexible URL matching** — per-URL rules (enhanced mode) match by a domain and
its subdomains, an exact domain, a domain keyword, or a regular expression over
the full URL, and apply in a priority order you drag to arrange — first match
Expand Down
13 changes: 9 additions & 4 deletions Sources/LockIME/API/URLCommandHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ final class URLCommandHandler {
// Global source targeting
case .lockToSource(let selector):
return withResolved(selector) { state.lockToSource($0) }
case .setDefaultSource(let selector):
guard let selector else { state.setDefaultSource(nil); return .success(nil) }
return withResolved(selector) { state.setDefaultSource($0) }
case .setDefaultSource(let selector, let action):
guard let selector else { state.setDefault(source: nil, action: action); return .success(nil) }
return withResolved(selector) { state.setDefault(source: $0, action: action) }
case .cycleSource(let direction):
state.cycleGlobalSource(direction); return .success(nil)
case .switchSource(let selector):
Expand Down Expand Up @@ -251,7 +251,12 @@ final class URLCommandHandler {
"build": Bundle.main.buildVersion,
]
if let id = state.currentSourceID { payload["currentSource"] = sourceDict(id) }
if let id = state.config.defaultSourceID { payload["defaultSource"] = sourceDict(id) }
if let id = state.config.defaultSourceID {
payload["defaultSource"] = sourceDict(id)
// Mirror the global default's lock/switch behavior, matching the
// `defaultAction` field `get-config` re-encodes from the config.
payload["defaultAction"] = state.config.defaultAction.rawValue
}
if let frontmost = state.liveFrontmostBundleID { payload["frontmostApp"] = frontmost }
return payload
}
Expand Down
37 changes: 29 additions & 8 deletions Sources/LockIME/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -365,11 +365,30 @@ final class AppState {
commit()
}

/// Lock to a specific source from the menu bar: make it the global target and
/// turn LockIME on in a single commit, so a one-tap menu pick always pins,
/// even from an off state. Clicking the already-locked source instead clears
/// the global target via `setDefaultSource(nil)` (leaving the app and
/// switching alive).
/// Set the global default's lock/switch behavior (the App Rules "Behavior"
/// picker write path). Inert until a default source is set.
func setDefaultAction(_ action: RuleAction) {
config.defaultAction = action
commit()
}

/// Set the global default source **and** its behavior in one commit — the
/// URL-scheme `set-default-source` write path. Clearing the source (`nil`)
/// leaves `defaultAction` untouched (it is inert without a source anyway).
func setDefault(source id: InputSourceID?, action: RuleAction) {
config.defaultSourceID = id
if id != nil { config.defaultAction = action }
commit()
}

/// Set the global target from the menu bar: make `id` the global default source
/// and turn LockIME on in a single commit, so a one-tap menu pick engages even
/// from an off state. It sets only the *source* and **preserves** the configured
/// `defaultAction` — under a `.switched` global default this switches you into
/// `id` once (then leaves you free); under the default `.lock` it pins `id`. The
/// lock/switch mode is a deliberate Settings choice, so the menu never silently
/// flips it. Clicking the already-targeted source instead clears the global
/// target via `setDefaultSource(nil)` (leaving the app and switching alive).
func lockToSource(_ id: InputSourceID) {
config.defaultSourceID = id
config.isEnabled = true
Expand All @@ -378,10 +397,12 @@ final class AppState {

// MARK: - Shortcut-driven source cycling

/// Lock the *global* target to the previous/next input source in the list,
/// Move the *global* target to the previous/next input source in the list,
/// wrapping around the ends, and turn LockIME on — the same write path as
/// `lockToSource`. Never lands on "none", and does nothing when fewer than
/// two input sources are installed (there's nowhere to cycle).
/// `lockToSource`, so it likewise sets only the source and **preserves** the
/// configured `defaultAction` (lock or switch). Never lands on "none", and does
/// nothing when fewer than two input sources are installed (there's nowhere to
/// cycle).
func cycleGlobalSource(_ direction: CycleDirection) {
let reference = config.defaultSourceID ?? engine?.currentSourceID()
guard let next = SourceCycler.step(
Expand Down
5 changes: 3 additions & 2 deletions Sources/LockIME/UI/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ struct MenuBarView: View {
// the gutter at a constant width, so the menu doesn't grow/shrink as the
// lock toggles. (A `Toggle`'s native checkmark lives in NSMenu's *state*
// column, which collapses to zero width when nothing is checked — that
// is what made the menu jump.) Clicking an unchecked source locks to it
// (sets the global target + turns LockIME on, one commit); clicking the
// is what made the menu jump.) Clicking an unchecked source targets it
// (sets the global source + turns LockIME on, one commit, preserving the
// configured lock/switch default behavior); clicking the
// checked source clears the global target (app and switch rules stay
// live). No separate on/off toggle, no submenu. Source names are
// verbatim system strings, not catalog keys. The global toggle-lock
Expand Down
14 changes: 13 additions & 1 deletion Sources/LockIME/UI/Settings/AppRulesSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,31 @@ struct AppRulesSettingsPane: View {
get: { state.config.defaultSourceID },
set: { state.setDefaultSource($0) }
)
let defaultActionBinding = Binding<RuleAction>(
get: { state.config.defaultAction },
set: { state.setDefaultAction($0) }
)
let newRuleModeBinding = Binding<AppRuleMode>(
get: { state.newAppRuleMode },
set: { state.setNewAppRuleMode($0) }
)

Form {
Section {
Picker("Locked input source", selection: defaultBinding) {
Picker("Input source", selection: defaultBinding) {
Text("None").tag(InputSourceID?.none)
ForEach(state.availableSources) { source in
Text(source.localizedName).tag(InputSourceID?.some(source.id))
}
}
// Lock vs one-shot switch only matters once a source is set — the
// picker is inert (and hidden) while the default is "None".
if state.config.defaultSourceID != nil {
Picker("Behavior", selection: defaultActionBinding) {
Text("Lock to").tag(RuleAction.lock)
Text("Switch to").tag(RuleAction.switchOnce)
}
}
} header: {
Text("Global default")
} footer: {
Expand Down
12 changes: 8 additions & 4 deletions Sources/LockIME/UI/Settings/ImportReviewSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,9 @@ struct ImportReviewSheet: View {
/// The file-side binding as composable `Text`. A source-pinning rule reads as
/// "Lock to %@" / "Switch to %@" so lock and switch are visibly parallel (and
/// a same-source lock-vs-switch conflict is distinguishable); a non-pinning
/// app mode reads as its mode word; the global default (always a lock, no
/// ambiguity) and a sourceless binding read as the bare name / "Default".
/// app mode reads as its mode word; the global default now reads the same
/// "Lock to"/"Switch to" way too (it carries a lock/switch action); a
/// sourceless binding reads "Default".
/// Returning `Text` keeps it recolorable inside `HStack`s while resolving
/// catalog keys against the injected `\.locale`.
private func fileBindingText(_ item: ImportItem) -> Text {
Expand All @@ -477,8 +478,11 @@ struct ImportReviewSheet: View {
) -> Text {
switch subject {
case .globalDefault:
if let source { return Text(verbatim: model.displayName(for: source)) }
return Text("Default")
// The global default now carries a lock/switch action, so render it as
// "Lock to %@" / "Switch to %@" too — parallel to app/URL rules, so a
// same-source lock-vs-switch conflict stays distinguishable.
guard let source else { return Text("Default") }
return pinnedBindingText(isSwitch: action == .switchOnce, source: source)
case .app:
if let mode, !mode.pinsSource { return Text(modeKey(mode)) } // ignore / use default
guard let source else { return Text("Default") }
Expand Down
14 changes: 12 additions & 2 deletions Sources/LockIMEKit/API/URLCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ public enum URLCommand: Equatable, Sendable {

// Global source targeting
case lockToSource(SourceSelector)
case setDefaultSource(SourceSelector?) // nil clears the global default
// `source` nil clears the global default; `action` picks lock vs one-shot
// switch (default `.lock`, ignored on the clear path).
case setDefaultSource(source: SourceSelector?, action: RuleAction)
case cycleSource(CycleDirection)
case switchSource(SourceSelector) // transient one-shot, no standing lock

Expand Down Expand Up @@ -269,7 +271,15 @@ public enum URLCommandParser {
case "lock-to-source":
return requiredSource(params).map { .lockToSource($0) }
case "set-default-source":
return .success(.setDefaultSource(optionalSource(params)))
let source = optionalSource(params)
let rawAction = params["action"] ?? "lock"
let action: RuleAction
switch rawAction.lowercased() {
case "lock": action = .lock
case "switch", "switchonce", "switch-once": action = .switchOnce
default: return .failure(.invalidParameter(name: "action", value: rawAction))
}
return .success(.setDefaultSource(source: source, action: action))
case "cycle-source":
return direction(params).map { .cycleSource($0) }
case "switch-source":
Expand Down
22 changes: 18 additions & 4 deletions Sources/LockIMEKit/Backup/ConfigBackup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,27 +48,34 @@ public struct BackupURLRule: Codable, Equatable, Sendable {
}

/// The portable part of a `LockConfiguration` — the "rules and binding intent"
/// a backup carries between machines: the global default source, per-app rules,
/// and per-URL rules. Per-device *runtime* state (the master lock, enhanced
/// mode, language preference, the login item) is deliberately **not** here, so
/// importing never flips those on someone else's machine.
/// a backup carries between machines: the global default source (and its
/// lock/switch action), per-app rules, and per-URL rules. Per-device *runtime*
/// state (the master lock, enhanced mode, language preference, the login item)
/// is deliberately **not** here, so importing never flips those on someone
/// else's machine.
///
/// `sourceNames` is a display-name catalog (input-source identifier → its name
/// at export time) so a target machine that is missing an input source can
/// still show a human-readable label instead of a bare identifier.
public struct BackupPayload: Codable, Equatable, Sendable {
public var defaultSourceID: InputSourceID?
/// The global default's lock/switch behavior; travels with `defaultSourceID`
/// on import. Default `.lock` (fully backward compatible). Mirrors
/// `LockConfiguration.defaultAction`.
public var defaultAction: RuleAction
public var appRules: [AppRule]
public var urlRules: [BackupURLRule]
public var sourceNames: [String: String]

public init(
defaultSourceID: InputSourceID? = nil,
defaultAction: RuleAction = .lock,
appRules: [AppRule] = [],
urlRules: [BackupURLRule] = [],
sourceNames: [String: String] = [:]
) {
self.defaultSourceID = defaultSourceID
self.defaultAction = defaultAction
self.appRules = appRules
self.urlRules = urlRules
self.sourceNames = sourceNames
Expand All @@ -80,6 +87,12 @@ public struct BackupPayload: Codable, Equatable, Sendable {
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
defaultSourceID = try container.decodeIfPresent(InputSourceID.self, forKey: .defaultSourceID)
// Decode the action as a raw *string* and map it (same rationale as
// `BackupURLRule.action`): a missing key (a backup written before the
// global default carried an action) OR an unrecognized value both fall
// back to `.lock` rather than throwing and mis-reporting the file.
let rawDefaultAction = try container.decodeIfPresent(String.self, forKey: .defaultAction)
defaultAction = rawDefaultAction.flatMap(RuleAction.init(rawValue:)) ?? .lock
appRules = try container.decodeIfPresent([AppRule].self, forKey: .appRules) ?? []
urlRules = try container.decodeIfPresent([BackupURLRule].self, forKey: .urlRules) ?? []
sourceNames = try container.decodeIfPresent([String: String].self, forKey: .sourceNames) ?? [:]
Expand Down Expand Up @@ -225,6 +238,7 @@ public extension ConfigBackup {

let payload = BackupPayload(
defaultSourceID: config.defaultSourceID,
defaultAction: config.defaultAction,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
appRules: config.appRules,
urlRules: config.urlRules.map { BackupURLRule(hostPattern: $0.hostPattern, lockedSourceID: $0.lockedSourceID, action: $0.action, matchType: $0.matchType) },
sourceNames: catalog
Expand Down
34 changes: 29 additions & 5 deletions Sources/LockIMEKit/Backup/ImportPlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ public struct ImportPlan: Sendable, Equatable {
/// from. Held so `resolvedConfiguration()` can keep `isEnabled` and
/// `enhancedModeEnabled` untouched.
public let baseConfig: LockConfiguration
/// The file's global-default lock/switch action. The diff keys the global
/// default on its source alone (see the `.globalDefault` item), so the action
/// travels alongside — applied at the same site the file's default source is,
/// and only when that binding wins (never on keep-local).
public let fileDefaultAction: RuleAction
/// Local app rules absent from the file — removed by Replace.
public let localOnlyAppRuleCount: Int
/// Local URL rules absent from the file — removed by Replace.
Expand Down Expand Up @@ -186,6 +191,7 @@ public struct ImportPlan: Sendable, Equatable {
) {
self.mode = mode
self.baseConfig = current
self.fileDefaultAction = backup.payload.defaultAction
self.installedSourceIDs = Set(installedSources.map(\.id))

var names: [InputSourceID: String] = [:]
Expand All @@ -199,12 +205,18 @@ public struct ImportPlan: Sendable, Equatable {

var items: [ImportItem] = []

// Global default.
// Global default. Both the source AND its lock/switch action are part of
// the binding, so a same-source/different-action file default is a
// `.conflict` (not `.unchanged`) — mirroring how app rules compare `mode`
// and URL rules compare `action`. The action rides on `fileAction`/
// `localAction` so the Review row can render "Lock to"/"Switch to" and
// `resolvedConfiguration()` / `summary()` see the change.
if let fileDefault = backup.payload.defaultSourceID {
let fileAction = backup.payload.defaultAction
let status: ImportItem.Status
if current.defaultSourceID == nil {
status = .new
} else if current.defaultSourceID == fileDefault {
} else if current.defaultSourceID == fileDefault && current.defaultAction == fileAction {
status = .unchanged
} else {
status = .conflict
Expand All @@ -213,7 +225,9 @@ public struct ImportPlan: Sendable, Equatable {
id: "default", subject: .globalDefault, status: status,
fileMode: nil, fileSource: fileDefault,
localMode: nil, localSource: current.defaultSourceID,
include: true, resolution: .keepLocal, missingDisposition: .keep
include: true, resolution: .keepLocal, missingDisposition: .keep,
fileAction: fileAction,
localAction: current.defaultSourceID != nil ? current.defaultAction : nil
))
}

Expand Down Expand Up @@ -359,6 +373,9 @@ public struct ImportPlan: Sendable, Equatable {
public func resolvedConfiguration() -> LockConfiguration {
var appRules: [String: AppRule]
var defaultSource: InputSourceID?
// The default's action rides with its source: it changes only when a file
// default binding actually wins below, otherwise the local action stands.
var defaultAction: RuleAction = baseConfig.defaultAction

// URL rules carry an explicit user-controlled priority (first match wins),
// so unlike app rules they are never alphabetized. `urlMap` holds the
Expand Down Expand Up @@ -390,7 +407,10 @@ public struct ImportPlan: Sendable, Equatable {
// A missing default set to "remove" falls back to the local
// default rather than clearing it (clearing the global default
// would strip the lock's target — too destructive to do here).
if !drop { defaultSource = item.fileSource }
if !drop {
defaultSource = item.fileSource
defaultAction = fileDefaultAction
}
case .app(let bundleID):
if drop {
appRules[bundleID] = nil
Expand Down Expand Up @@ -419,6 +439,7 @@ public struct ImportPlan: Sendable, Equatable {
result.appRules = appRules.values.sorted { $0.bundleID < $1.bundleID }
result.urlRules = resolvedURLOrder(present: urlMap)
result.defaultSourceID = defaultSource
result.defaultAction = defaultAction
return result
}

Expand Down Expand Up @@ -505,7 +526,10 @@ public struct ImportPlan: Sendable, Equatable {
/// `"default"`; app rules `"app:<id>"`; URL rules `"url:<host>"`.
private func bindingKeys(of config: LockConfiguration, includeURLPosition: Bool) -> [String: String] {
var map: [String: String] = [:]
if let def = config.defaultSourceID { map["default"] = def.rawValue }
// The default's action is part of its binding (like an app rule's mode or a
// URL rule's action), so an action-only change on the same source still
// reads as an update and flips `hasEffect` on.
if let def = config.defaultSourceID { map["default"] = "\(config.defaultAction.rawValue)|\(def.rawValue)" }
for rule in config.appRules {
// The mode rawValue already separates lock from switch; only a
// source-pinning mode contributes a source.
Expand Down
Loading
Loading