fix: window state and title bar color
Some checks are pending
Continuous integration / test (push) Waiting to run

This commit is contained in:
Timothy Z. 2026-05-11 17:52:30 +02:00
parent bbbe882faf
commit b06ce07077
5 changed files with 260 additions and 8 deletions

View file

@ -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",

View file

@ -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();
}
}

View file

@ -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;

View file

@ -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 {

View file

@ -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<Self> {
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::<WINDOWPLACEMENT>() 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<Self> {
if unsafe { IsIconic(hwnd) } != 0 {
return None;
}
let mut placement = WINDOWPLACEMENT {
length: std::mem::size_of::<WINDOWPLACEMENT>() 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<POINT> for Point {
fn from(point: POINT) -> Self {
Point {
x: point.x,
y: point.y,
}
}
}
impl From<Point> for POINT {
fn from(point: Point) -> Self {
POINT {
x: point.x,
y: point.y,
}
}
}
impl From<RECT> for Rect {
fn from(rect: RECT) -> Self {
Rect {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
}
}
}
impl From<Rect> 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,
}));
}
}