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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,19 @@ 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** — one **Enable LockIME** switch powers
everything; a subordinate **Enable locking** toggle controls only the
continuous lock. Turn locking off to use LockIME as a pure per-app/per-site
switcher — it switches you in, then leaves you free, pinning nothing.
- **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
wins.
- **Menu-bar control** — activate/deactivate, switch the locked input source,
view the current source, and track the activation count from the menu bar.
- **Keyboard shortcuts** — configurable global shortcuts to toggle locking and
cycle the locked input source, plus per-app shortcuts to cycle or unbind the
rule for whichever app is frontmost.
- **Keyboard shortcuts** — configurable global shortcuts to toggle LockIME on or
off and cycle the locked input source, plus per-app shortcuts to cycle or
unbind the rule for whichever app is frontmost.
- **Launch at login** — starts automatically when you log in (off by default).
- **Light & dark mode** — a unified, system-native design language that adapts to
light and dark appearance, plus a bespoke app icon. See
Expand Down
11 changes: 9 additions & 2 deletions Sources/LockIME/API/URLCommandHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,16 @@ final class URLCommandHandler {
/// `[[String: Any]]` array) for query commands, `nil` for actions.
private func perform(_ command: URLCommand) -> Result<Any?, URLCommandError> {
switch command {
// Master lock
// Master ("Enable LockIME") — gates the whole app.
case .lock:
state.setMasterEnabled(true); return .success(nil)
case .unlock:
state.setMasterEnabled(false); return .success(nil)
case .toggleLock:
state.setMasterEnabled(!state.isLocked); return .success(nil)
state.setMasterEnabled(!state.isAppEnabled); return .success(nil)
// Lock sub-toggle ("Enable locking").
case .setLocking(let flag):
state.setLockingEnabled(resolve(flag, current: state.config.lockingEnabled)); return .success(nil)

// Global source targeting
case .lockToSource(let selector):
Expand Down Expand Up @@ -238,6 +241,10 @@ final class URLCommandHandler {

private func statusPayload() -> [String: Any] {
var payload: [String: Any] = [
// `enabled` = the master ("Enable LockIME"); `lockingEnabled` = the
// lock sub-toggle; `locked` = both (a continuous lock is in force).
"enabled": state.isAppEnabled,
"lockingEnabled": state.config.lockingEnabled,
"locked": state.isLocked,
"enhancedMode": state.config.enhancedModeEnabled,
"launchAtLogin": state.launchAtLoginActive,
Expand Down
44 changes: 37 additions & 7 deletions Sources/LockIME/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,19 @@ final class AppState {
/// pane can route the user to General's single Accessibility grant.
var settingsTab: SettingsTab = .general

/// Master on/off, mirroring `config.isEnabled`.
var isLocked: Bool { config.isEnabled }
/// The master on/off — "Enable LockIME". Mirrors `config.isEnabled`; gates
/// the whole app (both locking and switching). Bound by the General master
/// toggle.
var isAppEnabled: Bool { config.isEnabled }

/// Whether a **continuous lock** is in force right now: the master is on *and*
/// the lock sub-toggle is on. This is what the padlock surfaces represent
/// (tray/About icon, menu glyph + "Locked/Unlocked" header, the locked-source
/// checkmark) — they speak to the *lock* capability, not mere app activeness.
/// For anyone who leaves locking on (the default) this equals `isEnabled`, so
/// those surfaces look exactly as before; only the opt-in pure-switch mode
/// (master on, locking off) shows them "unlocked".
var isLocked: Bool { config.isEnabled && config.lockingEnabled }

/// The SwiftData container backing the activation log (for `.modelContainer`).
var modelContainer: ModelContainer { logStore.container }
Expand Down Expand Up @@ -188,10 +199,13 @@ final class AppState {
updateController.onCheckOutcome = { [weak self] outcome in self?.presentUpdateOutcome(outcome) }
updateController.start()

// Global toggle-lock shortcut.
// Global toggle-lock shortcut: flips the master ("Enable LockIME") on/off,
// the app's quick stop/start. `lockingEnabled` persists across toggles, so
// a pure-switch user (locking off) toggling the app off then on stays in
// pure-switch mode rather than silently re-engaging the lock.
KeyboardShortcuts.onKeyUp(for: .toggleLock) { [weak self] in
guard let self else { return }
self.setMasterEnabled(!self.isLocked)
self.setMasterEnabled(!self.isAppEnabled)
}

// Global "lock to previous/next source" — cycle the global target
Expand Down Expand Up @@ -278,17 +292,32 @@ final class AppState {
commit(reason: .lockEngaged)
}

/// Toggle the **continuous-lock** capability (the General "Enable locking"
/// sub-toggle), subordinate to the master. Turning it off drops every standing
/// lock — the global default, per-app `.locked` rules, URL `.lock` rules, and
/// the address-bar lock all go inert — while one-shot switch rules keep firing.
/// That is the "act like Input Source Pro" mode: per-context auto-switch with
/// no global lock. No effect while the master is off (the app is fully idle).
func setLockingEnabled(_ on: Bool) {
config.lockingEnabled = on
commit(reason: .lockEngaged)
}

func setDefaultSource(_ id: InputSourceID?) {
config.defaultSourceID = id
commit()
}

/// Lock to a specific source from the menu bar: make it the global target
/// and turn locking on in a single commit. Clicking the already-locked
/// source instead disables locking via `setMasterEnabled(false)`.
/// Lock to a specific source from the menu bar: make it the global target and
/// engage the lock in a single commit — turning **both** the master and the
/// lock sub-toggle on, so a one-tap menu pick always pins, even from a
/// pure-switch or fully-off state. Clicking the already-locked 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
config.lockingEnabled = true
commit(reason: .lockEngaged)
}

Expand All @@ -305,6 +334,7 @@ final class AppState {
) else { return }
config.defaultSourceID = next
config.isEnabled = true
config.lockingEnabled = true
commit(reason: .lockEngaged)
}

Expand Down
88 changes: 70 additions & 18 deletions Sources/LockIME/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -2858,54 +2858,106 @@
}
}
},
"Enable input-source locking": {
"Enable LockIME": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "启用输入法锁定"
"value": "启用 LockIME"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "啟用輸入法鎖定"
"value": "啟用 LockIME"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "入力ソースのロックを有効にする"
"value": "LockIME を有効にする"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Activer le verrouillage de la source de saisie"
"value": "Activer LockIME"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Eingabequellen-Sperre aktivieren"
"value": "LockIME aktivieren"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Activar el bloqueo de la fuente de entrada"
"value": "Activar LockIME"
}
},
"pt": {
"stringUnit": {
"state": "translated",
"value": "Ativar o bloqueio da fonte de entrada"
"value": "Ativar o LockIME"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Включить блокировку источника ввода"
"value": "Включить LockIME"
}
}
}
},
"Enable locking": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "启用锁定"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "啟用鎖定"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ロックを有効にする"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Activer le verrouillage"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Sperre aktivieren"
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Activar el bloqueo"
}
},
"pt": {
"stringUnit": {
"state": "translated",
"value": "Ativar o bloqueio"
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Включить блокировку"
}
}
}
Expand Down Expand Up @@ -4730,54 +4782,54 @@
}
}
},
"When enabled, LockIME keeps the keyboard input source pinned to the configured target.": {
"Enable LockIME to apply your rules. With locking on, it keeps the input source pinned to your target; turn locking off to only switch on entry — for the apps and sites you set — and stay free to change it.": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "启用后,LockIME 会将键盘输入法固定为所配置的目标。"
"value": "启用 LockIME 后规则才会生效。开启锁定时,它会将输入法持续固定到你的目标;关闭锁定则只在进入你设定的应用和网站时切换一次,之后你可自由更改。"
}
},
"zh-Hant": {
"stringUnit": {
"state": "translated",
"value": "啟用後,LockIME 會將鍵盤輸入法固定為所設定的目標。"
"value": "啟用 LockIME 後規則才會生效。開啟鎖定時,它會將輸入法持續固定到你的目標;關閉鎖定則只在進入你設定的應用程式和網站時切換一次,之後你可自由變更。"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "有効にすると、LockIME はキーボードの入力ソースを設定したターゲットに固定し続けます。"
"value": "ルールを適用するには LockIME を有効にします。ロックをオンにすると入力ソースを指定したターゲットに固定し続けます。ロックをオフにすると、設定したアプリやサイトに入ったときに一度だけ切り替え、その後は自由に変更できます。"
}
},
"fr": {
"stringUnit": {
"state": "translated",
"value": "Une fois activé, LockIME maintient la source de saisie du clavier sur la cible configurée."
"value": "Activez LockIME pour appliquer vos règles. Lorsque le verrouillage est activé, il maintient la source de saisie sur votre cible ; désactivez-le pour ne basculer qu'à l'entrée — pour les apps et sites que vous définissez — tout en restant libre d'en changer."
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "Wenn aktiviert, hält LockIME die Tastatur-Eingabequelle auf dem konfigurierten Ziel."
"value": "Aktivieren Sie LockIME, um Ihre Regeln anzuwenden. Bei aktivierter Sperre bleibt die Eingabequelle auf Ihrem Ziel fixiert; schalten Sie die Sperre aus, um nur beim Wechsel zu den von Ihnen festgelegten Apps und Websites einmal umzuschalten – und sie weiterhin frei zu ändern."
}
},
"es": {
"stringUnit": {
"state": "translated",
"value": "Cuando está activado, LockIME mantiene la fuente de entrada del teclado fijada en el destino configurado."
"value": "Activa LockIME para aplicar tus reglas. Con el bloqueo activado, mantiene la fuente de entrada fijada en tu destino; desactívalo para cambiar solo al entrar —en las apps y sitios que definas— y seguir pudiendo cambiarla libremente."
}
},
"pt": {
"stringUnit": {
"state": "translated",
"value": "Quando ativado, o LockIME mantém a fonte de entrada do teclado fixada no destino configurado."
"value": "Ative o LockIME para aplicar suas regras. Com o bloqueio ativado, ele mantém a fonte de entrada fixada no seu destino; desative o bloqueio para alternar apenas ao entrar — nos apps e sites que você definir — e continuar livre para alterá-la."
}
},
"ru": {
"stringUnit": {
"state": "translated",
"value": "Когда включено, LockIME удерживает источник ввода клавиатуры на заданной цели."
"value": "Включите LockIME, чтобы применить правила. Когда блокировка включена, источник ввода остаётся закреплён за вашей целью; выключите её, чтобы переключаться только при входе — для заданных вами приложений и сайтов — и сохранять свободу смены."
}
}
}
Expand Down
13 changes: 8 additions & 5 deletions Sources/LockIME/UI/MenuBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,19 @@ struct MenuBarView: View {
// 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 target + enables, one commit); clicking the checked source
// disables locking. No separate master toggle, no submenu. Source names
// (sets the global target + engages master and locking, one commit);
// clicking the checked source clears the global target (app and switch
// rules stay live). No separate master toggle, no submenu. Source names
// are verbatim system strings, not catalog keys. The global toggle-lock
// shortcut (Settings ▸ Shortcuts) is unchanged: it flips locking on/off
// against the remembered target.
// shortcut (Settings ▸ Shortcuts) flips the master (Enable LockIME) on/off.
ForEach(state.availableSources) { source in
let isLockedTo = state.isLocked && state.config.defaultSourceID == source.id
Button {
if isLockedTo {
state.setMasterEnabled(false)
// Clear just the global lock target — the app and any one-shot
// switch rules stay alive. (Use Settings to turn LockIME off
// entirely, or to toggle locking without losing the target.)
state.setDefaultSource(nil)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else {
state.lockToSource(source.id)
}
Expand Down
19 changes: 15 additions & 4 deletions Sources/LockIME/UI/Settings/GeneralSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,33 @@ struct GeneralSettingsPane: View {
@Environment(AppState.self) private var state

var body: some View {
let lockBinding = Binding(
get: { state.isLocked },
let masterBinding = Binding(
get: { state.isAppEnabled },
set: { newValue in
withAnimation(DS.Motion.toggle) { state.setMasterEnabled(newValue) }
}
)
let lockingBinding = Binding(
get: { state.config.lockingEnabled },
set: { newValue in
withAnimation(DS.Motion.toggle) { state.setLockingEnabled(newValue) }
}
)

Form {
Section {
Toggle("Enable input-source locking", isOn: lockBinding)
Toggle("Enable LockIME", isOn: masterBinding)
// Subordinate to the master (HIG dependency): dimmed and inert
// while LockIME is off. Turning it off is the "act like Input
// Source Pro" mode — switch rules still fire, nothing is pinned.
Toggle("Enable locking", isOn: lockingBinding)
.disabled(!state.isAppEnabled)
LabeledContent("Current source", value: state.currentSourceName)
LabeledContent("Activations", value: state.activationCount.formatted())
} header: {
Text("Status")
} footer: {
SectionFooter("When enabled, LockIME keeps the keyboard input source pinned to the configured target.")
SectionFooter("Enable LockIME to apply your rules. With locking on, it keeps the input source pinned to your target; turn locking off to only switch on entry — for the apps and sites you set — and stay free to change it.")
}

Section {
Expand Down
Loading
Loading