Skip to content
Closed
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
34 changes: 29 additions & 5 deletions Apps/AIME/AIMEInputController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)")
Expand Down
12 changes: 9 additions & 3 deletions Apps/AIMESettings/Views/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ AIME 每个版本面向用户的变动都记在这里,官网「更新日志」

## [Unreleased]

### 新增

- 设置 › 快捷键 › 长按触发键:Option、Control、Command 现在都可以限定只用左侧或右侧的键触发快捷菜单(例如只用右 ⌥),另一侧保持普通按键用途;不区分左右则选「左右均可」。

## [0.1.7] - 2026-10-09

> 正式版 · Developer ID 签名 · 已通过 Apple 公证
Expand Down
110 changes: 91 additions & 19 deletions Packages/AIMEKit/Sources/AIMECore/ModifierHold.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
}
}
Loading