From 122f6c438c82229d3de7727b794c0369a647edcf Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Fri, 24 Jul 2026 16:40:13 +0800 Subject: [PATCH] feat: persist window size and dynamically adjust panel spacing on startup --- src-tauri/src/main.rs | 277 ++++++++++++++++++++++++++++++++++++++++- src-tauri/src/usage.rs | 2 + src/styles.css | 60 ++++++++- 3 files changed, 333 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 44eeb51..76b49b8 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -35,7 +35,7 @@ use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, TrayIconBuilder, TrayIconEvent}, }; -use tauri::{Emitter, Manager}; +use tauri::{Emitter, LogicalSize, Manager}; use tauri_plugin_opener::OpenerExt; use tokio_util::sync::CancellationToken; use zip::ZipArchive; @@ -62,6 +62,9 @@ const CORE_VERSION_FILE: &str = "core-version.txt"; const CORE_CHECKSUMS_FILE: &str = "checksums.txt"; const GUI_CONFIG_FILE: &str = "config.toml"; const LEGACY_GUI_CONFIG_FILE: &str = "cpa-gui.yaml"; +const MIN_MAIN_WINDOW_WIDTH: u32 = 640; +const MIN_MAIN_WINDOW_HEIGHT: u32 = 600; +const MAX_SAVED_WINDOW_DIMENSION: u32 = 16_384; const OAUTH_DIR_NAME: &str = "oauth"; const DEFAULT_API_KEY: &str = "123456"; const DEFAULT_API_KEY_REMARK: &str = "内置密钥"; @@ -187,6 +190,16 @@ struct GuiConfigState { inner: Mutex, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SavedWindowSize { + width: u32, + height: u32, +} + +struct MainWindowSizeState { + inner: Mutex>, +} + #[derive(Default)] struct AgentConfigStatusCache { refresh_lock: Mutex<()>, @@ -390,6 +403,8 @@ struct GuiConfigFile { port: u16, allow_lan: bool, run_on_startup: bool, + window_width: Option, + window_height: Option, auth_dir: String, #[serde(deserialize_with = "deserialize_gui_api_keys")] api_keys: Vec, @@ -443,6 +458,8 @@ impl Default for GuiConfigFile { port: 8317, allow_lan: false, run_on_startup: false, + window_width: None, + window_height: None, auth_dir: env::current_exe() .ok() .and_then(|path| path.parent().map(|parent| parent.join(OAUTH_DIR_NAME))) @@ -1006,6 +1023,30 @@ impl CoreProcessState { } } +impl MainWindowSizeState { + fn new(size: Option) -> Self { + Self { + inner: Mutex::new(size), + } + } + + fn snapshot(&self) -> Result, String> { + self.inner + .lock() + .map(|size| *size) + .map_err(|_| "主窗口尺寸状态锁已损坏".to_string()) + } + + fn replace(&self, size: SavedWindowSize) -> Result<(), String> { + let mut current = self + .inner + .lock() + .map_err(|_| "主窗口尺寸状态锁已损坏".to_string())?; + *current = Some(size); + Ok(()) + } +} + impl GuiConfigState { fn new(config: GuiConfigFile) -> Self { Self { @@ -1042,6 +1083,14 @@ impl GuiConfigState { }) } + fn set_window_size(&self, size: SavedWindowSize) -> Result { + self.update(|config| { + config.window_width = Some(size.width); + config.window_height = Some(size.height); + Ok(()) + }) + } + fn set_management_secret_key(&self, secret_key: String) -> Result { self.update(|config| { config.management_secret_key = secret_key; @@ -8701,6 +8750,123 @@ fn merge_core_api_keys_with_gui_metadata( merged } +fn normalized_saved_window_size(width: u32, height: u32) -> SavedWindowSize { + SavedWindowSize { + width: width.clamp(MIN_MAIN_WINDOW_WIDTH, MAX_SAVED_WINDOW_DIMENSION), + height: height.clamp(MIN_MAIN_WINDOW_HEIGHT, MAX_SAVED_WINDOW_DIMENSION), + } +} + +fn configured_window_size(config: &GuiConfigFile) -> Option { + match (config.window_width, config.window_height) { + (Some(width), Some(height)) => Some(normalized_saved_window_size(width, height)), + _ => None, + } +} + +fn logical_window_size_from_physical( + physical_size: &tauri::PhysicalSize, + scale_factor: f64, +) -> Option { + if !scale_factor.is_finite() || scale_factor <= 0.0 { + return None; + } + + let width = (f64::from(physical_size.width) / scale_factor).round() as u32; + let height = (f64::from(physical_size.height) / scale_factor).round() as u32; + if width < MIN_MAIN_WINDOW_WIDTH || height < MIN_MAIN_WINDOW_HEIGHT { + return None; + } + + Some(normalized_saved_window_size(width, height)) +} + +fn fit_window_size_to_current_monitor( + window: &tauri::WebviewWindow, + saved_size: SavedWindowSize, +) -> SavedWindowSize { + let monitor = window + .current_monitor() + .ok() + .flatten() + .or_else(|| window.primary_monitor().ok().flatten()); + let Some(monitor) = monitor else { + return saved_size; + }; + + let scale_factor = monitor.scale_factor(); + if !scale_factor.is_finite() || scale_factor <= 0.0 { + return saved_size; + } + + let (frame_width, frame_height) = window + .outer_size() + .ok() + .zip(window.inner_size().ok()) + .map(|(outer, inner)| { + ( + outer.width.saturating_sub(inner.width), + outer.height.saturating_sub(inner.height), + ) + }) + .unwrap_or_default(); + let work_area = monitor.work_area(); + let max_width = + (f64::from(work_area.size.width.saturating_sub(frame_width)) / scale_factor).floor() as u32; + let max_height = (f64::from(work_area.size.height.saturating_sub(frame_height)) / scale_factor) + .floor() as u32; + + SavedWindowSize { + width: saved_size.width.min(max_width.max(MIN_MAIN_WINDOW_WIDTH)), + height: saved_size + .height + .min(max_height.max(MIN_MAIN_WINDOW_HEIGHT)), + } +} + +fn restore_main_window_size(app: &tauri::AppHandle) -> Result<(), String> { + let window_size_state = app.state::(); + let Some(saved_size) = window_size_state.snapshot()? else { + return Ok(()); + }; + let window = app + .get_webview_window("main") + .ok_or_else(|| "主窗口不存在,无法恢复窗口尺寸".to_string())?; + let restored_size = fit_window_size_to_current_monitor(&window, saved_size); + + window + .set_size(LogicalSize::new( + f64::from(restored_size.width), + f64::from(restored_size.height), + )) + .map_err(|error| format!("恢复主窗口尺寸失败: {error}"))?; + window_size_state.replace(restored_size) +} + +fn persist_main_window_size(app: &tauri::AppHandle) -> Result<(), String> { + let window_size_state = app.state::(); + let saved_size = match window_size_state.snapshot()? { + Some(size) => size, + None => { + let window = app + .get_webview_window("main") + .ok_or_else(|| "主窗口不存在,无法保存窗口尺寸".to_string())?; + let physical_size = window + .inner_size() + .map_err(|error| format!("读取主窗口尺寸失败: {error}"))?; + let scale_factor = window + .scale_factor() + .map_err(|error| format!("读取主窗口缩放比例失败: {error}"))?; + logical_window_size_from_physical(&physical_size, scale_factor) + .ok_or_else(|| "主窗口尺寸无效,已跳过保存".to_string())? + } + }; + + app.state::() + .set_window_size(saved_size) + .map(|_| ()) +} + fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result { let mut changed = false; let normalized_locale = normalize_app_locale(&config.locale); @@ -8730,6 +8896,16 @@ fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result { config.management_secret_key = DEFAULT_MANAGEMENT_SECRET_KEY.to_string(); changed = true; } + let window_size = configured_window_size(config); + let normalized_window_width = window_size.map(|size| size.width); + let normalized_window_height = window_size.map(|size| size.height); + if config.window_width != normalized_window_width + || config.window_height != normalized_window_height + { + config.window_width = normalized_window_width; + config.window_height = normalized_window_height; + changed = true; + } let auth_dir = path_to_string(&fixed_oauth_dir()?); if config.auth_dir != auth_dir { config.auth_dir = auth_dir; @@ -10494,6 +10670,7 @@ fn main() { config } }; + let initial_window_size = configured_window_size(&gui_config); let app = tauri::Builder::default() .plugin( @@ -10506,8 +10683,36 @@ fn main() { .manage(CoreProcessState::default()) .manage(usage::UsageCollectorState::default()) .manage(GuiConfigState::new(gui_config)) + .manage(MainWindowSizeState::new(initial_window_size)) .manage(AgentConfigStatusCache::default()); + let app = app.on_window_event(|window, event| { + if window.label() != "main" { + return; + } + + let observed_size = match event { + tauri::WindowEvent::Resized(physical_size) => { + window.scale_factor().ok().and_then(|scale_factor| { + logical_window_size_from_physical(physical_size, scale_factor) + }) + } + tauri::WindowEvent::ScaleFactorChanged { + scale_factor, + new_inner_size, + .. + } => logical_window_size_from_physical(new_inner_size, *scale_factor), + _ => None, + }; + + if let Some(observed_size) = observed_size { + let window_size_state = window.state::(); + if let Err(error) = window_size_state.replace(observed_size) { + eprintln!("记录主窗口尺寸失败: {error}"); + } + } + }); + #[cfg(target_os = "macos")] let app = app.on_window_event(|window, event| { if window.label() == "main" { @@ -10538,6 +10743,10 @@ fn main() { let app = app .setup(move |app| { + if let Err(error) = restore_main_window_size(app.handle()) { + eprintln!("{error}"); + } + #[cfg(target_os = "macos")] setup_macos_tray(app)?; #[cfg(target_os = "windows")] @@ -10652,13 +10861,19 @@ fn main() { .build(tauri::generate_context!()) .expect("failed to build app"); - app.run(|app_handle, event| { - if matches!(event, tauri::RunEvent::Exit) { + app.run(|app_handle, event| match event { + tauri::RunEvent::ExitRequested { .. } => { + if let Err(error) = persist_main_window_size(app_handle) { + eprintln!("保存主窗口尺寸失败: {error}"); + } + } + tauri::RunEvent::Exit => { usage::stop_usage_collector(app_handle); let process_state = app_handle.state::(); let gui_config_state = app_handle.state::(); shutdown_managed_core(process_state.inner(), gui_config_state.inner()); } + _ => {} }); } @@ -10739,6 +10954,60 @@ mod tests { assert!(content.contains("routing-strategy = \"round-robin\"")); } + #[test] + fn gui_window_size_round_trips_through_portable_config() { + let config = GuiConfigFile { + window_width: Some(1440), + window_height: Some(900), + ..GuiConfigFile::default() + }; + + let content = toml::to_string_pretty(&config).unwrap(); + let restored = toml::from_str::(&content).unwrap(); + + assert!(content.contains("window-width = 1440")); + assert!(content.contains("window-height = 900")); + assert_eq!( + configured_window_size(&restored), + Some(SavedWindowSize { + width: 1440, + height: 900, + }) + ); + } + + #[test] + fn gui_window_size_is_clamped_and_requires_both_dimensions() { + let mut config = GuiConfigFile { + window_width: Some(320), + window_height: Some(30_000), + ..GuiConfigFile::default() + }; + + assert!(sanitize_gui_config(&mut config).unwrap()); + assert_eq!(config.window_width, Some(MIN_MAIN_WINDOW_WIDTH)); + assert_eq!(config.window_height, Some(MAX_SAVED_WINDOW_DIMENSION)); + + config.window_height = None; + assert!(sanitize_gui_config(&mut config).unwrap()); + assert_eq!(config.window_width, None); + assert_eq!(config.window_height, None); + } + + #[test] + fn physical_window_size_uses_display_scale_and_ignores_minimized_sizes() { + let physical_size = tauri::PhysicalSize::new(1500, 1000); + assert_eq!( + logical_window_size_from_physical(&physical_size, 1.25), + Some(SavedWindowSize { + width: 1200, + height: 800, + }) + ); + assert!(logical_window_size_from_physical(&tauri::PhysicalSize::new(0, 0), 1.0).is_none()); + assert!(logical_window_size_from_physical(&physical_size, 0.0).is_none()); + } + #[test] fn claude_agent_config_preserves_existing_fields() { let rendered = build_claude_agent_config( @@ -12199,6 +12468,8 @@ mod tests { port: 9527, allow_lan: true, run_on_startup: false, + window_width: None, + window_height: None, auth_dir: path_to_string(&fixed_oauth_dir().unwrap()), api_keys: vec![ built_in_api_key_entry(), diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index aee12a9..9a68aa5 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1376,6 +1376,8 @@ mod tests { port: 8317, allow_lan: false, run_on_startup: false, + window_width: None, + window_height: None, auth_dir: String::new(), api_keys: Vec::new(), management_secret_key: "123456".to_string(), diff --git a/src/styles.css b/src/styles.css index 27e00fb..0043a2f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -639,13 +639,16 @@ input:focus-visible { .client-api-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); + grid-auto-rows: 1fr; + align-items: stretch; gap: 12px; } .client-api-card { display: grid; - grid-template-rows: auto auto; + grid-template-rows: auto minmax(0, 1fr); gap: 14px; + height: 100%; min-width: 0; padding: 16px; border: 1px solid #e3e1db; @@ -704,6 +707,7 @@ input:focus-visible { .client-api-values { display: grid; + align-content: start; gap: 7px; } @@ -865,19 +869,69 @@ input:focus-visible { @container workspace (min-width: 840px) { .home-page { grid-template-columns: minmax(320px, 0.8fr) minmax(0, 1.2fr); - align-items: start; + grid-auto-rows: 1fr; + align-items: stretch; } .home-page > .home-layout { - display: contents; + display: grid; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + align-self: stretch; + align-items: stretch; + height: 100%; } .home-page > .client-api-panel { grid-column: 2; } + .home-page > .home-layout > .control-panel, + .home-page > .client-api-panel { + display: grid; + height: 100%; + align-self: stretch; + row-gap: clamp(12px, 2vh, 22px); + } + + .home-page > .home-layout > .control-panel { + grid-template-rows: auto minmax(0, 1fr) auto; + } + + .home-page > .client-api-panel { + grid-template-rows: auto minmax(0, 1fr); + } + + .home-page > .home-layout > .control-panel > .panel-heading, + .home-page > .client-api-panel > .panel-heading, + .version-management-layout > .panel > .panel-heading { + min-height: 0; + margin-bottom: 0; + padding-bottom: 0; + } + .version-management-layout { grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 1fr; + align-items: stretch; + } + + .version-management-layout > .panel { + height: 100%; + align-self: stretch; + grid-template-rows: auto minmax(0, 1fr) auto; + row-gap: clamp(12px, 2vh, 22px); + } + + .home-page > .home-layout > .control-panel > .panel-detail-grid, + .version-management-layout > .panel > .panel-detail-grid, + .home-page > .client-api-panel > .client-api-grid { + align-content: space-evenly; + } + + .home-page > .home-layout > .control-panel > .panel-action-row, + .version-management-layout > .panel > .panel-action-row { + padding-top: 0; } }