Merge remote-tracking branch 'origin/beta' into codex/ui-2.0-details

This commit is contained in:
sim committed 2026-09-08 14:21:03 +08:00
commit 7ae366e2aa
4 files changed
+498 -31

No files matched your search

@@ -3255,8 +3255,10 @@ impl OpenLessBackend {
};
let preferences = self.get_preferences();
let mode = preferences.hotkey.mode;
let modifier_only =
crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey).is_some();
let modifier_only = crate::hotkey_interpreter::modifier_arbitration_required(
crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey),
mode,
);
let (intent, reservation) = {
let mut hotkey = self
.hotkey
@@ -12,12 +12,14 @@ use std::time::{Duration, Instant};
use futures_util::{SinkExt, StreamExt};
use parking_lot::Mutex as ParkingMutex;
use serde_json::{json, Value};
use tokio::net::TcpStream;
use tokio::net::{lookup_host, TcpStream};
use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex, Notify};
use tokio_tungstenite::client_async_tls;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::header::HeaderValue;
use tokio_tungstenite::tungstenite::Error as WsError;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use uuid::Uuid;
use crate::config::{TaskSpawner, TokioTaskSpawner};
@@ -39,6 +41,92 @@ const FINAL_RESULT_TIMEOUT: Duration = Duration::from_secs(12);
/// 热键彻底失灵(开不了也停不了,只能退出重开)。详见 stepfun_realtime.rs 同名常量。
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
/// 单个候选地址的 TCP 上限。IPv6 黑洞时不能把整段 5s 耗在第一个 AAAA 上。
const PER_ADDR_TCP_TIMEOUT: Duration = Duration::from_millis(1500);
fn default_port_for_request(
request: &tokio_tungstenite::tungstenite::handshake::client::Request,
) -> Result<u16, WsError> {
let default_port = match request.uri().scheme_str() {
Some("ws") => 80,
Some("wss") => 443,
_ => {
return Err(WsError::Url(
tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme,
))
}
};
Ok(request.uri().port_u16().unwrap_or(default_port))
}
fn order_connect_addrs(mut addrs: Vec<std::net::SocketAddr>) -> Vec<std::net::SocketAddr> {
addrs.sort_by_key(|addr| u8::from(!addr.ip().is_ipv4()));
addrs
}
async fn connect_ws_to_addrs(
request: tokio_tungstenite::tungstenite::handshake::client::Request,
addrs: Vec<std::net::SocketAddr>,
) -> Result<
(
WsStream,
tokio_tungstenite::tungstenite::handshake::client::Response,
),
WsError,
> {
if addrs.is_empty() {
return Err(WsError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"no addresses for websocket endpoint",
)));
}
let mut last_err = None;
for addr in addrs {
match tokio::time::timeout(PER_ADDR_TCP_TIMEOUT, TcpStream::connect(addr)).await {
Ok(Ok(stream)) => match client_async_tls(request.clone(), stream).await {
Ok(connection) => return Ok(connection),
Err(error) => last_err = Some(error),
},
Ok(Err(error)) => last_err = Some(WsError::Io(error)),
Err(_) => {
last_err = Some(WsError::Io(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"tcp connect timeout ({})",
if addr.ip().is_ipv4() { "v4" } else { "v6" }
),
)))
}
}
}
Err(last_err.unwrap_or_else(|| {
WsError::Io(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"no tcp candidate",
))
}))
}
async fn connect_ws_prefer_ipv4(
request: tokio_tungstenite::tungstenite::handshake::client::Request,
) -> Result<
(
WsStream,
tokio_tungstenite::tungstenite::handshake::client::Response,
),
WsError,
> {
let port = default_port_for_request(&request)?;
let host = request.uri().host().unwrap_or("").to_string();
let addrs = lookup_host((host.as_str(), port))
.await
.map_err(WsError::Io)?
.collect::<Vec<_>>();
connect_ws_to_addrs(request, order_connect_addrs(addrs)).await
}
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
type WsSink = futures_util::stream::SplitSink<WsStream, Message>;
type SharedWriter = Arc<AsyncMutex<Option<WsSink>>>;
@@ -171,7 +259,7 @@ impl BailianRealtimeASR {
.map_err(|e| BailianASRError::ConnectionFailed(e.to_string()))?,
);
let (ws, _resp) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(request))
let (ws, _resp) = tokio::time::timeout(CONNECT_TIMEOUT, connect_ws_prefer_ipv4(request))
.await
.map_err(|_| {
BailianASRError::ConnectionFailed(format!(
@@ -765,6 +853,100 @@ mod tests {
})
}
#[test]
fn websocket_default_ports_match_uri_scheme() {
let ws = "ws://localhost/path".into_client_request().unwrap();
let wss = "wss://localhost/path".into_client_request().unwrap();
let explicit = "ws://localhost:9000/path".into_client_request().unwrap();
assert_eq!(default_port_for_request(&ws).unwrap(), 80);
assert_eq!(default_port_for_request(&wss).unwrap(), 443);
assert_eq!(default_port_for_request(&explicit).unwrap(), 9000);
}
#[test]
fn websocket_default_port_rejects_non_websocket_scheme() {
let request = "https://localhost/path".into_client_request().unwrap();
let explicit_port = "https://localhost:443/path".into_client_request().unwrap();
assert!(matches!(
default_port_for_request(&request),
Err(WsError::Url(
tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme
))
));
assert!(matches!(
default_port_for_request(&explicit_port),
Err(WsError::Url(
tokio_tungstenite::tungstenite::error::UrlError::UnsupportedUrlScheme
))
));
}
#[test]
fn order_connect_addrs_puts_ipv4_first() {
let v6: std::net::SocketAddr = "[2408:400a::1]:443".parse().unwrap();
let v4: std::net::SocketAddr = "8.152.159.24:443".parse().unwrap();
let ordered = order_connect_addrs(vec![v6, v4]);
assert!(ordered[0].ip().is_ipv4());
assert!(ordered[1].ip().is_ipv6());
}
#[tokio::test]
async fn websocket_handshake_failure_falls_back_to_next_address() {
let rejected_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let rejected_addr = rejected_listener.local_addr().unwrap();
let rejected_task = tokio::spawn(async move {
let (stream, _) = rejected_listener.accept().await.unwrap();
drop(stream);
});
let accepted_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let accepted_addr = accepted_listener.local_addr().unwrap();
let accepted_task = tokio::spawn(async move {
let (stream, _) = accepted_listener.accept().await.unwrap();
tokio_tungstenite::accept_async(stream).await.unwrap();
});
let request = format!("ws://localhost:{}/path", accepted_addr.port())
.into_client_request()
.unwrap();
let (stream, _) = connect_ws_to_addrs(request, vec![rejected_addr, accepted_addr])
.await
.unwrap();
drop(stream);
rejected_task.await.unwrap();
accepted_task.await.unwrap();
}
#[tokio::test]
async fn websocket_connects_to_ipv6_candidate_when_available() {
let Ok(listener) = tokio::net::TcpListener::bind("[::1]:0").await else {
return;
};
let addr = listener.local_addr().unwrap();
let server_task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
tokio_tungstenite::accept_async(stream).await.unwrap();
});
let request = format!("ws://[::1]:{}/path", addr.port())
.into_client_request()
.unwrap();
let (stream, _) = connect_ws_to_addrs(request, vec![addr]).await.unwrap();
drop(stream);
server_task.await.unwrap();
}
#[tokio::test]
async fn websocket_connect_with_no_candidates_fails_immediately() {
let request = "ws://localhost/path".into_client_request().unwrap();
let result = connect_ws_to_addrs(request, Vec::new()).await;
assert!(
matches!(result, Err(WsError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound)
);
}
// ---- merge_segments ----
#[test]
@@ -1,7 +1,7 @@
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use crate::shared_types::HotkeyMode;
use crate::shared_types::{HotkeyMode, HotkeyTrigger};
use crate::types::DictationPhase;
/// Auto mode treats a shorter press as a latched Toggle press and a longer
@@ -21,6 +21,17 @@ const MODIFIER_ARBITRATION_GRACE: Duration = Duration::from_millis(150);
/// awaits microphone/ASR setup. Keep a bounded set of those early Combined ids.
const MAX_PENDING_COMBINED: usize = 64;
/// macOS emits Fn Auto/Toggle edges only after native release arbitration.
pub(crate) fn modifier_arbitration_required(
trigger: Option<HotkeyTrigger>,
mode: HotkeyMode,
) -> bool {
trigger.is_some()
&& !(cfg!(target_os = "macos")
&& trigger == Some(HotkeyTrigger::Fn)
&& matches!(mode, HotkeyMode::Auto | HotkeyMode::Toggle))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HotkeyIntent {
Noop,
@@ -266,6 +277,27 @@ impl HotkeyInterpreter {
mod tests {
use super::*;
#[test]
fn mac_fn_tap_arrives_pre_arbitrated_but_hold_and_other_modifiers_do_not() {
let fn_tap_needs_grace = !cfg!(target_os = "macos");
assert_eq!(
modifier_arbitration_required(Some(HotkeyTrigger::Fn), HotkeyMode::Auto),
fn_tap_needs_grace
);
assert_eq!(
modifier_arbitration_required(Some(HotkeyTrigger::Fn), HotkeyMode::Toggle),
fn_tap_needs_grace
);
assert!(modifier_arbitration_required(
Some(HotkeyTrigger::Fn),
HotkeyMode::Hold
));
assert!(modifier_arbitration_required(
Some(HotkeyTrigger::LeftOption),
HotkeyMode::Auto
));
}
#[test]
fn combined_before_pressed_cancels_only_that_generation() {
let start = Instant::now();
+276 -25
View File
@@ -437,7 +437,9 @@ mod platform {
update_shared_modifier_shortcuts, HotkeyAdapter, HotkeyCombinedEdge, HotkeyEvent, Shared,
StartupTx,
};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger};
use crate::types::{
HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyMode, HotkeyTrigger,
};
pub fn start_adapter(
binding: HotkeyBinding,
@@ -565,6 +567,9 @@ mod platform {
const KEY_DOWN: CgEventType = 10;
const KEY_UP: CgEventType = 11;
const FLAGS_CHANGED: CgEventType = 12;
/// Brightness / volume / keyboard-backlight and similar macOS function-layer
/// actions are delivered as system-defined events instead of KEY_DOWN.
const SYSTEM_DEFINED: CgEventType = 14;
const TAP_DISABLED_BY_TIMEOUT: CgEventType = 0xFFFF_FFFE;
const TAP_DISABLED_BY_USER_INPUT: CgEventType = 0xFFFF_FFFF;
@@ -577,6 +582,15 @@ mod platform {
const FLAG_MASK_SECONDARY_FN: CgEventFlags = 0x0080_0000;
const ESC_KEYCODE: i64 = 53;
// IOKit hidsystem/IOLLEvent.h + ev_keymap.h. systemDefined subtype 8 carries
// auxiliary control keys in data1's high 16 bits; 0..=23 are the scanned
// brightness / volume / media / illumination family. Globe/Menu is 25 and
// must stay excluded, otherwise a plain Fn tap could cancel itself.
const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8;
const NX_NUM_SCANNED_SPECIAL_KEYS: u16 = 24;
/// Auto / Toggle 下 Fn 是双用途键:短按用于听写,明显长按留给 macOS 功能层。
/// 与 Auto 模式既有的 350ms 短按 / 长按分界保持一致。
const FN_TAP_MAX_DURATION: std::time::Duration = std::time::Duration::from_millis(350);
type CgEventTapCallBack = extern "C" fn(
proxy: *mut c_void,
@@ -621,6 +635,8 @@ mod platform {
cancel_tx: Sender<()>,
/// 组合键撤销专用通道,见模块注释——不与 tx 挤同一条串行 bridge。
combo_tx: Sender<HotkeyCombinedEdge>,
/// Auto / Toggle 下 Fn 延迟到松开才决定是否派发:短按派发,长按或功能层组合丢弃。
fn_pressed_at: parking_lot::Mutex<Option<std::time::Instant>>,
/// 与 MacHotkeyAdapter 共享的 (tap, runloop) refs。tap re-enable on
/// TAP_DISABLED_BY_TIMEOUT 走 handles.tap;adapter shutdown 也走这两个 lock。
handles: Arc<MacShutdownHandles>,
@@ -636,7 +652,10 @@ mod platform {
combo_tx: Sender<HotkeyCombinedEdge>,
status_tx: StartupTx<Arc<MacShutdownHandles>>,
) {
let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN) | (1u64 << KEY_UP);
let mask: CgEventMask = (1u64 << FLAGS_CHANGED)
| (1u64 << KEY_DOWN)
| (1u64 << KEY_UP)
| (1u64 << SYSTEM_DEFINED);
let handles = Arc::new(MacShutdownHandles {
tap: std::sync::Mutex::new(None),
runloop: std::sync::Mutex::new(None),
@@ -646,6 +665,7 @@ mod platform {
tx,
cancel_tx,
combo_tx,
fn_pressed_at: parking_lot::Mutex::new(None),
handles: Arc::clone(&handles),
}));
@@ -719,6 +739,13 @@ mod platform {
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, false);
}
// Fn+亮度 / 音量 / 键盘背光等不会产生 KEY_DOWN,而是 systemDefined。
// 只要 Fn 触发键正按住,就把它视为功能层组合,不能同时唤起听写。
SYSTEM_DEFINED => {
if let Some((subtype, data1)) = system_defined_event_payload(event) {
note_fn_function_layer_event(ctx, subtype, data1);
}
}
_ => {}
}
event
@@ -742,6 +769,9 @@ mod platform {
ctx.shared.recording_fn_held.store(false, Ordering::SeqCst);
log::info!("[hotkey] 录制态 Fn↑(松开)");
}
// 录制态只负责把 Fn 交给 ShortcutRecorder。不要同时更新听写触发 latch,
// 否则前端保存 Fn、退出录制态后,同一次物理松手会被误当成一次听写短按。
return;
}
// Shift 是翻译模式修饰键 — 与触发键的 keycode 检查独立,任何时刻按 Shift 都生效。
@@ -788,17 +818,35 @@ mod platform {
None,
);
let trigger = ctx.shared.binding.read().trigger;
if trigger == HotkeyTrigger::Custom {
handle_dictation_trigger_flags_changed(ctx, keycode, flags, std::time::Instant::now());
}
fn fn_uses_tap_only_semantics(trigger: HotkeyTrigger, mode: HotkeyMode) -> bool {
trigger == HotkeyTrigger::Fn && matches!(mode, HotkeyMode::Auto | HotkeyMode::Toggle)
}
/// 处理 modifier-only 听写触发键的 flagsChanged 边沿。
///
/// Fn 在 Auto / Toggle 下不能沿用其他修饰键的“按下即触发”:它同时是 macOS
/// 功能层修饰键。这里先记住按下,等松开后确认是短按且未叠加功能键,才补发一对
/// Pressed / Released。这样长按、Fn+F1/音量/亮度等系统操作从未启动听写,不会闪胶囊。
fn handle_dictation_trigger_flags_changed(
ctx: &CallbackContext,
keycode: i64,
flags: CgEventFlags,
now: std::time::Instant,
) {
let (trigger, mode) = {
let binding = ctx.shared.binding.read();
(binding.trigger, binding.mode)
};
if trigger == HotkeyTrigger::Custom || keycode != trigger_to_keycode(trigger) {
return;
}
let expected_keycode = trigger_to_keycode(trigger);
if keycode != expected_keycode {
return;
}
let mask = trigger_to_flag_mask(trigger);
let is_active = (flags & mask) != 0;
let is_active = (flags & trigger_to_flag_mask(trigger)) != 0;
let was_held = ctx.shared.trigger_held.load(Ordering::SeqCst);
let tap_only_fn = fn_uses_tap_only_semantics(trigger, mode);
if is_active && !was_held {
ctx.shared.trigger_held.store(true, Ordering::SeqCst);
@@ -807,23 +855,46 @@ mod platform {
.trigger_press_id
.store(press_id, Ordering::SeqCst);
ctx.shared.trigger_companion_seen.store(0, Ordering::SeqCst);
send_or_log(
&ctx.tx,
HotkeyEvent::Pressed {
at: std::time::Instant::now(),
press_id,
},
);
} else if !is_active && was_held {
if tap_only_fn {
*ctx.fn_pressed_at.lock() = Some(now);
log::debug!("[hotkey] Fn↓ 等待松开后判定短按 / 功能层操作");
} else {
send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: now, press_id });
}
return;
}
if !is_active && was_held {
ctx.shared.trigger_held.store(false, Ordering::SeqCst);
let press_id = ctx.shared.trigger_press_id.swap(0, Ordering::SeqCst);
send_or_log(
&ctx.tx,
HotkeyEvent::Released {
at: std::time::Instant::now(),
press_id,
},
);
if !tap_only_fn {
send_or_log(&ctx.tx, HotkeyEvent::Released { at: now, press_id });
return;
}
let pressed_at = ctx.fn_pressed_at.lock().take();
let companion_seen = press_id != 0
&& ctx.shared.trigger_companion_seen.load(Ordering::SeqCst) == press_id;
let held_for = pressed_at.map(|at| now.saturating_duration_since(at));
if !companion_seen && held_for.is_some_and(|duration| duration < FN_TAP_MAX_DURATION) {
let pressed_at = pressed_at.expect("checked above");
send_or_log(
&ctx.tx,
HotkeyEvent::Pressed {
at: pressed_at,
press_id,
},
);
send_or_log(&ctx.tx, HotkeyEvent::Released { at: now, press_id });
} else {
log::info!(
"[hotkey] Fn 操作未触发听写(held_ms={}, companion_seen={companion_seen})",
held_for.map(|duration| duration.as_millis()).unwrap_or(0)
);
}
} else if !is_active && trigger == HotkeyTrigger::Fn {
// 录制态 / binding 更新会主动重置 held latch;物理松手仍需清掉旧时间戳。
ctx.fn_pressed_at.lock().take();
}
}
@@ -868,6 +939,40 @@ mod platform {
note_companion_key_down(ctx);
}
fn system_defined_event_payload(event: CgEventRef) -> Option<(i16, isize)> {
use objc2::msg_send;
use objc2::runtime::{AnyClass, AnyObject};
if event.is_null() {
return None;
}
let cls = AnyClass::get("NSEvent")?;
let ns_event: *mut AnyObject =
unsafe { msg_send![cls, eventWithCGEvent: event.cast::<c_void>()] };
if ns_event.is_null() {
return None;
}
let subtype: i16 = unsafe { msg_send![ns_event, subtype] };
let data1: isize = unsafe { msg_send![ns_event, data1] };
Some((subtype, data1))
}
fn is_auxiliary_function_key_event(subtype: i16, data1: isize) -> bool {
if subtype != NX_SUBTYPE_AUX_CONTROL_BUTTONS {
return false;
}
let key_type = ((data1 as u64 >> 16) & 0xffff) as u16;
key_type < NX_NUM_SCANNED_SPECIAL_KEYS
}
fn note_fn_function_layer_event(ctx: &CallbackContext, subtype: i16, data1: isize) {
if is_auxiliary_function_key_event(subtype, data1)
&& ctx.shared.binding.read().trigger == HotkeyTrigger::Fn
{
note_companion_key_down(ctx);
}
}
/// 触发键按住期间按下任意普通键 = 用户在打组合键(Option+任意字母/数字键、Option+Tab…),
/// 不是想说话 —— 往组合键撤销通道发一次让上层撤销这次按下。
///
@@ -891,6 +996,16 @@ mod platform {
{
return;
}
let deferred_fn = {
let binding = ctx.shared.binding.read();
fn_uses_tap_only_semantics(binding.trigger, binding.mode)
};
if deferred_fn {
// Auto / Toggle 下 Fn 尚未向 coordinator 派发 Pressed;记住 companion 即可,
// 松手时会整次丢弃。此时发送 abort 只会留下一个永远等不到 Pressed 的 pending id。
log::info!("[hotkey] Fn 功能层组合按下 —— 本次短按候选作废");
return;
}
log::info!("[hotkey] 触发键与其他键组合按下 —— 撤销本次触发");
send_combo_abort_or_log(&ctx.combo_tx, press_id);
}
@@ -970,6 +1085,7 @@ mod platform {
tx,
cancel_tx,
combo_tx,
fn_pressed_at: parking_lot::Mutex::new(None),
handles: Arc::new(MacShutdownHandles {
tap: std::sync::Mutex::new(None),
runloop: std::sync::Mutex::new(None),
@@ -1083,6 +1199,141 @@ mod platform {
// 撤销全程不碰 Pressed/Released 那条串行通道 —— 它此刻正卡在 begin_session 里。
assert!(drain(&rx).is_empty());
}
#[test]
fn mac_fn_auto_short_tap_dispatches_only_after_release() {
let shared = shared(HotkeyTrigger::Fn);
shared.binding.write().mode = HotkeyMode::Auto;
let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared));
let pressed_at = std::time::Instant::now();
handle_dictation_trigger_flags_changed(
&ctx,
trigger_to_keycode(HotkeyTrigger::Fn),
FLAG_MASK_SECONDARY_FN,
pressed_at,
);
assert!(drain(&rx).is_empty(), "Fn down must stay a tap candidate");
let released_at = pressed_at + std::time::Duration::from_millis(120);
handle_dictation_trigger_flags_changed(
&ctx,
trigger_to_keycode(HotkeyTrigger::Fn),
0,
released_at,
);
let events = drain(&rx);
assert!(matches!(events.as_slice(), (
[HotkeyEvent::Pressed { press_id: left, .. }, HotkeyEvent::Released { press_id: right, .. }]
) if *left != 0 && left == right));
assert_eq!(drain_combo(&combo_rx), 0);
}
#[test]
fn mac_fn_auto_long_hold_is_reserved_for_system_functions() {
let shared = shared(HotkeyTrigger::Fn);
shared.binding.write().mode = HotkeyMode::Auto;
let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared));
let pressed_at = std::time::Instant::now();
handle_dictation_trigger_flags_changed(
&ctx,
trigger_to_keycode(HotkeyTrigger::Fn),
FLAG_MASK_SECONDARY_FN,
pressed_at,
);
handle_dictation_trigger_flags_changed(
&ctx,
trigger_to_keycode(HotkeyTrigger::Fn),
0,
pressed_at + FN_TAP_MAX_DURATION,
);
assert!(drain(&rx).is_empty());
assert_eq!(drain_combo(&combo_rx), 0);
}
#[test]
fn mac_fn_function_layer_event_suppresses_tap_without_pending_abort() {
let shared = shared(HotkeyTrigger::Fn);
shared.binding.write().mode = HotkeyMode::Toggle;
let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared));
let pressed_at = std::time::Instant::now();
handle_dictation_trigger_flags_changed(
&ctx,
trigger_to_keycode(HotkeyTrigger::Fn),
FLAG_MASK_SECONDARY_FN,
pressed_at,
);
// brightness-up (NX_KEYTYPE_BRIGHTNESS_UP=2) 的 subtype/data1 编码。
note_fn_function_layer_event(
&ctx,
NX_SUBTYPE_AUX_CONTROL_BUTTONS,
(2_i64 << 16) as isize,
);
handle_dictation_trigger_flags_changed(
&ctx,
trigger_to_keycode(HotkeyTrigger::Fn),
0,
pressed_at + std::time::Duration::from_millis(80),
);
assert!(drain(&rx).is_empty());
assert_eq!(drain_combo(&combo_rx), 0);
}
#[test]
fn mac_system_defined_event_does_not_abort_non_fn_dictation() {
let shared = shared(HotkeyTrigger::LeftOption);
let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared));
shared.trigger_press_id.store(7, Ordering::SeqCst);
shared.trigger_held.store(true, Ordering::SeqCst);
note_fn_function_layer_event(
&ctx,
NX_SUBTYPE_AUX_CONTROL_BUTTONS,
(2_i64 << 16) as isize,
);
assert_eq!(shared.trigger_companion_seen.load(Ordering::SeqCst), 0);
assert!(drain(&rx).is_empty());
assert_eq!(drain_combo(&combo_rx), 0);
}
#[test]
fn mac_globe_system_event_is_not_mistaken_for_a_function_layer_key() {
assert!(!is_auxiliary_function_key_event(
NX_SUBTYPE_AUX_CONTROL_BUTTONS,
(25_i64 << 16) as isize,
));
assert!(!is_auxiliary_function_key_event(0, (2_i64 << 16) as isize));
}
#[test]
fn mac_fn_hold_mode_keeps_press_to_talk_semantics() {
let shared = shared(HotkeyTrigger::Fn);
shared.binding.write().mode = HotkeyMode::Hold;
let (ctx, rx) = callback_context(Arc::clone(&shared));
let pressed_at = std::time::Instant::now();
handle_dictation_trigger_flags_changed(
&ctx,
trigger_to_keycode(HotkeyTrigger::Fn),
FLAG_MASK_SECONDARY_FN,
pressed_at,
);
assert_eq!(edge_names(drain(&rx)), vec!["pressed"]);
handle_dictation_trigger_flags_changed(
&ctx,
trigger_to_keycode(HotkeyTrigger::Fn),
0,
pressed_at + std::time::Duration::from_secs(1),
);
assert_eq!(edge_names(drain(&rx)), vec!["released"]);
}
}
}