Skip to content

Commit fab9651

Browse files
author
吕柏青
committed
fix(popup): capsule 四边 clamp + Windows 句柄重试(#470) + qa 焦点契约文档化(#466)
1 parent bdd26fb commit fab9651

2 files changed

Lines changed: 156 additions & 21 deletions

File tree

openless-all/app/src-tauri/src/coordinator/ime_insertion.rs

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -479,19 +479,45 @@ pub(crate) fn show_capsule_window_no_activate<R: tauri::Runtime>(
479479
SWP_SHOWWINDOW, SW_SHOWNOACTIVATE,
480480
};
481481

482-
let Ok(handle) = window.window_handle() else {
483-
// #470 诊断 v2:Win32 show 路径最可能的暗点之一。此前静默 return,
484-
// 无法观测「胶囊完全不显示」是否卡在这里。
485-
log::warn!(
486-
"[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped"
487-
);
488-
return false;
489-
};
490-
let RawWindowHandle::Win32(raw) = handle.as_raw() else {
491-
log::warn!("[capsule] no_activate failed: non-Win32 RawWindowHandle — Win32 show skipped");
482+
// #470:首帧 show 时 webview 句柄常常还没 realize(window_handle() 暂不可用),
483+
// 此前一拿不到就 return false → 回落到会抢焦点的 window.show(),正是「胶囊不显示 /
484+
// 焦点 churn」的主嫌。改为短暂有界重试:最多 5 次、每次 18ms(总 < 100ms),拿到
485+
// Win32 句柄就走 no-activate 正常路径;全部失败才让调用方回落 show()。
486+
// 本函数在 run_on_main_thread 闭包里同步执行(见 capsule.rs),短暂阻塞主线程是
487+
// 可接受代价 —— 否则那一帧本就要走 show() 抢焦点。
488+
const HANDLE_RETRY_ATTEMPTS: u32 = 5;
489+
const HANDLE_RETRY_INTERVAL_MS: u64 = 18;
490+
let mut hwnd: Option<HWND> = None;
491+
for attempt in 0..HANDLE_RETRY_ATTEMPTS {
492+
match window.window_handle() {
493+
Ok(handle) => match handle.as_raw() {
494+
RawWindowHandle::Win32(raw) => {
495+
hwnd = Some(HWND(raw.hwnd.get() as *mut _));
496+
break;
497+
}
498+
_ => {
499+
// 非 Win32 句柄不会随重试变化,直接放弃。
500+
log::warn!(
501+
"[capsule] no_activate failed: non-Win32 RawWindowHandle — Win32 show skipped"
502+
);
503+
return false;
504+
}
505+
},
506+
Err(_) if attempt + 1 < HANDLE_RETRY_ATTEMPTS => {
507+
std::thread::sleep(std::time::Duration::from_millis(HANDLE_RETRY_INTERVAL_MS));
508+
}
509+
Err(e) => {
510+
// #470:重试耗尽仍拿不到句柄,记录后让调用方回落 show()。
511+
log::warn!(
512+
"[capsule] no_activate failed: window_handle() unavailable after {HANDLE_RETRY_ATTEMPTS} retries ({e}) — Win32 show skipped"
513+
);
514+
return false;
515+
}
516+
}
517+
}
518+
let Some(hwnd) = hwnd else {
492519
return false;
493520
};
494-
let hwnd = HWND(raw.hwnd.get() as *mut _);
495521

496522
let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
497523
let _ = unsafe {

openless-all/app/src-tauri/src/lib.rs

Lines changed: 119 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,34 @@ fn bottom_visual_position(
12071207
(x, y)
12081208
}
12091209

1210+
/// 把窗口左上角 `(x, y)`(同 area 同坐标系,physical px)夹到给定矩形内,
1211+
/// **保证整窗(含自身 w×h)落在 area 内可见**。area 为工作区时即可避开任务栏。
1212+
///
1213+
/// 纯函数,无 Win32 依赖,便于单测多显示器 / 负原点 / 异常 DPI 输入。issue #470:
1214+
/// 此前 Windows 分支只夹上边(`y.max(mon.top)`),左/右/下未夹,多屏负坐标下胶囊
1215+
/// 可能被算到屏外却无任何观测。这里四边都夹。
1216+
///
1217+
/// area 比窗口还小时(`area_right - w < area_left`),`max_x` 退化为 `area_left`,
1218+
/// `clamp` 把左上角收回 area 左上角,保证至少左上角可见、不溢出为负超界。
1219+
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
1220+
fn clamp_to_monitor(
1221+
x: i32,
1222+
y: i32,
1223+
w: i32,
1224+
h: i32,
1225+
area_left: i32,
1226+
area_top: i32,
1227+
area_right: i32,
1228+
area_bottom: i32,
1229+
) -> (i32, i32) {
1230+
// 右/下边界 = area 右下角减去窗口自身尺寸,确保整窗可见。
1231+
let max_x = (area_right - w).max(area_left);
1232+
let max_y = (area_bottom - h).max(area_top);
1233+
let clamped_x = x.clamp(area_left, max_x);
1234+
let clamped_y = y.clamp(area_top, max_y);
1235+
(clamped_x, clamped_y)
1236+
}
1237+
12101238
/// 把 QA 浮窗放到屏幕底部居中、紧贴胶囊上方。tauri 启动期 + show 之前都会调一次,
12111239
/// 防止用户切换显示器后位置错乱。
12121240
fn position_qa_window<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> tauri::Result<()> {
@@ -1232,8 +1260,20 @@ fn position_qa_window<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> ta
12321260

12331261
/// 显示 QA 窗口并发一条状态事件(前端订阅 `qa:state`)。
12341262
/// `content_kind` 是不透明字符串("loading" / "answer" / "idle" 等),
1235-
/// 让前端 React 视图自行决定渲染哪一种。**不**抢前台 app 焦点(保证 Cmd+C
1236-
/// fallback 仍能从原 app 拿到选区)。
1263+
/// 让前端 React 视图自行决定渲染哪一种。
1264+
///
1265+
/// ## 跨端焦点契约(#164 / #466,三端**有意**不同,勿盲目对齐)
1266+
/// - **macOS**:`orderFrontRegardless` —— 窗口可见但不成为 key window,frontmost
1267+
/// 始终是用户原 app,AX / Cmd+C fallback 能直接读到选区。**全程不抢焦点**。
1268+
/// - **Windows**:`show_qa_window_no_activate` 实际是 `show()` + `set_focus()`,
1269+
/// 出现的那一帧会短暂抢前台。这是 #466 对 #164 的**有意取舍**:WebView2 子窗口有
1270+
/// 独立 focus 模型,不主动抓焦点则 QA webview 收不到键盘事件 → ESC 到不了 React
1271+
/// 监听、X 按钮 first-click 被 OS 当激活点击吃掉。代价由 `coordinator/qa.rs` 的
1272+
/// focus-dance 补偿:抓选区前用 `qa_focus_target` 把焦点临时还给用户原 app,
1273+
/// `simulate_copy` 跑完再 `refocus_qa_window` 收回。**移除 set_focus 会同时回归
1274+
/// #164 的反面(ESC/X 失效),别删。** 详见 `show_qa_window_no_activate` 内注释。
1275+
/// - **Linux**:`window.show()`。qa 窗口静态配置 `focus: false`(tauri.conf.json),
1276+
/// Tauri 将其建成非激活窗口,因此 show() **不抢焦点**,与 macOS 契约一致。
12371277
pub(crate) fn show_qa_window<R: tauri::Runtime>(app: &AppHandle<R>, content_kind: &str) {
12381278
let Some(window) = app.get_webview_window("qa") else {
12391279
log::info!("[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})");
@@ -1286,6 +1326,8 @@ pub(crate) fn show_qa_window<R: tauri::Runtime>(app: &AppHandle<R>, content_kind
12861326
log::warn!("[qa] show fallback failed: {e}");
12871327
}
12881328
}
1329+
// Linux:qa 窗口静态配置 focus:false → Tauri 建成非激活窗口,window.show() 不抢
1330+
// 焦点,与 macOS「不抢焦点」契约一致(无需 Windows 那套 set_focus + focus-dance)。
12891331
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
12901332
if let Err(e) = window.show() {
12911333
log::warn!("[qa] show failed: {e}");
@@ -1582,6 +1624,12 @@ pub(crate) struct ForegroundMonitor {
15821624
pub(crate) top: i32,
15831625
pub(crate) right: i32,
15841626
pub(crate) bottom: i32,
1627+
/// 工作区矩形(physical px,去掉任务栏)。多端一致:胶囊优先夹到工作区内,
1628+
/// 避免压住任务栏。取不到时回退为整屏矩形。issue #470。
1629+
pub(crate) work_left: i32,
1630+
pub(crate) work_top: i32,
1631+
pub(crate) work_right: i32,
1632+
pub(crate) work_bottom: i32,
15851633
/// 该显示器的有效 DPI 缩放(1.0 = 96dpi)。
15861634
pub(crate) scale: f64,
15871635
}
@@ -1619,6 +1667,10 @@ pub(crate) fn foreground_window_monitor() -> Option<ForegroundMonitor> {
16191667
top: mi.rcMonitor.top,
16201668
right: mi.rcMonitor.right,
16211669
bottom: mi.rcMonitor.bottom,
1670+
work_left: mi.rcWork.left,
1671+
work_top: mi.rcWork.top,
1672+
work_right: mi.rcWork.right,
1673+
work_bottom: mi.rcWork.bottom,
16221674
scale: (dpi_x as f64 / 96.0).max(0.1),
16231675
})
16241676
}
@@ -1651,15 +1703,24 @@ pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
16511703
let offset_from_bottom =
16521704
(capsule_visual_height(translation_active) + 80.0 + bounds.bottom_inset) * scale;
16531705
let y = ((mon.bottom as f64) - offset_from_bottom).round() as i32;
1654-
let clamped_y = y.max(mon.top);
1655-
// #470 诊断 v2:当前只夹了上边(.max(mon.top)),未夹下/左/右。多显示器、
1656-
// 负坐标或异常 DPI 下胶囊可能被算到屏幕外却无任何观测。记录显示器几何与
1657-
// 最终落点,用于证伪/证实「胶囊定位到屏幕外」(C 子嫌疑)。
1706+
1707+
// #470:四边都夹到「工作区」内(去掉任务栏),保证整窗可见。GetMonitorInfoW
1708+
// 取不到 rcWork 时(理论上不会,rcWork 总随 rcMonitor 一同填)退回整屏矩形。
1709+
let (work_l, work_t, work_r, work_b) =
1710+
if mon.work_right > mon.work_left && mon.work_bottom > mon.work_top {
1711+
(mon.work_left, mon.work_top, mon.work_right, mon.work_bottom)
1712+
} else {
1713+
(mon.left, mon.top, mon.right, mon.bottom)
1714+
};
1715+
let (clamped_x, clamped_y) =
1716+
clamp_to_monitor(x, y, phys_w, phys_h, work_l, work_t, work_r, work_b);
16581717
log::debug!(
1659-
"[capsule] win position: mon=({},{})..({},{}) scale={:.2} size=({}x{}) -> x={} y={} clamped_y={}",
1660-
mon.left, mon.top, mon.right, mon.bottom, scale, phys_w, phys_h, x, y, clamped_y
1718+
"[capsule] win position: mon=({},{})..({},{}) work=({},{})..({},{}) scale={:.2} size=({}x{}) -> raw=({},{}) clamped=({},{})",
1719+
mon.left, mon.top, mon.right, mon.bottom,
1720+
work_l, work_t, work_r, work_b,
1721+
scale, phys_w, phys_h, x, y, clamped_x, clamped_y
16611722
);
1662-
window.set_position(PhysicalPosition::new(x, clamped_y))?;
1723+
window.set_position(PhysicalPosition::new(clamped_x, clamped_y))?;
16631724
return Ok(());
16641725
}
16651726
// 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor 逻辑。
@@ -1740,7 +1801,7 @@ fn capsule_height_for_qa() -> f64 {
17401801
mod tests {
17411802
use super::{
17421803
bottom_center_position, bottom_visual_position, capsule_height_for_qa,
1743-
capsule_visual_height, capsule_window_bounds, logical_monitor_frame,
1804+
capsule_visual_height, capsule_window_bounds, clamp_to_monitor, logical_monitor_frame,
17441805
parse_tray_polish_mode_id, rotate_log_if_too_large, tray_polish_mode_menu_entries,
17451806
tray_style_menu_enabled, LogicalMonitorFrame, LOG_ROTATE_LIMIT_BYTES,
17461807
};
@@ -1889,6 +1950,54 @@ mod tests {
18891950
assert_eq!(pos, (610.0, -176.0));
18901951
}
18911952

1953+
// ---- #470: capsule 四边 clamp(纯函数,合成多显示器 / 负原点 / 1.5x DPI 输入)----
1954+
1955+
#[test]
1956+
fn clamp_to_monitor_leaves_on_screen_position_untouched() {
1957+
// 1080p 主屏正中偏下,整窗本就可见 → 原样返回。
1958+
let (x, y) = clamp_to_monitor(800, 900, 264, 126, 0, 0, 1920, 1040);
1959+
assert_eq!((x, y), (800, 900));
1960+
}
1961+
1962+
#[test]
1963+
fn clamp_to_monitor_pulls_back_off_screen_right_and_bottom() {
1964+
// x/y 算到了屏幕右下外侧 → 收回到「右下角减去窗口尺寸」,整窗仍可见。
1965+
let (x, y) = clamp_to_monitor(2000, 1200, 264, 126, 0, 0, 1920, 1040);
1966+
assert_eq!((x, y), (1920 - 264, 1040 - 126));
1967+
// 整窗右/下边界都落在 area 内。
1968+
assert!(x + 264 <= 1920);
1969+
assert!(y + 126 <= 1040);
1970+
}
1971+
1972+
#[test]
1973+
fn clamp_to_monitor_pushes_into_negative_origin_left_monitor() {
1974+
// 副屏在主屏左侧(负 X 原点),落点算到了副屏左外侧 → 夹回 area_left。
1975+
// 1.5x DPI 下尺寸偏大,但 area 仍宽于窗口,左上角夹到 (-2560, top)。
1976+
let (x, y) = clamp_to_monitor(-3000, -100, 294, 138, -2560, 0, 0, 1440);
1977+
assert_eq!(x, -2560);
1978+
assert_eq!(y, 0);
1979+
// 右/下仍在 area 内。
1980+
assert!(x >= -2560 && x + 294 <= 0);
1981+
assert!(y >= 0 && y + 138 <= 1440);
1982+
}
1983+
1984+
#[test]
1985+
fn clamp_to_monitor_respects_work_area_above_taskbar() {
1986+
// 工作区底部 = 1040(任务栏占了 1040..1080)。落点本在任务栏区域(y=1030),
1987+
// 应被夹到「工作区底 - 窗口高」之上,胶囊整窗不压任务栏。
1988+
let (_x, y) = clamp_to_monitor(800, 1030, 264, 126, 0, 0, 1920, 1040);
1989+
assert_eq!(y, 1040 - 126);
1990+
assert!(y + 126 <= 1040);
1991+
}
1992+
1993+
#[test]
1994+
fn clamp_to_monitor_degrades_gracefully_when_window_wider_than_area() {
1995+
// 病态输入:area 比窗口还窄(罕见,但要保证不 panic、不溢出为负超界)。
1996+
// max_x 钳到 area_left,clamp 把左上角收回 area_left。
1997+
let (x, y) = clamp_to_monitor(500, 500, 800, 600, 0, 0, 400, 300);
1998+
assert_eq!((x, y), (0, 0));
1999+
}
2000+
18922001
#[test]
18932002
fn oversized_log_rotates_to_single_archive() {
18942003
let dir = std::env::temp_dir().join(format!("openless-log-rotate-{}", std::process::id()));

0 commit comments

Comments
 (0)