diff --git a/apps/windows/src/app.rs b/apps/windows/src/app.rs index 894e7d4..5c0dcfa 100644 --- a/apps/windows/src/app.rs +++ b/apps/windows/src/app.rs @@ -65,7 +65,9 @@ const WS_VSCROLL: u32 = 0x00200000; const WS_BORDER: u32 = 0x00800000; const WS_EX_TOOLWINDOW: u32 = 0x00000080; +const WS_EX_APPWINDOW: u32 = 0x00040000; const WS_EX_LAYERED: u32 = 0x00080000; +const GWL_EXSTYLE: i32 = -20; const ES_MULTILINE: u32 = 0x0004; const ES_AUTOVSCROLL: u32 = 0x0040; @@ -125,6 +127,8 @@ const HWND_NOTOPMOST: HWND = -2isize as HWND; const SWP_NOMOVE: u32 = 0x0002; const SWP_NOSIZE: u32 = 0x0001; const SWP_NOACTIVATE: u32 = 0x0010; +const SWP_NOZORDER: u32 = 0x0004; +const SWP_FRAMECHANGED: u32 = 0x0020; const LWA_ALPHA: u32 = 0x00000002; const TRANSPARENT: i32 = 1; @@ -215,6 +219,7 @@ const ID_BTN_UPDATE: usize = 428; const ID_CHK_AUTOSTART: usize = 425; const ID_BTN_SAVE_SETTINGS: usize = 426; const ID_BTN_HYMT_DELETE: usize = 427; +const ID_CHK_NORMAL_WINDOW: usize = 429; const ID_TRAY_TRANSLATE: usize = 901; const ID_TRAY_OCR: usize = 902; @@ -653,6 +658,7 @@ struct Win32AppState { cmb_opacity: HWND, chk_ocr_newline: HWND, chk_autostart: HWND, + chk_normal_window: HWND, btn_save_settings: HWND, lbl_settings_status: HWND, } @@ -740,7 +746,13 @@ pub fn run() { let title = wide_null("TTREE"); let hwnd = CreateWindowExW( - WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_COMPOSITED, + WS_EX_LAYERED + | WS_EX_COMPOSITED + | if settings.appearance().normal_window_mode { + WS_EX_APPWINDOW + } else { + WS_EX_TOOLWINDOW + }, class_name.as_ptr(), title.as_ptr(), WS_OVERLAPPED | WS_THICKFRAME | WS_CLIPCHILDREN, @@ -917,6 +929,7 @@ pub fn run() { cmb_opacity: ptr::null_mut(), chk_ocr_newline: ptr::null_mut(), chk_autostart: ptr::null_mut(), + chk_normal_window: ptr::null_mut(), updater: Default::default(), update_view: Default::default(), btn_update: ptr::null_mut(), @@ -1529,6 +1542,14 @@ impl Win32AppState { ID_CHK_AUTOSTART, false, ); + self.chk_normal_window = self.create_ctrl( + hinst, + "BUTTON", + "普通窗口模式", + BS_AUTOCHECKBOX, + ID_CHK_NORMAL_WINDOW, + false, + ); self.btn_update = self.create_ctrl( hinst, "BUTTON", @@ -1692,6 +1713,7 @@ impl Win32AppState { self.cmb_opacity, self.chk_ocr_newline, self.chk_autostart, + self.chk_normal_window, self.btn_update, self.lbl_update, ] @@ -2050,6 +2072,7 @@ impl Win32AppState { self.cmb_opacity, self.chk_ocr_newline, self.chk_autostart, + self.chk_normal_window, self.btn_update, self.lbl_update, ], @@ -2082,9 +2105,9 @@ impl Win32AppState { ID_SETTINGS_KEYS => 82 + row_h * 5 + 32 + 90, _ => { if wide { - 490 + 568 } else { - 530 + 608 } } }; @@ -2314,9 +2337,13 @@ impl Win32AppState { } _ => { let opacity_h = if wide { 84 } else { 112 }; - bg.card(BoxRect::new(x, top, fw, opacity_h + 286), p); + bg.card(BoxRect::new(x, top, fw, opacity_h + 364), p); form_row(self.lbl_opacity, self.cmb_opacity, top + 18, 32, &mut bg); for (i, (c, description)) in [ + ( + self.chk_normal_window, + "显示在任务栏和 Alt+Tab 中,切走时不收起", + ), (self.chk_ocr_newline, "合并识别文本中的段落换行"), (self.chk_autostart, "登录 Windows 时自动启动"), ] @@ -2328,13 +2355,18 @@ impl Win32AppState { place(c, x + inset, y + 15, fw - inset * 2, 26); show(c); bg.label( - BoxRect::new(x + inset + 25, y + 44, fw - inset * 2 - 25, 18), + BoxRect::new( + x + inset + if c == self.chk_normal_window { 0 } else { 25 }, + y + 44, + fw - inset * 2 - if c == self.chk_normal_window { 0 } else { 25 }, + 18, + ), description, true, true, ); } - let update_y = top + opacity_h + 168; + let update_y = top + opacity_h + 246; place(self.btn_update, x + inset, update_y, 140, 32); place( self.lbl_update, @@ -2419,6 +2451,66 @@ impl Win32AppState { InvalidateRect(self.btn_pin, ptr::null(), 0); } + unsafe fn apply_normal_window_mode(&self, enabled: bool) { + let current = GetWindowLongPtrW(self.hwnd, GWL_EXSTYLE) as u32; + let style = (current & !(WS_EX_APPWINDOW | WS_EX_TOOLWINDOW)) + | if enabled { + WS_EX_APPWINDOW + } else { + WS_EX_TOOLWINDOW + }; + if current == style { + return; + } + let visible = IsWindowVisible(self.hwnd) != 0; + let focus = GetFocus(); + // The shell updates taskbar membership when a window is hidden and shown. + // Do not use hide_window here: switching modes must keep active requests. + if visible { + ShowWindow(self.hwnd, SW_HIDE); + } + SetWindowLongPtrW(self.hwnd, GWL_EXSTYLE, style as isize); + SetWindowPos( + self.hwnd, + ptr::null_mut(), + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED, + ); + if visible { + ShowWindow(self.hwnd, SW_SHOW); + SetForegroundWindow(self.hwnd); + if !focus.is_null() { + SetFocus(focus); + } + } + } + + unsafe fn save_normal_window_mode(&self) { + let mut cfg = self.settings.appearance(); + let previous = cfg.normal_window_mode; + cfg.normal_window_mode = + SendMessageW(self.chk_normal_window, BM_GETCHECK, 0, 0) as usize == BST_CHECKED; + if let Err(error) = self.settings.save_appearance(cfg) { + SendMessageW( + self.chk_normal_window, + BM_SETCHECK, + if previous { BST_CHECKED } else { BST_UNCHECKED }, + 0, + ); + set_hwnd_text( + self.lbl_settings_status, + &format!("保存窗口模式失败: {error}"), + ); + } else { + self.apply_normal_window_mode(cfg.normal_window_mode); + set_hwnd_text(self.lbl_settings_status, "窗口模式已保存"); + } + InvalidateRect(self.chk_normal_window, ptr::null(), 0); + } + unsafe fn show_and_focus(&mut self) { ShowWindow(self.hwnd, SW_RESTORE); ShowWindow(self.hwnd, SW_SHOW); @@ -2969,6 +3061,16 @@ impl Win32AppState { }; SendMessageW(self.cmb_opacity, CB_SETCURSEL, op_idx, 0); + SendMessageW( + self.chk_normal_window, + BM_SETCHECK, + if app_cfg.normal_window_mode { + BST_CHECKED + } else { + BST_UNCHECKED + }, + 0, + ); SendMessageW( self.chk_ocr_newline, BM_SETCHECK, @@ -3075,8 +3177,15 @@ impl Win32AppState { let app_cfg = AppearanceConfig { panel_opacity: opacity, ocr_strip_newlines: ocr_strip, + normal_window_mode: self.settings.appearance().normal_window_mode, }; - let _ = self.settings.save_appearance(app_cfg); + if let Err(error) = self.settings.save_appearance(app_cfg) { + set_hwnd_text( + self.lbl_settings_status, + &format!("保存通用设置失败: {error}"), + ); + return; + } let alpha = (opacity * 255.0).round() as u8; SetLayeredWindowAttributes(self.hwnd, 0, alpha, LWA_ALPHA); @@ -3450,7 +3559,11 @@ unsafe extern "system" fn main_wnd_proc( if active_state == WA_INACTIVE { with_state(|st| { st.set_shortcut_recording(false); - if !st.pinned && !st.ocr_in_flight && st.active_tab != TabMode::Settings { + if !st.pinned + && !st.settings.appearance().normal_window_mode + && !st.ocr_in_flight + && st.active_tab != TabMode::Settings + { st.hide_window(); } }); @@ -3608,6 +3721,7 @@ unsafe fn handle_wm_command(st: &mut Win32AppState, id: usize, code: u16) { } ID_BTN_OCR if code == BN_CLICKED => st.trigger_ocr(), ID_BTN_PIN if code == BN_CLICKED => st.set_pinned(!st.pinned), + ID_CHK_NORMAL_WINDOW if code == BN_CLICKED => st.save_normal_window_mode(), ID_EDIT_SOURCE if code == EN_CHANGE => { st.refresh_source_input(); diff --git a/apps/windows/src/app/ui.rs b/apps/windows/src/app/ui.rs index a8a2c49..58e1365 100644 --- a/apps/windows/src/app/ui.rs +++ b/apps/windows/src/app/ui.rs @@ -50,6 +50,7 @@ const DT_CALCRECT: u32 = 0x400; const DT_NOPREFIX: u32 = 0x800; const DT_END_ELLIPSIS: u32 = 0x8000; const SRCCOPY: u32 = 0x00cc0020; +const HALFTONE: i32 = 4; #[repr(C)] struct PAINTSTRUCT { @@ -110,7 +111,8 @@ extern "system" { pub fn GetDpiForSystem() -> u32; pub fn GetWindowRect(hwnd: HWND, rect: *mut RECT) -> BOOL; fn GetDlgCtrlID(hwnd: HWND) -> i32; - fn GetWindowLongPtrW(hwnd: HWND, index: i32) -> isize; + pub fn GetWindowLongPtrW(hwnd: HWND, index: i32) -> isize; + pub fn SetWindowLongPtrW(hwnd: HWND, index: i32, value: isize) -> isize; fn SetViewportOrgEx(hdc: HDC, x: i32, y: i32, old: *mut POINT) -> BOOL; fn RedrawWindow(hwnd: HWND, rect: *const RECT, region: HANDLE, flags: u32) -> BOOL; pub fn GetFocus() -> HWND; @@ -158,6 +160,20 @@ extern "system" { sy: i32, rop: u32, ) -> BOOL; + fn StretchBlt( + dest: HDC, + x: i32, + y: i32, + w: i32, + h: i32, + src: HDC, + sx: i32, + sy: i32, + sw: i32, + sh: i32, + rop: u32, + ) -> BOOL; + fn SetStretchBltMode(hdc: HDC, mode: i32) -> i32; } #[link(name = "comctl32")] extern "system" { @@ -797,28 +813,95 @@ fn px(r: BoxRect) -> RECT { } } unsafe fn fill(hdc: HDC, r: RECT, color: u32, radius: i32) { - let brush = CreateSolidBrush(color); if radius == 0 { + let brush = CreateSolidBrush(color); FillRect(hdc, &r, brush); + DeleteObject(brush); } else { - let pen = CreatePen(0, 1, color); - let old_b = SelectObject(hdc, brush); - let old_p = SelectObject(hdc, pen); - RoundRect( - hdc, - r.left, - r.top, - r.right, - r.bottom, - scale(radius * 2), - scale(radius * 2), - ); - SelectObject(hdc, old_b); - SelectObject(hdc, old_p); - DeleteObject(pen); + rounded(hdc, r, color, scale(radius * 2)); } +} +/// Solid rounded rectangle at the caller's scale. The pen matches the brush so +/// the outline does not introduce a second color. GDI rounds corners without +/// anti-aliasing, so pass a supersampled rect for smooth edges. +unsafe fn rounded(hdc: HDC, r: RECT, color: u32, diameter: i32) { + let brush = CreateSolidBrush(color); + let pen = CreatePen(0, 1, color); + let old_b = SelectObject(hdc, brush); + let old_p = SelectObject(hdc, pen); + RoundRect(hdc, r.left, r.top, r.right, r.bottom, diameter, diameter); + SelectObject(hdc, old_b); + SelectObject(hdc, old_p); + DeleteObject(pen); DeleteObject(brush); } +/// Draws the settings switch. GDI's `RoundRect` is not anti-aliased, and at 22 +/// logical pixels tall the staircase on the pill and on the knob reads as a +/// low-resolution control. Both shapes are rasterized together at 4x and +/// halftone downscaled. They share one offscreen pass on purpose: downscaling +/// them separately would blend the knob's edge with the card instead of the +/// track and leave a halo. +unsafe fn draw_switch( + hdc: HDC, + track: RECT, + thumb: RECT, + track_color: u32, + thumb_color: u32, + background: u32, +) { + const FACTOR: i32 = 4; + let width = (track.right - track.left).max(1); + let height = (track.bottom - track.top).max(1); + let dc = CreateCompatibleDC(hdc); + let bitmap = CreateCompatibleBitmap(hdc, width * FACTOR, height * FACTOR); + if dc.is_null() || bitmap.is_null() { + if !dc.is_null() { + DeleteDC(dc); + } + if !bitmap.is_null() { + DeleteObject(bitmap); + } + fill(hdc, track, track_color, height / 2); + fill(hdc, thumb, thumb_color, (thumb.bottom - thumb.top) / 2); + return; + } + let old_bitmap = SelectObject(dc, bitmap); + let area = RECT { + left: 0, + top: 0, + right: width * FACTOR, + bottom: height * FACTOR, + }; + let backdrop = CreateSolidBrush(background); + FillRect(dc, &area, backdrop); + DeleteObject(backdrop); + rounded(dc, area, track_color, height * FACTOR); + let knob = RECT { + left: (thumb.left - track.left) * FACTOR, + top: (thumb.top - track.top) * FACTOR, + right: (thumb.right - track.left) * FACTOR, + bottom: (thumb.bottom - track.top) * FACTOR, + }; + rounded(dc, knob, thumb_color, (thumb.bottom - thumb.top) * FACTOR); + let mode = SetStretchBltMode(hdc, HALFTONE); + StretchBlt( + hdc, + track.left, + track.top, + width, + height, + dc, + 0, + 0, + width * FACTOR, + height * FACTOR, + SRCCOPY, + ); + SetStretchBltMode(hdc, mode); + SelectObject(dc, old_bitmap); + DeleteObject(bitmap); + DeleteDC(dc); +} unsafe fn text(hdc: HDC, s: &str, r: RECT, font: HFONT, color: u32, flags: u32) { let old = SelectObject(hdc, font); SetBkMode(hdc, TRANSPARENT); @@ -1291,6 +1374,45 @@ unsafe fn draw_checkbox(hwnd: HWND, hdc: HDC) { fill(hdc, r, background, 0); let center = (r.top + r.bottom) / 2; let check = SendMessageW(hwnd, BM_GETCHECK, 0, 0) as usize == BST_CHECKED; + if GetDlgCtrlID(hwnd) as usize == ID_CHK_NORMAL_WINDOW { + let track = RECT { + left: r.right - scale(40), + top: center - scale(11), + right: r.right, + bottom: center + scale(11), + }; + let thumb_left = track.left + scale(if check { 20 } else { 2 }); + let thumb = RECT { + left: thumb_left, + top: track.top + scale(2), + right: thumb_left + scale(18), + bottom: track.bottom - scale(2), + }; + // `p.line` is the card border and `p.pill` is the card fill, so an off + // track drawn with them disappears into the card it sits on. + draw_switch( + hdc, + track, + thumb, + if check { p.ink } else { p.faint }, + p.pill, + background, + ); + r.right = track.left - scale(12); + let f = VISUALS.with(|v| v.borrow().regular); + text( + hdc, + &get_hwnd_text(hwnd), + r, + f, + p.ink, + DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS, + ); + if GetFocus() == hwnd { + DrawFocusRect(hdc, &r); + } + return; + } let box_r = RECT { left: scale(1), top: center - scale(7), @@ -1381,7 +1503,7 @@ unsafe extern "system" fn control_proc( } let checkbox = matches!( GetDlgCtrlID(hwnd) as usize, - ID_CHK_AI_ENABLED | ID_CHK_OCR_NEWLINE | ID_CHK_AUTOSTART + ID_CHK_AI_ENABLED | ID_CHK_OCR_NEWLINE | ID_CHK_AUTOSTART | ID_CHK_NORMAL_WINDOW ); let interactive = checkbox || combo != 0 || GetWindowLongPtrW(hwnd, -16) as u32 & 0xf == BS_OWNERDRAW; diff --git a/crates/ttree-core/src/config.rs b/crates/ttree-core/src/config.rs index a9b29f3..90ee4bc 100644 --- a/crates/ttree-core/src/config.rs +++ b/crates/ttree-core/src/config.rs @@ -358,6 +358,9 @@ pub struct AppearanceConfig { pub panel_opacity: f64, #[serde(default = "default_true")] pub ocr_strip_newlines: bool, + /// Windows 普通窗口:显示在任务栏与 Alt+Tab 中,失焦时保持显示。 + #[serde(default)] + pub normal_window_mode: bool, } fn default_panel_opacity() -> f64 { @@ -369,6 +372,7 @@ impl Default for AppearanceConfig { Self { panel_opacity: default_panel_opacity(), ocr_strip_newlines: true, + normal_window_mode: false, } } } diff --git a/crates/ttree-storage/src/settings.rs b/crates/ttree-storage/src/settings.rs index e8b53bf..71aeb63 100644 --- a/crates/ttree-storage/src/settings.rs +++ b/crates/ttree-storage/src/settings.rs @@ -297,6 +297,7 @@ impl SettingsStore { let clamped = AppearanceConfig { panel_opacity: AppearanceConfig::clamp_opacity(cfg.panel_opacity), ocr_strip_newlines: cfg.ocr_strip_newlines, + normal_window_mode: cfg.normal_window_mode, }; let mut guard = self.cache.write().expect("settings lock poisoned"); guard.raw_map.insert( @@ -307,6 +308,10 @@ impl SettingsStore { "appearance.ocr_strip_newlines".into(), Value::from(clamped.ocr_strip_newlines), ); + guard.raw_map.insert( + "appearance.normal_window_mode".into(), + Value::from(clamped.normal_window_mode), + ); write_json_map(&self.path, &guard.raw_map)?; guard.appearance = clamped; Ok(()) @@ -550,6 +555,12 @@ fn load_appearance(map: &Map) -> AppearanceConfig { { cfg.ocr_strip_newlines = v; } + if let Some(v) = map + .get("appearance.normal_window_mode") + .and_then(Value::as_bool) + { + cfg.normal_window_mode = v; + } cfg } @@ -589,6 +600,29 @@ fn load_window_size(map: &Map) -> Option<(f64, f64)> { mod tests { use super::*; + #[test] + fn normal_window_mode_defaults_off_for_existing_settings_and_survives_reload() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write( + tmp.path(), + r#"{"appearance.panel_opacity":0.8,"appearance.ocr_strip_newlines":false}"#, + ) + .unwrap(); + let store = SettingsStore::open_at(tmp.path().to_path_buf()); + assert!(!store.appearance().normal_window_mode); + + for enabled in [true, false] { + let mut appearance = store.appearance(); + appearance.normal_window_mode = enabled; + store.save_appearance(appearance).unwrap(); + + let reloaded = SettingsStore::open_at(tmp.path().to_path_buf()); + assert_eq!(reloaded.appearance().normal_window_mode, enabled); + assert_eq!(reloaded.appearance().panel_opacity, 0.8); + assert!(!reloaded.appearance().ocr_strip_newlines); + } + } + #[test] fn persists_and_reloads_settings_and_api_key_in_json() { let tmp = tempfile::NamedTempFile::new().unwrap();