From b06ce07077d127816b7f3412d393888fb5f92711 Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Mon, 11 May 2026 17:52:30 +0200 Subject: [PATCH] fix: window state and title bar color --- Cargo.toml | 1 + src/stremio_app/app.rs | 38 +++++- src/stremio_app/mod.rs | 1 + src/stremio_app/window_helper.rs | 47 +++++++- src/stremio_app/window_settings.rs | 181 +++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 src/stremio_app/window_settings.rs diff --git a/Cargo.toml b/Cargo.toml index 2321a28..affa85e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ native-windows-gui = { git = "https://github.com/Stremio/native-windows-gui", fe ] } native-windows-derive = "1" winapi = { version = "0.3.9", features = [ + "dwmapi", "libloaderapi", "handleapi", "jobapi2", diff --git a/src/stremio_app/app.rs b/src/stremio_app/app.rs index 024272e..5d7b901 100644 --- a/src/stremio_app/app.rs +++ b/src/stremio_app/app.rs @@ -24,6 +24,7 @@ use crate::stremio_app::{ systray::SystemTray, updater, window_helper::WindowStyle, + window_settings::WindowSettings, PipeServer, }; @@ -53,14 +54,15 @@ pub struct MainWindow { OnPaint: [Self::on_paint], OnMinMaxInfo: [Self::on_min_max(SELF, EVT_DATA)], OnWindowMinimize: [Self::transmit_window_state_change], - OnWindowMaximize: [Self::transmit_window_state_change], + OnWindowMaximize: [Self::on_window_state_changed], OnWindowFocus: [Self::transmit_window_state_change], + OnResizeEnd: [Self::save_window_settings], )] pub window: nwg::Window, #[nwg_partial(parent: window)] #[nwg_events( (tray, MousePressLeftUp): [Self::on_show], - (tray_exit, OnMenuItemSelected): [nwg::stop_thread_dispatch()], + (tray_exit, OnMenuItemSelected): [Self::on_exit], (tray_show_hide, OnMenuItemSelected): [Self::on_show_hide], (tray_topmost, OnMenuItemSelected): [Self::on_toggle_topmost], )] @@ -130,7 +132,13 @@ impl MainWindow { self.webview.dev_tools.set(self.dev_tools).ok(); if let Some(hwnd) = self.window.handle.hwnd() { if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() { - saved_style.center_window(hwnd, WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT); + saved_style.set_title_bar_color(hwnd); + if let Some(window_settings) = WindowSettings::load() { + saved_style + .restore_window_placement(hwnd, window_settings.to_window_placement()); + } else { + saved_style.center_window(hwnd, WINDOW_MIN_WIDTH, WINDOW_MIN_HEIGHT); + } } } @@ -360,6 +368,25 @@ impl MainWindow { self.webview.fit_to_window(self.window.handle.hwnd()); } } + fn on_window_state_changed(&self) { + self.save_window_settings(); + self.transmit_window_state_change(); + } + fn save_window_settings(&self) { + if self + .saved_window_style + .try_borrow() + .map(|style| style.full_screen) + .unwrap_or(false) + { + return; + } + if let Some(hwnd) = self.window.handle.hwnd() { + if let Err(err) = WindowSettings::save(hwnd) { + eprintln!("Cannot save window settings: {err}"); + } + } + } fn on_toggle_fullscreen_notice(&self) { if let Some(hwnd) = self.window.handle.hwnd() { if let Ok(mut saved_style) = self.saved_window_style.try_borrow_mut() { @@ -422,8 +449,13 @@ impl MainWindow { if let nwg::EventData::OnWindowClose(data) = data { data.close(false); } + self.save_window_settings(); self.window.set_visible(false); self.tray.tray_show_hide.set_checked(self.window.visible()); self.transmit_window_visibility_change(); } + fn on_exit(&self) { + self.save_window_settings(); + nwg::stop_thread_dispatch(); + } } diff --git a/src/stremio_app/mod.rs b/src/stremio_app/mod.rs index 0301d8b..d0198c1 100644 --- a/src/stremio_app/mod.rs +++ b/src/stremio_app/mod.rs @@ -9,6 +9,7 @@ pub mod named_pipe; pub mod splash; pub mod systray; pub mod window_helper; +pub mod window_settings; pub use named_pipe::{PipeClient, PipeServer}; pub mod constants; pub mod updater; diff --git a/src/stremio_app/window_helper.rs b/src/stremio_app/window_helper.rs index 68c5547..1312b2e 100644 --- a/src/stremio_app/window_helper.rs +++ b/src/stremio_app/window_helper.rs @@ -1,13 +1,21 @@ use std::{cmp, mem}; +use winapi::ctypes::c_void; +use winapi::shared::minwindef::DWORD; use winapi::shared::windef::HWND; +use winapi::um::dwmapi::DwmSetWindowAttribute; use winapi::um::winuser::{ GetForegroundWindow, GetMonitorInfoA, GetSystemMetrics, GetWindowLongA, GetWindowRect, - IsIconic, IsZoomed, MonitorFromWindow, SetForegroundWindow, SetWindowLongA, SetWindowPos, - GWL_EXSTYLE, GWL_STYLE, HWND_NOTOPMOST, HWND_TOPMOST, MONITORINFO, MONITOR_DEFAULTTONEAREST, - SM_CXSCREEN, SM_CYSCREEN, SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOSIZE, WS_CAPTION, - WS_EX_CLIENTEDGE, WS_EX_DLGMODALFRAME, WS_EX_STATICEDGE, WS_EX_TOPMOST, WS_EX_WINDOWEDGE, - WS_THICKFRAME, + IsIconic, IsZoomed, MonitorFromWindow, SetForegroundWindow, SetWindowLongA, SetWindowPlacement, + SetWindowPos, GWL_EXSTYLE, GWL_STYLE, HWND_NOTOPMOST, HWND_TOPMOST, MONITORINFO, + MONITOR_DEFAULTTONEAREST, SM_CXSCREEN, SM_CYSCREEN, SWP_FRAMECHANGED, SWP_NOMOVE, SWP_NOSIZE, + WINDOWPLACEMENT, WS_CAPTION, WS_EX_CLIENTEDGE, WS_EX_DLGMODALFRAME, WS_EX_STATICEDGE, + WS_EX_TOPMOST, WS_EX_WINDOWEDGE, WS_THICKFRAME, }; + +const DWMWA_CAPTION_COLOR: DWORD = 35; +const DWMWA_TEXT_COLOR: DWORD = 36; +const STREMIO_CAPTION_COLOR: DWORD = 0x00f72f7b; +const WHITE_TEXT_COLOR: DWORD = 0x00ffffff; // https://doc.qt.io/qt-5/qt.html#WindowState-enum bitflags! { struct WindowState: u8 { @@ -71,6 +79,35 @@ impl WindowStyle { self.pos = ((monitor_w - self.size.0) / 2, (monitor_h - self.size.1) / 2); self.show_window_at(hwnd, HWND_NOTOPMOST); } + pub fn restore_window_placement(&mut self, hwnd: HWND, placement: WINDOWPLACEMENT) { + self.pos = ( + placement.rcNormalPosition.left, + placement.rcNormalPosition.top, + ); + self.size = ( + placement.rcNormalPosition.right - placement.rcNormalPosition.left, + placement.rcNormalPosition.bottom - placement.rcNormalPosition.top, + ); + unsafe { + SetWindowPlacement(hwnd, &placement); + } + } + pub fn set_title_bar_color(&self, hwnd: HWND) { + unsafe { + DwmSetWindowAttribute( + hwnd, + DWMWA_CAPTION_COLOR, + &STREMIO_CAPTION_COLOR as *const _ as *const c_void, + mem::size_of_val(&STREMIO_CAPTION_COLOR) as DWORD, + ); + DwmSetWindowAttribute( + hwnd, + DWMWA_TEXT_COLOR, + &WHITE_TEXT_COLOR as *const _ as *const c_void, + mem::size_of_val(&WHITE_TEXT_COLOR) as DWORD, + ); + } + } pub fn toggle_full_screen(&mut self, hwnd: HWND) { if self.full_screen { let topmost = if self.ex_style as u32 & WS_EX_TOPMOST == WS_EX_TOPMOST { diff --git a/src/stremio_app/window_settings.rs b/src/stremio_app/window_settings.rs new file mode 100644 index 0000000..76179ea --- /dev/null +++ b/src/stremio_app/window_settings.rs @@ -0,0 +1,181 @@ +use serde::{Deserialize, Serialize}; +use std::{env, fs, io, path::PathBuf}; +use winapi::shared::windef::{HWND, POINT, RECT}; +use winapi::um::winuser::{ + GetWindowPlacement, IsIconic, SW_SHOWMAXIMIZED, SW_SHOWNORMAL, WINDOWPLACEMENT, +}; + +const WINDOW_SETTINGS_FILE: &str = "window-state.json"; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct WindowSettings { + show_cmd: u32, + min_position: Point, + max_position: Point, + normal_position: Rect, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct Point { + x: i32, + y: i32, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct Rect { + left: i32, + top: i32, + right: i32, + bottom: i32, +} + +impl WindowSettings { + pub fn load() -> Option { + fs::read_to_string(settings_path()) + .ok() + .and_then(|settings| serde_json::from_str(&settings).ok()) + } + + pub fn save(hwnd: HWND) -> io::Result<()> { + let Some(settings) = Self::from_window(hwnd) else { + return Ok(()); + }; + let path = settings_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(&settings).map_err(io::Error::other)?; + fs::write(path, json) + } + + pub fn to_window_placement(&self) -> WINDOWPLACEMENT { + let mut placement = WINDOWPLACEMENT { + length: std::mem::size_of::() as u32, + flags: 0, + showCmd: self.show_cmd, + ptMinPosition: self.min_position.clone().into(), + ptMaxPosition: self.max_position.clone().into(), + rcNormalPosition: self.normal_position.clone().into(), + }; + if !is_restorable_size(&placement.rcNormalPosition) { + placement.showCmd = SW_SHOWNORMAL as u32; + } + placement + } + + fn from_window(hwnd: HWND) -> Option { + if unsafe { IsIconic(hwnd) } != 0 { + return None; + } + + let mut placement = WINDOWPLACEMENT { + length: std::mem::size_of::() as u32, + flags: 0, + showCmd: 0, + ptMinPosition: POINT { x: 0, y: 0 }, + ptMaxPosition: POINT { x: 0, y: 0 }, + rcNormalPosition: RECT { + left: 0, + top: 0, + right: 0, + bottom: 0, + }, + }; + + if unsafe { GetWindowPlacement(hwnd, &mut placement) } == 0 { + return None; + } + if !is_restorable_size(&placement.rcNormalPosition) { + return None; + } + + Some(WindowSettings { + show_cmd: if placement.showCmd == SW_SHOWMAXIMIZED as u32 { + SW_SHOWMAXIMIZED as u32 + } else { + SW_SHOWNORMAL as u32 + }, + min_position: placement.ptMinPosition.into(), + max_position: placement.ptMaxPosition.into(), + normal_position: placement.rcNormalPosition.into(), + }) + } +} + +fn settings_path() -> PathBuf { + env::var_os("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(env::temp_dir) + .join("Stremio") + .join(WINDOW_SETTINGS_FILE) +} + +fn is_restorable_size(rect: &RECT) -> bool { + rect.right > rect.left && rect.bottom > rect.top +} + +impl From for Point { + fn from(point: POINT) -> Self { + Point { + x: point.x, + y: point.y, + } + } +} + +impl From for POINT { + fn from(point: Point) -> Self { + POINT { + x: point.x, + y: point.y, + } + } +} + +impl From for Rect { + fn from(rect: RECT) -> Self { + Rect { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + } + } +} + +impl From for RECT { + fn from(rect: Rect) -> Self { + RECT { + left: rect.left, + top: rect.top, + right: rect.right, + bottom: rect.bottom, + } + } +} + +#[cfg(test)] +mod tests { + use super::is_restorable_size; + use winapi::shared::windef::RECT; + + #[test] + fn rejects_empty_window_rect() { + assert!(!is_restorable_size(&RECT { + left: 10, + top: 10, + right: 10, + bottom: 20, + })); + } + + #[test] + fn accepts_non_empty_window_rect() { + assert!(is_restorable_size(&RECT { + left: 10, + top: 10, + right: 20, + bottom: 20, + })); + } +}