From ff9c4ba3bc9fa7141689e86007331c566f1419f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=A2=E6=9D=A1?= Date: Sat, 10 Oct 2026 22:54:08 +0800 Subject: [PATCH] feat: left/right side options for the quick-menu hold key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Option, Control and Command can each open the quick menu on the left or right key only, leaving the other side for normal use; "either side" keeps the previous behavior. Existing features.json values ("option", "control", "command") decode unchanged, so no config migration. Detection now reads the unmasked modifierFlags raw value: the device-dependent bits (NX_DEVICELALTKEYMASK and friends) tell the sides apart, while librime still receives device-independent flags. A second guard keeps side-only transitions (left ⌥ held, right ⌥ joins) out of librime, exactly as the old dedup did. - ModifierHold.Key grows 6 sided cases; a hold counts when the masked flags equal generic-flag+side for a sided key, or generic-flag plus at most one side bit for an unsided key - withoutHoldKey keeps the generic flag while the opposite side is still down, so ⌥+digit on that side isn't read as a plain menu key - Swallowing the hold-key release clears librime's ascii_composer tap latch via cancelModifierTap, so a surviving opposite-side release can't toggle Chinese/English - Settings picker offers either/left/right per key ×3 + off - Tests cover sided arming, opposite-side join, sided release filtering, generic-flag preservation and rawValue compatibility --- Apps/AIME/AIMEInputController.swift | 34 ++++- Apps/AIMESettings/Views/Components.swift | 12 +- CHANGELOG.en.md | 4 + CHANGELOG.md | 4 + .../Sources/AIMECore/ModifierHold.swift | 110 ++++++++++++--- .../AIMECoreTests/ModifierHoldTests.swift | 128 ++++++++++++++++++ 6 files changed, 265 insertions(+), 27 deletions(-) diff --git a/Apps/AIME/AIMEInputController.swift b/Apps/AIME/AIMEInputController.swift index 0086e1c..1c01ea3 100644 --- a/Apps/AIME/AIMEInputController.swift +++ b/Apps/AIME/AIMEInputController.swift @@ -13,6 +13,8 @@ final class AIMEInputController: IMKInputController { private(set) var session: RimeSession? private var sessionGeneration = -1 private var lastModifiers: NSEvent.ModifierFlags = [] + /// Unmasked flags of the last modifier event: device bits tell ⌥ left from right. + private var lastDeviceFlags: UInt = 0 private var appOptions = InputEngine.AppOptions() private var bundleID: String? private var hasMarkedText = false @@ -113,7 +115,10 @@ final class AIMEInputController: IMKInputController { engine.refreshTheme() bundleID = client?.bundleIdentifier() appOptions = engine.appOptions(for: bundleID) - lastModifiers = NSEvent.modifierFlags.intersection(.deviceIndependentFlagsMask) + // One snapshot for both views so a flag flip between reads can't desync them. + let currentFlags = NSEvent.modifierFlags + lastModifiers = currentFlags.intersection(.deviceIndependentFlagsMask) + lastDeviceFlags = currentFlags.rawValue // Nothing here may call back into the client: it is blocked until activateServer // returns. A reused session may be stale if another window of the same app // switched Chinese/English meanwhile, so re-apply the remembered state. @@ -129,6 +134,7 @@ final class AIMEInputController: IMKInputController { this.liveSession?.cancelModifierTap() this.recentText.reset() this.lastModifiers = [] + this.lastDeviceFlags = 0 this.closeMenu() this.cancelPolish() this.commitPending(client) @@ -282,24 +288,36 @@ final class AIMEInputController: IMKInputController { case .flagsChanged: engine.lastUserKeyAt = Date() + // Two views of the same event: the raw value keeps device-dependent bits + // (left vs right ⌥/⌃/⌘) for the menu-hold state machine; librime only + // understands the device-independent mask, which merges the sides. + let deviceFlags = event.modifierFlags.rawValue let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) // Modifier transitions only (never character keys): safe to log for diagnosis. engine.logger.debug("flagsChanged keyCode=\(event.keyCode, privacy: .public) flags=\(flags.rawValue, privacy: .public) last=\(self.lastModifiers.rawValue, privacy: .public)") - defer { lastModifiers = flags } + defer { lastModifiers = flags; lastDeviceFlags = deviceFlags } DebugLog.write("flagsChanged keyCode=\(event.keyCode) flags=\(flags.rawValue) last=\(lastModifiers.rawValue) ascii=\(session.option("ascii_mode"))") // Mac Catalyst apps (Messages) deliver every modifier event twice. A repeat // has the same flags as the last one and would read as "released", toggling // Chinese/English the moment Shift goes down. - guard flags != lastModifiers else { return false } + guard deviceFlags != lastDeviceFlags else { return false } // Hold-to-open for the quick menu. The release after a fired hold is kept from // librime, where a lone modifier tap can switch Chinese/English. menuHold.key = engine.features.menuHoldKey - if menuHold.consumeRelease(flags.rawValue) { return true } - if let token = menuHold.modifiersChanged(flags.rawValue) { + if menuHold.consumeRelease(deviceFlags) { + // The release we just swallowed was part of the hold; clear librime's + // tap latch so a surviving opposite-side key can't end the sequence + // with a lone release that ascii_composer reads as a 中/英 toggle tap. + session.cancelModifierTap() + return true + } + if let token = menuHold.modifiersChanged(deviceFlags) { scheduleMenuHold(token: token) } else if !menuHold.isArmed { engine.panel.hideHoldCue(animated: true) // let go early, or another modifier joined } + // Side-only changes (left ⌥ held, right ⌥ joins) carry no keysym for librime. + guard flags != lastModifiers else { return false } guard let keysym = RimeKey.keysym(forVirtualKey: event.keyCode) else { return false } if keysym == RimeKey.capsLock { // Caps Lock toggles on a single event; report press + release. @@ -337,8 +355,14 @@ final class AIMEInputController: IMKInputController { private var draftHint: PanelState { let hold: String = switch engine.features.menuHoldKey { case .option: "长按 ⌥ 动作" + case .optionLeft: "长按左⌥ 动作" + case .optionRight: "长按右⌥ 动作" case .control: "长按 ⌃ 动作" + case .controlLeft: "长按左⌃ 动作" + case .controlRight: "长按右⌃ 动作" case .command: "长按 ⌘ 动作" + case .commandLeft: "长按左⌘ 动作" + case .commandRight: "长按右⌘ 动作" case .off: "⌃⌥M 动作" } var state = PanelState(status: "↩ 上屏 \(hold)") diff --git a/Apps/AIMESettings/Views/Components.swift b/Apps/AIMESettings/Views/Components.swift index 717d811..45f9f3e 100644 --- a/Apps/AIMESettings/Views/Components.swift +++ b/Apps/AIMESettings/Views/Components.swift @@ -87,9 +87,15 @@ private struct QuickMenuShortcutsSection: View { get: { model.features.menuHoldKey }, set: { key in model.updateFeatures { $0.menuHoldKey = key } } )) { - Text("⌥ Option").tag(ModifierHold.Key.option) - Text("⌃ Control").tag(ModifierHold.Key.control) - Text("⌘ Command").tag(ModifierHold.Key.command) + Text("⌥ Option(左右均可)").tag(ModifierHold.Key.option) + Text("左 ⌥ Option").tag(ModifierHold.Key.optionLeft) + Text("右 ⌥ Option").tag(ModifierHold.Key.optionRight) + Text("⌃ Control(左右均可)").tag(ModifierHold.Key.control) + Text("左 ⌃ Control").tag(ModifierHold.Key.controlLeft) + Text("右 ⌃ Control").tag(ModifierHold.Key.controlRight) + Text("⌘ Command(左右均可)").tag(ModifierHold.Key.command) + Text("左 ⌘ Command").tag(ModifierHold.Key.commandLeft) + Text("右 ⌘ Command").tag(ModifierHold.Key.commandRight) Text("关闭").tag(ModifierHold.Key.off) } .labelsHidden() diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 9511bfc..d4d7857 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -12,6 +12,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and ## [Unreleased] +### Added + +- Settings › Shortcuts › hold-to-open key: Option, Control and Command can each be limited to the left or right key only (e.g. right ⌥), leaving the other side for normal use; pick “either side” to keep the old behavior. + ## [0.1.7] - 2026-10-09 > Stable release · Developer ID signed · notarized by Apple diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3ff3b..ad9a051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ AIME 每个版本面向用户的变动都记在这里,官网「更新日志」 ## [Unreleased] +### 新增 + +- 设置 › 快捷键 › 长按触发键:Option、Control、Command 现在都可以限定只用左侧或右侧的键触发快捷菜单(例如只用右 ⌥),另一侧保持普通按键用途;不区分左右则选「左右均可」。 + ## [0.1.7] - 2026-10-09 > 正式版 · Developer ID 签名 · 已通过 Apple 公证 diff --git a/Packages/AIMEKit/Sources/AIMECore/ModifierHold.swift b/Packages/AIMEKit/Sources/AIMECore/ModifierHold.swift index ed8d6ed..04fa7ac 100644 --- a/Packages/AIMEKit/Sources/AIMECore/ModifierHold.swift +++ b/Packages/AIMEKit/Sources/AIMECore/ModifierHold.swift @@ -6,19 +6,55 @@ public import Foundation /// /// The hold only fires when the chosen modifier is the *only* one down and no other key /// is pressed meanwhile, so shortcuts like ⌥← or ⌘C are never mistaken for a hold. +/// +/// Left and right keys are told apart with the device-dependent bits in the same +/// `modifierFlags` raw value (`NX_DEVICELALTKEYMASK` and friends): the plain +/// `.option`/`.control`/`.command` cases accept either side, `…Left`/`…Right` only one. public struct ModifierHold: Sendable, Equatable { public enum Key: String, Sendable, Codable, CaseIterable { - case option, control, command, off + case option, optionLeft, optionRight + case control, controlLeft, controlRight + case command, commandLeft, commandRight + case off - /// `NSEvent.ModifierFlags` raw value of the key. + /// `NSEvent.ModifierFlags` raw value of the key (device-independent). public var cocoaFlag: UInt? { switch self { - case .option: 1 << 19 - case .control: 1 << 18 - case .command: 1 << 20 + case .option, .optionLeft, .optionRight: 1 << 19 + case .control, .controlLeft, .controlRight: 1 << 18 + case .command, .commandLeft, .commandRight: 1 << 20 case .off: nil } } + + /// Device-dependent bit for the required side (`NX_DEVICELALTKEYMASK` etc.). + /// `nil` means either side counts. Bit layout per IOLLEvent.h: + /// LControl 1<<0, RControl 1<<13, LShift 1<<1, RShift 1<<2, + /// LCommand 1<<3, RCommand 1<<4, LAlt 1<<5, RAlt 1<<6. + /// (1<<7 is `NX_DEVICE_ALPHASHIFT_STATELESS_MASK`, *not* right control.) + public var sideMask: UInt? { + switch self { + case .optionLeft: 1 << 5 + case .optionRight: 1 << 6 + case .controlLeft: 1 << 0 + case .controlRight: 1 << 13 + case .commandLeft: 1 << 3 + case .commandRight: 1 << 4 + default: nil + } + } + + /// Device-dependent bits of *both* sides of this key; zero for `off`. + /// Watched so the opposite side pressing or joining still invalidates a hold. + var deviceMask: UInt { + switch self { + case .option, .optionLeft, .optionRight: (1 << 5) | (1 << 6) + case .control, .controlLeft, .controlRight: (1 << 0) | (1 << 13) + case .command, .commandLeft, .commandRight: (1 << 3) | (1 << 4) + case .off: 0 + } + } + } /// Long enough not to trigger on ordinary shortcuts, short enough to feel instant. @@ -35,23 +71,47 @@ public struct ModifierHold: Sendable, Equatable { public init(key: Key = .option) { self.key = key } + /// `flags & relevantMask` must satisfy `matches` for the hold to count: + /// the key's own bits and nothing else. `relevantMask` adds the device bits of the + /// key's both sides, so the opposite side joining reads as a second key — not a + /// plain hold — for both sided and unsided keys. + private var relevantMask: UInt { Self.modifierMask | key.deviceMask } + /// "The hold key alone is down": generic flag set, and the device bits show either + /// the required side alone, or (for an unsided key) either side alone — or no side + /// bit at all when the source hides it (virtual keyboards, remote sessions). + private func matches(_ down: UInt) -> Bool { + guard let flag = key.cocoaFlag else { return false } + guard down & Self.modifierMask == flag else { return false } + let sides = down & key.deviceMask + if let side = key.sideMask { return sides == side } + // Unsided: zero side bits is fine; exactly one side is fine; both is two keys. + return sides == 0 || sides & (sides - 1) == 0 + } + /// The key's own target bit pattern (generic flag + required side, if any); used + /// where "remove the held key from flags" wants one mask, not a predicate. + private var target: UInt? { + guard let flag = key.cocoaFlag else { return nil } + return flag | (key.sideMask ?? 0) + } + /// Ends the input context and invalidates any timer from its previous hold. public mutating func reset() { cancel() } - /// Feed every modifier change. Returns a token when the caller should start the - /// timer (`duration`) and then call `fire(token:)`. + /// Feed every modifier change with the *unmasked* `modifierFlags` raw value — + /// sided keys need the device bits the caller strips for librime. Returns a token + /// when the caller should start the timer (`duration`) and then call `fire(token:)`. public mutating func modifiersChanged(_ flags: UInt) -> Int? { - let down = flags & Self.modifierMask - guard let flag = key.cocoaFlag else { return nil } - if down == flag, !isArmed, !isFired { + let down = flags & relevantMask + guard key.cocoaFlag != nil else { return nil } + if matches(down), !isArmed, !isFired { isArmed = true token += 1 return token } - if down != flag { - // Released, or another modifier joined: not a plain hold any more. + if !matches(down) { + // Released, the wrong side, or another modifier joined: not a plain hold. if isArmed { isArmed = false; token += 1 } } return nil @@ -77,19 +137,31 @@ public struct ModifierHold: Sendable, Equatable { return true } - /// Feed the modifier change that follows a fired hold. True when it is the hold - /// key's release, which must not reach librime (a lone modifier tap can switch - /// Chinese/English there). + /// Feed the modifier change that follows a fired hold (same unmasked flags as + /// `modifiersChanged`). True when it is the hold key's release, which must not + /// reach librime (a lone modifier tap can switch Chinese/English there). public mutating func consumeRelease(_ flags: UInt) -> Bool { - guard isFired, let flag = key.cocoaFlag, flags & flag == 0 else { return false } + guard isFired, let target else { return false } + // The hold key counts as released when its bits are gone: for a sided key the + // side bit (the generic flag may still be set by the opposite side); for an + // unsided key the generic flag clears only once the last side is released. + let releasedBits = key.sideMask ?? key.cocoaFlag ?? 0 + guard flags & releasedBits == 0 else { return false } isFired = false return true } /// Modifier flags with the held key removed: digits pressed while it is still down - /// count as plain digits in the menu. + /// count as plain digits in the menu. Input is the same unmasked raw value. public func withoutHoldKey(_ flags: UInt) -> UInt { - guard isFired, let flag = key.cocoaFlag else { return flags } - return flags & ~flag + guard isFired, let target else { return flags } + var cleared = flags & ~target + // A sided key's generic bit may still be earned by the opposite side staying + // down: keep it, or ⌥+digit on that side would read as a plain menu key. + if key.sideMask != nil, let flag = key.cocoaFlag, + flags & flag != 0, cleared & flag == 0 { + cleared |= flag + } + return cleared } } diff --git a/Packages/AIMEKit/Tests/AIMECoreTests/ModifierHoldTests.swift b/Packages/AIMEKit/Tests/AIMECoreTests/ModifierHoldTests.swift index b1a62fe..570edde 100644 --- a/Packages/AIMEKit/Tests/AIMECoreTests/ModifierHoldTests.swift +++ b/Packages/AIMEKit/Tests/AIMECoreTests/ModifierHoldTests.swift @@ -3,6 +3,10 @@ import Testing struct ModifierHoldTests { let option: UInt = 1 << 19, shift: UInt = 1 << 17, command: UInt = 1 << 20 + // Device-dependent bits per IOLLEvent.h: L⌃ 1<<0, R⌃ 1<<13, L⌥ 1<<5, R⌥ 1<<6. + // (1<<7 is NX_DEVICE_ALPHASHIFT_STATELESS_MASK — a real right ⌃ is bit 13.) + let lOption: UInt = (1 << 19) | (1 << 5), rOption: UInt = (1 << 19) | (1 << 6) + let lControl: UInt = (1 << 18) | (1 << 0), rControl: UInt = (1 << 18) | (1 << 13) @Test func holdingTheKeyAloneFiresOnce() { var hold = ModifierHold(key: .option) @@ -85,6 +89,130 @@ struct ModifierHoldTests { #expect(freshFire) } + @Test func sidedKeyOnlyArmsOnItsOwnSide() { + var hold = ModifierHold(key: .optionLeft) + var token = hold.modifiersChanged(lOption) + #expect(token != nil) // left ⌥ arms + hold.cancel() + token = hold.modifiersChanged(rOption) + #expect(token == nil) // right ⌥ does not + #expect(!hold.isArmed) + token = hold.modifiersChanged(option) + #expect(token == nil) // side-less press (virtual kbd) + + hold.key = .optionRight + token = hold.modifiersChanged(rOption) + #expect(token != nil) + hold.cancel() + token = hold.modifiersChanged(lOption) + #expect(token == nil) + + hold.key = .controlLeft + token = hold.modifiersChanged(lControl) + #expect(token != nil) + hold.cancel() + token = hold.modifiersChanged(rControl) + #expect(token == nil) + + hold.key = .controlRight // right ⌃: bit 13, not 7 + token = hold.modifiersChanged(rControl) + #expect(token != nil) + hold.cancel() + token = hold.modifiersChanged(lControl) + #expect(token == nil) + token = hold.modifiersChanged((1 << 18) | (1 << 7)) // alphashift-stateless is not ⌃ + #expect(token == nil) + } + + @Test func unsidedKeyAcceptsEitherSideButNotBoth() { + var hold = ModifierHold(key: .option) + var token = hold.modifiersChanged(lOption) + #expect(token != nil) + hold.cancel() + token = hold.modifiersChanged(rOption) + #expect(token != nil) + hold.cancel() + token = hold.modifiersChanged(option) + #expect(token != nil) // no side info still works + hold.cancel() + // Both ⌥ keys down = two keys, never a plain hold, whatever the configured side. + token = hold.modifiersChanged(lOption | rOption) + #expect(token == nil) + hold.key = .optionLeft + token = hold.modifiersChanged(lOption | rOption) + #expect(token == nil) + } + + @Test func oppositeSideJoiningCancelsASidedHold() { + var hold = ModifierHold(key: .optionLeft) + let token = hold.modifiersChanged(lOption) ?? -1 + _ = hold.modifiersChanged(lOption | rOption) // right ⌥ joins the hold + let fired = hold.fire(token: token) + #expect(!fired) + // Releasing the joined side still leaves the hold key down, but the hold is over. + // The event stream re-arms on the still-held key — same as ⇧+⌥ → release ⇧ today — + // so the timer just restarts; asserting no *stale* token fires is what matters. + let rearmed = hold.modifiersChanged(lOption) + #expect(rearmed != nil) + #expect(hold.isArmed) + } + + @Test func sidedReleaseIsOnlyConsumedWhenThatSideLeaves() { + var hold = ModifierHold(key: .optionLeft) + var token = hold.modifiersChanged(lOption) ?? -1 + var fired = hold.fire(token: token) + #expect(fired) + // Right ⌥ still held while left leaves: generic ⌥ flag remains, the side bit is proof. + var consumed = hold.consumeRelease(rOption) + #expect(consumed) + #expect(!hold.isFired) + + hold.cancel() + hold.key = .optionRight + token = hold.modifiersChanged(rOption) ?? -1 + fired = hold.fire(token: token) + #expect(fired) + // Left ⌥ joins while the fired key stays down: its bit is still set → not a release. + consumed = hold.consumeRelease(lOption | rOption) + #expect(!consumed) + #expect(hold.isFired) + consumed = hold.consumeRelease(lOption) // right side leaves, left stays + #expect(consumed) + #expect(!hold.isFired) + + // Unsided key fires while both sides are down is impossible, but a plain + // release check still holds: either side's flags keep the generic bit set. + hold.cancel() + hold.key = .option + token = hold.modifiersChanged(lOption) ?? -1 + fired = hold.fire(token: token) + #expect(fired) + consumed = hold.consumeRelease(0) + #expect(consumed) + } + + @Test func withoutHoldKeyKeepsGenericFlagWhileOppositeSideIsDown() { + var hold = ModifierHold(key: .optionLeft) + let token = hold.modifiersChanged(lOption) ?? -1 + let fired = hold.fire(token: token) + #expect(fired) + // Right ⌥ joins while the menu is up: ⌥+2 on that side must not read as plain "2". + let both = lOption | rOption + #expect(hold.withoutHoldKey(both) & option == option) + // The held side's device bit is still stripped; released left, right still down. + #expect(hold.withoutHoldKey(rOption) & option == option) + // Nothing held: everything stripped. + #expect(hold.withoutHoldKey(0) == 0) + } + + @Test func sidedKeysKeepDistinctRawValues() { + // features.json stores rawValues; legacy "option" must stay "any side". + #expect(ModifierHold.Key(rawValue: "option") == .option) + #expect(ModifierHold.Key.optionLeft.rawValue == "optionLeft") + #expect(ModifierHold.Key.optionRight.rawValue == "optionRight") + #expect(ModifierHold.Key.allCases.count == 10) + } + @Test func resetClearsFiredStateWithoutConsumingNextRelease() throws { var hold = ModifierHold(key: .option) let armedToken = hold.modifiersChanged(option)