From 52d68a63f8878930b90adbbf4dcdb3753addef12 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Wed, 30 Sep 2026 13:02:51 -0700 Subject: [PATCH] fix(windows): acrylic and Mica show through the row, live theme switching, Windows 7 glass through the toolbar - The title bar no longer paints an opaque fill over a translucent surface, so Acrylic on 10 and Mica on 11 show. - A system theme change re-reads the app colour mode before winit handles it; the acrylic tint follows. - On Windows 7 glass the sidebar stays opaque and toolbar fields and buttons get light faces. - Tests that assumed Unix paths or permission bits are portable or Unix-only. Assisted-by: claude-opus-5.5 --- arc/platforms.md | 9 ++-- crates/notebook/tests/discovery.rs | 3 ++ crates/notebook/tests/session.rs | 10 ++-- crates/notebook/tests/structure.rs | 32 ++++++------ crates/snowbound/src/library.rs | 6 +-- crates/snowbound/src/linux.rs | 5 ++ crates/snowbound/src/macos.rs | 5 ++ crates/snowbound/src/main.rs | 10 ++-- crates/snowbound/src/sidebar.rs | 4 +- crates/snowbound/src/windows.rs | 82 +++++++++++++++++++++++------- crates/ui/src/shell.rs | 13 +++-- crates/ui/src/theme.rs | 13 ++++- 12 files changed, 139 insertions(+), 53 deletions(-) diff --git a/arc/platforms.md b/arc/platforms.md index 56f89b2deebf6b5cd04af2e58d1b2337b474fd4b..36d86630cd1ae56f92d6cb38625ef93e626caf86 100644 --- a/arc/platforms.md +++ b/arc/platforms.md @@ -114,9 +114,12 @@ keyboard, the toolbar and the macOS menu bar all run commands from it. them over the glass; 11 doesn't over the Direct3D surface, so the row draws them as 11 does, lit where the system reports the pointer. On 8 and 10, whose frames are opaque, the window has no system frame and the - row draws and runs caption buttons as 10 does, over acrylic on 10. With - Windows 7's basic or classic theme the system draws the title bar and - the row lies beneath it, as on KDE. + row draws and runs caption buttons as 10 does, over acrylic on 10. Over + 7's glass the sidebar stays opaque, and fields and tool buttons take white + faces, as Internet Explorer's do there. The theme and material follow the + system's colour mode as it changes. With Windows 7's basic or classic + theme the system draws the title bar and the row lies beneath it, as on + KDE. - A notebook on a share opens by its UNC path through Windows' own SMB client, which takes OneNote's opens and locks natively: `onestore` opens a section as OneNote does (a reader shares it with everyone, a writer denies diff --git a/crates/notebook/tests/discovery.rs b/crates/notebook/tests/discovery.rs index 4961768984d55c1a9f529af4dcc514f11937a684..1b28f8cd7b0944da08831a18f6f594bbe5a83a42 100644 --- a/crates/notebook/tests/discovery.rs +++ b/crates/notebook/tests/discovery.rs @@ -463,8 +463,10 @@ fn local_access_stays_inside_the_selected_root() { } /// Restores permissions a test took away, so the temporary directory can be removed. +#[cfg(unix)] struct Restore(Vec<(std::path::PathBuf, u32)>); +#[cfg(unix)] impl Drop for Restore { fn drop(&mut self) { use std::os::unix::fs::PermissionsExt; @@ -474,6 +476,7 @@ impl Drop for Restore { } } +#[cfg(unix)] #[test] fn unreadable_children_are_listed_as_unavailable_and_retried() { use std::os::unix::fs::PermissionsExt; diff --git a/crates/notebook/tests/session.rs b/crates/notebook/tests/session.rs index 748ddd5c8753464badc1f187f41c389c0f61475d..4217a8484aaae6c73f44fa18655eb47bc7e59357 100644 --- a/crates/notebook/tests/session.rs +++ b/crates/notebook/tests/session.rs @@ -196,9 +196,10 @@ fn saves_wait_for_an_unreachable_file_and_publish_after_relaunch() { let (section, _) = open(&file, &cache); let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); - use std::os::unix::fs::PermissionsExt; let permissions = std::fs::metadata(&file).unwrap().permissions(); - std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o444)).unwrap(); + let mut read_only = permissions.clone(); + read_only.set_readonly(true); + std::fs::set_permissions(&file, read_only).unwrap(); let after = edited(&before, "Offline "); let id = typed(§ion, space, &before, 0..0, "Offline "); wait(§ion, |event| matches!(event, Event::Unreachable(_))); @@ -721,9 +722,10 @@ fn a_conflicting_save_keeps_the_native_page_and_a_conflict_page_the_session_dele let space = section.pages().unwrap()[0].0; let before = section.page(space).unwrap(); let text = first_text(&before); - use std::os::unix::fs::PermissionsExt; let permissions = std::fs::metadata(&file).unwrap().permissions(); - std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o444)).unwrap(); + let mut read_only = permissions.clone(); + read_only.set_readonly(true); + std::fs::set_permissions(&file, read_only).unwrap(); let mut local = before.clone(); model_ops::replace_text(&mut local, text, 0..8, "Local"); let id = typed(§ion, space, &before, 0..8, "Local"); diff --git a/crates/notebook/tests/structure.rs b/crates/notebook/tests/structure.rs index 36c5b95670685c49fb69983c1abcc14c37af5e3b..8ccfc51d95f6ac3df93cec7afe1e3e61e4f5966f 100644 --- a/crates/notebook/tests/structure.rs +++ b/crates/notebook/tests/structure.rs @@ -260,7 +260,6 @@ fn file_id(path: &std::path::Path) -> [u8; 16] { #[test] fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadable_one() { - use std::os::unix::fs::PermissionsExt; let temporary = tempfile::tempdir().unwrap(); let root = temporary.path().join("notebook"); let bin = root.join("OneNote_RecycleBin"); @@ -318,19 +317,22 @@ fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadabl if let Some(directory) = std::env::var_os("NOTEBOOK_RECYCLE_EXPORT") { copy_dir(&root, std::path::Path::new(&directory)); } - // Permission bits do not bind the superuser. - if unsafe { libc::geteuid() } == 0 { - return; - } - // A bin that cannot be read is neither replaced nor added to. - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap(); - let denied = notebook.refresh().and_then(|_| recycle(&mut notebook)); - std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); - assert!(denied.is_err()); - let names: Vec<_> = std::fs::read_dir(&bin) - .unwrap() - .map(|entry| entry.unwrap().file_name().into_string().unwrap()) - .collect(); - assert_eq!(names.len(), 2, "{names:?}"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Permission bits do not bind the superuser. + if unsafe { libc::geteuid() } == 0 { + return; + } + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap(); + let denied = notebook.refresh().and_then(|_| recycle(&mut notebook)); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(denied.is_err()); + let names: Vec<_> = std::fs::read_dir(&bin) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect(); + assert_eq!(names.len(), 2, "{names:?}"); + } } diff --git a/crates/snowbound/src/library.rs b/crates/snowbound/src/library.rs index 00f39b9e5e96855780cf67edf1169f6f6335dcdd..bdf08a0afa95e61cdf10fa4568d1d1fece4dc009 100644 --- a/crates/snowbound/src/library.rs +++ b/crates/snowbound/src/library.rs @@ -1060,10 +1060,8 @@ mod tests { ); assert_eq!(shown("", "Group/New Section 1.one"), under); assert_eq!(shown("notes/lab", "elsewhere/New Section 1.one"), None); - assert_eq!( - shown("notes/lab", "/tmp/Section.one"), - Some(PathBuf::from("/tmp/Section.one")) - ); + let elsewhere = std::env::temp_dir().join("Section.one"); + assert_eq!(library("notes/lab").local(&elsewhere), Some(elsewhere)); } #[test] diff --git a/crates/snowbound/src/linux.rs b/crates/snowbound/src/linux.rs index a5531d423c2597dbd6ad1dd118fd398cf30f8a4f..360cc5a4873837bc1936f5d733adbbf30b1d15cc 100644 --- a/crates/snowbound/src/linux.rs +++ b/crates/snowbound/src/linux.rs @@ -253,6 +253,11 @@ fn desktop() -> Desktop { }) } +/// The theme over the window's backdrop, as the kit draws it. +pub fn over_backdrop(theme: ui::Theme) -> ui::Theme { + theme.over_backdrop() +} + /// Menus in `appearance` as the desktop draws its own: libadwaita's popover menus on GNOME /// and Breeze's on KDE, each moving as that desktop moves them. pub fn menu(appearance: Theme) -> Option { diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs index 58fab3643cba5284e64fb749cdf036437103f56f..442f85bf7c407779a7e54e45920f8a1b3898372d 100644 --- a/crates/snowbound/src/macos.rs +++ b/crates/snowbound/src/macos.rs @@ -246,6 +246,11 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) { } } +/// The theme over the window's backdrop, as the kit draws it. +pub fn over_backdrop(theme: ui::Theme) -> ui::Theme { + theme.over_backdrop() +} + /// None: the kit's own menus stand in for AppKit's. pub fn menu(_: winit::window::Theme) -> Option { None diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs index 9a57260a1271fd71e983715162da952e72872338..684f1eafae212644f3c8891d1378c8e3885a5a98 100644 --- a/crates/snowbound/src/main.rs +++ b/crates/snowbound/src/main.rs @@ -876,7 +876,9 @@ impl State { theme(appearance, stored.light_pages, backdrop), platform::double_click_interval(), ); - let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]); + let titlebar = platform::titlebar(appearance) + .filter(|_| !backdrop) + .unwrap_or([ui.theme.strip; 2]); // A window shown but never focused hears no focus event; a hidden one draws as focused. ui.window_focused = !visible || window.has_focus(); platform::system_interface(&mut ui); @@ -1118,7 +1120,9 @@ impl State { /// Takes `appearance`'s colours. fn set_appearance(&mut self, appearance: winit::window::Theme) { self.ui.theme = theme(appearance, self.light_pages, self.surface.translucent()); - self.titlebar = platform::titlebar(appearance).unwrap_or([self.ui.theme.strip; 2]); + self.titlebar = platform::titlebar(appearance) + .filter(|_| !self.surface.translucent()) + .unwrap_or([self.ui.theme.strip; 2]); } /// Takes the colour scheme chosen, or the system's where it follows the system. @@ -3702,7 +3706,7 @@ fn theme(appearance: winit::window::Theme, light_pages: bool, backdrop: bool) -> [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink]; } if backdrop { - theme.over_backdrop() + platform::over_backdrop(theme) } else { theme } diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs index a81d5127a579f80a956427fbfd7adcfd0bb4382c..10584d7bf1a77f1b80533ff7a793f0120e7c1a60 100644 --- a/crates/snowbound/src/sidebar.rs +++ b/crates/snowbound/src/sidebar.rs @@ -584,7 +584,7 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) { flags: Flags::CLICKABLE, size: [fill(), px(ROW)], fill: lit, - hover_fill: Some(ui::mix(lit.unwrap_or(theme.strip), theme.hover(), 0.6)), + hover_fill: Some(ui::mix(lit.unwrap_or(theme.sidebar), theme.hover(), 0.6)), radius: 4.0, pad: [6.0 + INDENT * row.depth as f32, 0.0], // The field's text stands where the label did. @@ -681,7 +681,7 @@ impl crate::State { Spec { flags: Flags::CLIP, size: [px(width), fill()], - fill: Some(theme.strip), + fill: Some(theme.sidebar), ..Spec::default() }, ); diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs index 8580a477902d3429c7352fe8fd6cae3a5efade1b..d5ea3965edb7bd32ff1ed0018289df6645702a47 100644 --- a/crates/snowbound/src/windows.rs +++ b/crates/snowbound/src/windows.rs @@ -234,6 +234,12 @@ unsafe extern "system" fn frame_procedure( match message { // Accent colours and transparency effects reach the window only as settings. wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => { + if message == wm::WM_SETTINGCHANGE && setting(lparam) == "ImmersiveColorSet" { + refresh_color_policy(); + if ACRYLIC.load(Ordering::Relaxed) { + accent(hwnd, Accent::Acrylic); + } + } if let Some(proxy) = QUIT.get() { let _ = proxy.send_event(crate::UserEvent::Appearance); } @@ -252,6 +258,33 @@ unsafe extern "system" fn frame_procedure( result.unwrap_or_else(|| previous(message, wparam, lparam)) } +/// The setting a `WM_SETTINGCHANGE` names, if any. +fn setting(lparam: LPARAM) -> String { + if lparam == 0 { + return String::new(); + } + let text = lparam as *const u16; + let length = (0..).take_while(|&i| unsafe { *text.add(i) } != 0).count(); + String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, length) }) +} + +/// Has uxtheme read the apps' colour mode again: it answers winit's `ShouldAppsUseDarkMode` +/// from a cache that a change of mode otherwise leaves stale. +fn refresh_color_policy() { + // RefreshImmersiveColorPolicyState is exported by ordinal alone, and 104 only from 1809. + if version() < (10, 0, 17763) { + return; + } + let refresh = unsafe { + let module = LoadLibraryW(wide("uxtheme.dll").as_ptr()); + GetProcAddress(module, 104 as *const u8) + }; + if let Some(refresh) = refresh { + let refresh: unsafe extern "system" fn() = unsafe { std::mem::transmute(refresh) }; + unsafe { refresh() }; + } +} + type Procedure<'a> = &'a dyn Fn(u32, WPARAM, LPARAM) -> LRESULT; /// The frame over glass or Mica: the system's frame keeps its sides and bottom, the caption @@ -383,7 +416,7 @@ fn drawn_frame( None } wm::WM_EXITSIZEMOVE if ACRYLIC.load(Ordering::Relaxed) => { - accent(hwnd, Accent::Acrylic(ACRYLIC_TINT.load(Ordering::Relaxed))); + accent(hwnd, Accent::Acrylic); None } _ => None, @@ -409,15 +442,13 @@ pub fn cuts_corners() -> bool { false } -/// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur, -/// and the tint it shows it with. +/// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur. static ACRYLIC: AtomicBool = AtomicBool::new(false); -static ACRYLIC_TINT: AtomicU32 = AtomicU32::new(0); enum Accent { Blur, - /// Acrylic tinted with an `0xAABBGGRR` colour. - Acrylic(u32), + /// Acrylic in the shell's own tints: Windows 10's light and dark flyouts. + Acrylic, } /// Windows 10's accent under the window, through the undocumented @@ -448,10 +479,15 @@ fn accent(hwnd: HWND, accent: Accent) -> bool { tint: 0, animation: 0, }, - Accent::Acrylic(tint) => Policy { + Accent::Acrylic => Policy { state: 4, flags: 2, - tint, + // 0xAABBGGRR, after the apps' colour mode. + tint: if registry_dword(PERSONALIZE, "AppsUseLightTheme") == Some(0) { + 0xcc20_2020 + } else { + 0xccf3_f3f3 + }, animation: 0, }, }; @@ -466,12 +502,11 @@ fn accent(hwnd: HWND, accent: Accent) -> bool { /// Whether Personalization's transparency effects are on, as acrylic and Mica need. fn transparency() -> bool { - registry_dword( - r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", - "EnableTransparency", - ) != Some(0) + registry_dword(PERSONALIZE, "EnableTransparency") != Some(0) } +const PERSONALIZE: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"; + /// Lays the system's material under the window, which shows through the app's transparent /// pixels: Aero glass on Windows 7 while the desktop composes, acrylic on Windows 10 (blur /// before its April 2018 update) and Mica on 11. Windows 8 has none. @@ -504,11 +539,7 @@ pub fn install_backdrop(window: &Window) -> bool { } } Caption::Drawn if build >= 17134 => { - // The shell's own acrylic tints: Windows 10's light and dark flyouts. - let dark = appearance(window) == Theme::Dark; - let tint = if dark { 0xcc20_2020 } else { 0xccf3_f3f3 }; - ACRYLIC_TINT.store(tint, Ordering::Relaxed); - let shown = accent(hwnd, Accent::Acrylic(tint)); + let shown = accent(hwnd, Accent::Acrylic); ACRYLIC.store(shown, Ordering::Relaxed); shown } @@ -543,6 +574,23 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> { }) } +/// The theme over the window's material. Over Windows 7's glass, fields and tool buttons +/// take white faces, as Internet Explorer's do there, so their text and icons stay legible, +/// and the sidebar stays opaque. +pub fn over_backdrop(theme: ui::Theme) -> ui::Theme { + let sidebar = theme.sidebar; + let theme = theme.over_backdrop(); + if caption() != Caption::Glass || eleven() { + return theme; + } + ui::Theme { + sidebar, + base: [1.0, 1.0, 1.0, 0.8], + tool: [1.0, 1.0, 1.0, 0.35], + ..theme + } +} + /// None: the kit's own menus. pub fn menu(_: Theme) -> Option { None diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index cfff2c6229dd8d345ea306aa15384966c8424699..24bf0eaca67761237db02018b67d05ad2a0d60f1 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -270,7 +270,7 @@ pub fn tool_button( size: [px(TOOL), px(TOOL)], icon: Some(icon), color: Some(tint), - fill: (on == Some(true)).then_some(hover), + fill: (on == Some(true)).then_some(hover).or(rest(&ui.theme)), hover_fill: Some(hover), radius: 4.0, center: true, @@ -283,6 +283,11 @@ pub fn tool_button( ui.signal(id) } +/// A tool button's face at rest, where the theme gives it one. +fn rest(theme: &crate::Theme) -> Option<[f32; 4]> { + (theme.tool[3] > 0.0).then_some(theme.tool) +} + /// Shows button `id` as a toggle, pressed or not, where `on` is given. fn press_state(ui: &mut Ui, id: Id, on: Option) { if let Some(on) = on @@ -404,7 +409,7 @@ pub fn split_button( Spec { flags: Flags::FLOAT, size: [px(TOOL + RADIUS), px(TOOL)], - fill: fade(lit), + fill: fade(lit).or(rest(&ui.theme)), radius: RADIUS, ..Spec::default() }, @@ -476,7 +481,9 @@ pub fn menu_button( Spec { flags: Flags::CLICKABLE, size: [px(TOOL + ARROW), px(TOOL)], - fill: (ui.popup_open(menu) || on == Some(true)).then(|| theme.hover()), + fill: (ui.popup_open(menu) || on == Some(true)) + .then(|| theme.hover()) + .or(rest(&theme)), hover_fill: Some(theme.hover()), radius: 4.0, role: Some(Role::Button), diff --git a/crates/ui/src/theme.rs b/crates/ui/src/theme.rs index 9433a3ea6c95e407a38fbbc0734a9e5e57974ee2..c4e0c0daf65aac4ad16986b247cc8d7acc977182 100644 --- a/crates/ui/src/theme.rs +++ b/crates/ui/src/theme.rs @@ -8,9 +8,13 @@ pub struct Theme { pub base: [f32; 4], /// Side panels. pub panel: [f32; 4], - /// The window's title bar, toolbar, tab row and sidebar; transparent where the system's - /// backdrop shows through them. + /// The window's title bar, toolbar and tab row; transparent where the system's backdrop + /// shows through them. pub strip: [f32; 4], + /// The notebook sidebar: the strip's colour, unless kept opaque over a backdrop. + pub sidebar: [f32; 4], + /// Toolbar buttons at rest: clear, or a face where the backdrop would hide their icons. + pub tool: [f32; 4], pub accent: [f32; 4], /// The text caret, and selected text's fill with and without keyboard focus; the /// platform's own where it has them. @@ -139,6 +143,8 @@ impl Theme { base: srgb(0x19, 0x1b, 0x1c), panel: srgb(0x1f, 0x22, 0x23), strip: srgb(0x27, 0x2a, 0x2b), + sidebar: srgb(0x27, 0x2a, 0x2b), + tool: [0.0; 4], accent: srgb(0x00, 0x79, 0xa6), caret, selection, @@ -168,6 +174,8 @@ impl Theme { base: srgb(0xfc, 0xfc, 0xfd), panel: srgb(0xf4, 0xf5, 0xf7), strip: srgb(0xeb, 0xed, 0xf0), + sidebar: srgb(0xeb, 0xed, 0xf0), + tool: [0.0; 4], accent: srgb(0x00, 0x79, 0xa6), caret, selection, @@ -196,6 +204,7 @@ impl Theme { let text = |alpha| [self.text[0], self.text[1], self.text[2], alpha]; Self { strip: [0.0; 4], + sidebar: [0.0; 4], text_dim: text(0.5), chip: text(0.1), base: text(0.05), -- 2.54.0