feat(windows): add a normal window mode switch

Add a 普通窗口模式 switch to the general settings section. When enabled the
window is created with WS_EX_APPWINDOW instead of WS_EX_TOOLWINDOW, so it
appears in the taskbar and Alt+Tab, and losing focus no longer hides it. The
extended style switches live without a restart; a failed save rolls the
checkbox back and reports the error, and the general section now reports
save failures instead of discarding them.

The switch is drawn anti-aliased. GDI's RoundRect has no smoothing, and at
22 logical pixels tall the staircase on the pill and the knob read as a
low-resolution control, so both shapes are rasterized together at 4x and
halftone-downscaled. The off track uses p.faint: p.line is the card border
and p.pill is the card fill, so the previous colours made the switch
disappear into the card it sits on.
This commit is contained in:
lingr25 committed 2026-10-16 14:09:40 +09:00
1 parent 6b59eb12e5
commit 1fde401b3a
4 files changed
+300 -26

No files matched your search

+122 -8
View File
@@ -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();
+140 -18
View File
@@ -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;
+4
View File
@@ -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,
}
}
}
+34
View File
@@ -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<String, Value>) -> 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<String, Value>) -> 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();