authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 13:02:51-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-09-30 13:36:50-07:00
log52d68a63f8878930b90adbbf4dcdb3753addef12
treed378e222fa69a760d4e07df8d12e0cf401ec903f
parent3c0f71aec0443df1823c3d81c1f020949bbb169e
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

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

12 files changed, 139 insertions(+), 53 deletions(-)

arc/platforms.md+6-3
......@@ -114,9 +114,12 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
114114 them over the glass; 11 doesn't over the Direct3D surface, so the row
115115 draws them as 11 does, lit where the system reports the pointer. On 8
116116 and 10, whose frames are opaque, the window has no system frame and the
117 row draws and runs caption buttons as 10 does, over acrylic on 10. With
118 Windows 7's basic or classic theme the system draws the title bar and
119 the row lies beneath it, as on KDE.
117 row draws and runs caption buttons as 10 does, over acrylic on 10. Over
118 7's glass the sidebar stays opaque, and fields and tool buttons take white
119 faces, as Internet Explorer's do there. The theme and material follow the
120 system's colour mode as it changes. With Windows 7's basic or classic
121 theme the system draws the title bar and the row lies beneath it, as on
122 KDE.
120123- A notebook on a share opens by its UNC path through Windows' own SMB
121124 client, which takes OneNote's opens and locks natively: `onestore` opens a
122125 section as OneNote does (a reader shares it with everyone, a writer denies
crates/notebook/tests/discovery.rs+3
......@@ -463,8 +463,10 @@ fn local_access_stays_inside_the_selected_root() {
463463}
464464
465465/// Restores permissions a test took away, so the temporary directory can be removed.
466#[cfg(unix)]
466467struct Restore(Vec<(std::path::PathBuf, u32)>);
467468
469#[cfg(unix)]
468470impl Drop for Restore {
469471 fn drop(&mut self) {
470472 use std::os::unix::fs::PermissionsExt;
......@@ -474,6 +476,7 @@ impl Drop for Restore {
474476 }
475477}
476478
479#[cfg(unix)]
477480#[test]
478481fn unreadable_children_are_listed_as_unavailable_and_retried() {
479482 use std::os::unix::fs::PermissionsExt;
crates/notebook/tests/session.rs+6-4
......@@ -196,9 +196,10 @@ fn saves_wait_for_an_unreachable_file_and_publish_after_relaunch() {
196196 let (section, _) = open(&file, &cache);
197197 let space = section.pages().unwrap()[0].0;
198198 let before = section.page(space).unwrap();
199 use std::os::unix::fs::PermissionsExt;
200199 let permissions = std::fs::metadata(&file).unwrap().permissions();
201 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o444)).unwrap();
200 let mut read_only = permissions.clone();
201 read_only.set_readonly(true);
202 std::fs::set_permissions(&file, read_only).unwrap();
202203 let after = edited(&before, "Offline ");
203204 let id = typed(&section, space, &before, 0..0, "Offline ");
204205 wait(&section, |event| matches!(event, Event::Unreachable(_)));
......@@ -721,9 +722,10 @@ fn a_conflicting_save_keeps_the_native_page_and_a_conflict_page_the_session_dele
721722 let space = section.pages().unwrap()[0].0;
722723 let before = section.page(space).unwrap();
723724 let text = first_text(&before);
724 use std::os::unix::fs::PermissionsExt;
725725 let permissions = std::fs::metadata(&file).unwrap().permissions();
726 std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o444)).unwrap();
726 let mut read_only = permissions.clone();
727 read_only.set_readonly(true);
728 std::fs::set_permissions(&file, read_only).unwrap();
727729 let mut local = before.clone();
728730 model_ops::replace_text(&mut local, text, 0..8, "Local");
729731 let id = typed(&section, space, &before, 0..8, "Local");
crates/notebook/tests/structure.rs+17-15
......@@ -260,7 +260,6 @@ fn file_id(path: &std::path::Path) -> [u8; 16] {
260260
261261#[test]
262262fn a_page_delete_lists_the_recycle_bin_it_finds_or_makes_and_leaves_an_unreadable_one() {
263 use std::os::unix::fs::PermissionsExt;
264263 let temporary = tempfile::tempdir().unwrap();
265264 let root = temporary.path().join("notebook");
266265 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
318317 if let Some(directory) = std::env::var_os("NOTEBOOK_RECYCLE_EXPORT") {
319318 copy_dir(&root, std::path::Path::new(&directory));
320319 }
321 // Permission bits do not bind the superuser.
322 if unsafe { libc::geteuid() } == 0 {
323 return;
324 }
325
326320 // A bin that cannot be read is neither replaced nor added to.
327 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap();
328 let denied = notebook.refresh().and_then(|_| recycle(&mut notebook));
329 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
330 assert!(denied.is_err());
331 let names: Vec<_> = std::fs::read_dir(&bin)
332 .unwrap()
333 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
334 .collect();
335 assert_eq!(names.len(), 2, "{names:?}");
321 #[cfg(unix)]
322 {
323 use std::os::unix::fs::PermissionsExt;
324 // Permission bits do not bind the superuser.
325 if unsafe { libc::geteuid() } == 0 {
326 return;
327 }
328 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o000)).unwrap();
329 let denied = notebook.refresh().and_then(|_| recycle(&mut notebook));
330 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
331 assert!(denied.is_err());
332 let names: Vec<_> = std::fs::read_dir(&bin)
333 .unwrap()
334 .map(|entry| entry.unwrap().file_name().into_string().unwrap())
335 .collect();
336 assert_eq!(names.len(), 2, "{names:?}");
337 }
336338}
crates/snowbound/src/library.rs+2-4
......@@ -1060,10 +1060,8 @@ mod tests {
10601060 );
10611061 assert_eq!(shown("", "Group/New Section 1.one"), under);
10621062 assert_eq!(shown("notes/lab", "elsewhere/New Section 1.one"), None);
1063 assert_eq!(
1064 shown("notes/lab", "/tmp/Section.one"),
1065 Some(PathBuf::from("/tmp/Section.one"))
1066 );
1063 let elsewhere = std::env::temp_dir().join("Section.one");
1064 assert_eq!(library("notes/lab").local(&elsewhere), Some(elsewhere));
10671065 }
10681066
10691067 #[test]
crates/snowbound/src/linux.rs+5
......@@ -253,6 +253,11 @@ fn desktop() -> Desktop {
253253 })
254254}
255255
256/// The theme over the window's backdrop, as the kit draws it.
257pub fn over_backdrop(theme: ui::Theme) -> ui::Theme {
258 theme.over_backdrop()
259}
260
256261/// Menus in `appearance` as the desktop draws its own: libadwaita's popover menus on GNOME
257262/// and Breeze's on KDE, each moving as that desktop moves them.
258263pub fn menu(appearance: Theme) -> Option<ui::Menu> {
crates/snowbound/src/macos.rs+5
......@@ -246,6 +246,11 @@ pub fn represent(window: &Window, file: Option<&std::path::Path>) {
246246 }
247247}
248248
249/// The theme over the window's backdrop, as the kit draws it.
250pub fn over_backdrop(theme: ui::Theme) -> ui::Theme {
251 theme.over_backdrop()
252}
253
249254/// None: the kit's own menus stand in for AppKit's.
250255pub fn menu(_: winit::window::Theme) -> Option<ui::Menu> {
251256 None
crates/snowbound/src/main.rs+7-3
......@@ -876,7 +876,9 @@ impl State {
876876 theme(appearance, stored.light_pages, backdrop),
877877 platform::double_click_interval(),
878878 );
879 let titlebar = platform::titlebar(appearance).unwrap_or([ui.theme.strip; 2]);
879 let titlebar = platform::titlebar(appearance)
880 .filter(|_| !backdrop)
881 .unwrap_or([ui.theme.strip; 2]);
880882 // A window shown but never focused hears no focus event; a hidden one draws as focused.
881883 ui.window_focused = !visible || window.has_focus();
882884 platform::system_interface(&mut ui);
......@@ -1118,7 +1120,9 @@ impl State {
11181120 /// Takes `appearance`'s colours.
11191121 fn set_appearance(&mut self, appearance: winit::window::Theme) {
11201122 self.ui.theme = theme(appearance, self.light_pages, self.surface.translucent());
1121 self.titlebar = platform::titlebar(appearance).unwrap_or([self.ui.theme.strip; 2]);
1123 self.titlebar = platform::titlebar(appearance)
1124 .filter(|_| !self.surface.translucent())
1125 .unwrap_or([self.ui.theme.strip; 2]);
11221126 }
11231127
11241128 /// 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) ->
37023706 [theme.paper, theme.paper_ink] = [light.paper, light.paper_ink];
37033707 }
37043708 if backdrop {
3705 theme.over_backdrop()
3709 platform::over_backdrop(theme)
37063710 } else {
37073711 theme
37083712 }
crates/snowbound/src/sidebar.rs+2-2
......@@ -584,7 +584,7 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
584584 flags: Flags::CLICKABLE,
585585 size: [fill(), px(ROW)],
586586 fill: lit,
587 hover_fill: Some(ui::mix(lit.unwrap_or(theme.strip), theme.hover(), 0.6)),
587 hover_fill: Some(ui::mix(lit.unwrap_or(theme.sidebar), theme.hover(), 0.6)),
588588 radius: 4.0,
589589 pad: [6.0 + INDENT * row.depth as f32, 0.0],
590590 // The field's text stands where the label did.
......@@ -681,7 +681,7 @@ impl crate::State {
681681 Spec {
682682 flags: Flags::CLIP,
683683 size: [px(width), fill()],
684 fill: Some(theme.strip),
684 fill: Some(theme.sidebar),
685685 ..Spec::default()
686686 },
687687 );
crates/snowbound/src/windows.rs+65-17
......@@ -234,6 +234,12 @@ unsafe extern "system" fn frame_procedure(
234234 match message {
235235 // Accent colours and transparency effects reach the window only as settings.
236236 wm::WM_SETTINGCHANGE | wm::WM_DWMCOLORIZATIONCOLORCHANGED => {
237 if message == wm::WM_SETTINGCHANGE && setting(lparam) == "ImmersiveColorSet" {
238 refresh_color_policy();
239 if ACRYLIC.load(Ordering::Relaxed) {
240 accent(hwnd, Accent::Acrylic);
241 }
242 }
237243 if let Some(proxy) = QUIT.get() {
238244 let _ = proxy.send_event(crate::UserEvent::Appearance);
239245 }
......@@ -252,6 +258,33 @@ unsafe extern "system" fn frame_procedure(
252258 result.unwrap_or_else(|| previous(message, wparam, lparam))
253259}
254260
261/// The setting a `WM_SETTINGCHANGE` names, if any.
262fn setting(lparam: LPARAM) -> String {
263 if lparam == 0 {
264 return String::new();
265 }
266 let text = lparam as *const u16;
267 let length = (0..).take_while(|&i| unsafe { *text.add(i) } != 0).count();
268 String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, length) })
269}
270
271/// Has uxtheme read the apps' colour mode again: it answers winit's `ShouldAppsUseDarkMode`
272/// from a cache that a change of mode otherwise leaves stale.
273fn refresh_color_policy() {
274 // RefreshImmersiveColorPolicyState is exported by ordinal alone, and 104 only from 1809.
275 if version() < (10, 0, 17763) {
276 return;
277 }
278 let refresh = unsafe {
279 let module = LoadLibraryW(wide("uxtheme.dll").as_ptr());
280 GetProcAddress(module, 104 as *const u8)
281 };
282 if let Some(refresh) = refresh {
283 let refresh: unsafe extern "system" fn() = unsafe { std::mem::transmute(refresh) };
284 unsafe { refresh() };
285 }
286}
287
255288type Procedure<'a> = &'a dyn Fn(u32, WPARAM, LPARAM) -> LRESULT;
256289
257290/// The frame over glass or Mica: the system's frame keeps its sides and bottom, the caption
......@@ -383,7 +416,7 @@ fn drawn_frame(
383416 None
384417 }
385418 wm::WM_EXITSIZEMOVE if ACRYLIC.load(Ordering::Relaxed) => {
386 accent(hwnd, Accent::Acrylic(ACRYLIC_TINT.load(Ordering::Relaxed)));
419 accent(hwnd, Accent::Acrylic);
387420 None
388421 }
389422 _ => None,
......@@ -409,15 +442,13 @@ pub fn cuts_corners() -> bool {
409442 false
410443}
411444
412/// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur,
413/// and the tint it shows it with.
445/// Whether Windows 10 shows acrylic under the window, which a drag swaps for plain blur.
414446static ACRYLIC: AtomicBool = AtomicBool::new(false);
415static ACRYLIC_TINT: AtomicU32 = AtomicU32::new(0);
416447
417448enum Accent {
418449 Blur,
419 /// Acrylic tinted with an `0xAABBGGRR` colour.
420 Acrylic(u32),
450 /// Acrylic in the shell's own tints: Windows 10's light and dark flyouts.
451 Acrylic,
421452}
422453
423454/// Windows 10's accent under the window, through the undocumented
......@@ -448,10 +479,15 @@ fn accent(hwnd: HWND, accent: Accent) -> bool {
448479 tint: 0,
449480 animation: 0,
450481 },
451 Accent::Acrylic(tint) => Policy {
482 Accent::Acrylic => Policy {
452483 state: 4,
453484 flags: 2,
454 tint,
485 // 0xAABBGGRR, after the apps' colour mode.
486 tint: if registry_dword(PERSONALIZE, "AppsUseLightTheme") == Some(0) {
487 0xcc20_2020
488 } else {
489 0xccf3_f3f3
490 },
455491 animation: 0,
456492 },
457493 };
......@@ -466,12 +502,11 @@ fn accent(hwnd: HWND, accent: Accent) -> bool {
466502
467503/// Whether Personalization's transparency effects are on, as acrylic and Mica need.
468504fn transparency() -> bool {
469 registry_dword(
470 r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
471 "EnableTransparency",
472 ) != Some(0)
505 registry_dword(PERSONALIZE, "EnableTransparency") != Some(0)
473506}
474507
508const PERSONALIZE: &str = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
509
475510/// Lays the system's material under the window, which shows through the app's transparent
476511/// pixels: Aero glass on Windows 7 while the desktop composes, acrylic on Windows 10 (blur
477512/// before its April 2018 update) and Mica on 11. Windows 8 has none.
......@@ -504,11 +539,7 @@ pub fn install_backdrop(window: &Window) -> bool {
504539 }
505540 }
506541 Caption::Drawn if build >= 17134 => {
507 // The shell's own acrylic tints: Windows 10's light and dark flyouts.
508 let dark = appearance(window) == Theme::Dark;
509 let tint = if dark { 0xcc20_2020 } else { 0xccf3_f3f3 };
510 ACRYLIC_TINT.store(tint, Ordering::Relaxed);
511 let shown = accent(hwnd, Accent::Acrylic(tint));
542 let shown = accent(hwnd, Accent::Acrylic);
512543 ACRYLIC.store(shown, Ordering::Relaxed);
513544 shown
514545 }
......@@ -543,6 +574,23 @@ pub fn titlebar(appearance: Theme) -> Option<[[f32; 4]; 2]> {
543574 })
544575}
545576
577/// The theme over the window's material. Over Windows 7's glass, fields and tool buttons
578/// take white faces, as Internet Explorer's do there, so their text and icons stay legible,
579/// and the sidebar stays opaque.
580pub fn over_backdrop(theme: ui::Theme) -> ui::Theme {
581 let sidebar = theme.sidebar;
582 let theme = theme.over_backdrop();
583 if caption() != Caption::Glass || eleven() {
584 return theme;
585 }
586 ui::Theme {
587 sidebar,
588 base: [1.0, 1.0, 1.0, 0.8],
589 tool: [1.0, 1.0, 1.0, 0.35],
590 ..theme
591 }
592}
593
546594/// None: the kit's own menus.
547595pub fn menu(_: Theme) -> Option<ui::Menu> {
548596 None
crates/ui/src/shell.rs+10-3
......@@ -270,7 +270,7 @@ pub fn tool_button(
270270 size: [px(TOOL), px(TOOL)],
271271 icon: Some(icon),
272272 color: Some(tint),
273 fill: (on == Some(true)).then_some(hover),
273 fill: (on == Some(true)).then_some(hover).or(rest(&ui.theme)),
274274 hover_fill: Some(hover),
275275 radius: 4.0,
276276 center: true,
......@@ -283,6 +283,11 @@ pub fn tool_button(
283283 ui.signal(id)
284284}
285285
286/// A tool button's face at rest, where the theme gives it one.
287fn rest(theme: &crate::Theme) -> Option<[f32; 4]> {
288 (theme.tool[3] > 0.0).then_some(theme.tool)
289}
290
286291/// Shows button `id` as a toggle, pressed or not, where `on` is given.
287292fn press_state(ui: &mut Ui, id: Id, on: Option<bool>) {
288293 if let Some(on) = on
......@@ -404,7 +409,7 @@ pub fn split_button(
404409 Spec {
405410 flags: Flags::FLOAT,
406411 size: [px(TOOL + RADIUS), px(TOOL)],
407 fill: fade(lit),
412 fill: fade(lit).or(rest(&ui.theme)),
408413 radius: RADIUS,
409414 ..Spec::default()
410415 },
......@@ -476,7 +481,9 @@ pub fn menu_button(
476481 Spec {
477482 flags: Flags::CLICKABLE,
478483 size: [px(TOOL + ARROW), px(TOOL)],
479 fill: (ui.popup_open(menu) || on == Some(true)).then(|| theme.hover()),
484 fill: (ui.popup_open(menu) || on == Some(true))
485 .then(|| theme.hover())
486 .or(rest(&theme)),
480487 hover_fill: Some(theme.hover()),
481488 radius: 4.0,
482489 role: Some(Role::Button),
crates/ui/src/theme.rs+11-2
......@@ -8,9 +8,13 @@ pub struct Theme {
88 pub base: [f32; 4],
99 /// Side panels.
1010 pub panel: [f32; 4],
11 /// The window's title bar, toolbar, tab row and sidebar; transparent where the system's
12 /// backdrop shows through them.
11 /// The window's title bar, toolbar and tab row; transparent where the system's backdrop
12 /// shows through them.
1313 pub strip: [f32; 4],
14 /// The notebook sidebar: the strip's colour, unless kept opaque over a backdrop.
15 pub sidebar: [f32; 4],
16 /// Toolbar buttons at rest: clear, or a face where the backdrop would hide their icons.
17 pub tool: [f32; 4],
1418 pub accent: [f32; 4],
1519 /// The text caret, and selected text's fill with and without keyboard focus; the
1620 /// platform's own where it has them.
......@@ -139,6 +143,8 @@ impl Theme {
139143 base: srgb(0x19, 0x1b, 0x1c),
140144 panel: srgb(0x1f, 0x22, 0x23),
141145 strip: srgb(0x27, 0x2a, 0x2b),
146 sidebar: srgb(0x27, 0x2a, 0x2b),
147 tool: [0.0; 4],
142148 accent: srgb(0x00, 0x79, 0xa6),
143149 caret,
144150 selection,
......@@ -168,6 +174,8 @@ impl Theme {
168174 base: srgb(0xfc, 0xfc, 0xfd),
169175 panel: srgb(0xf4, 0xf5, 0xf7),
170176 strip: srgb(0xeb, 0xed, 0xf0),
177 sidebar: srgb(0xeb, 0xed, 0xf0),
178 tool: [0.0; 4],
171179 accent: srgb(0x00, 0x79, 0xa6),
172180 caret,
173181 selection,
......@@ -196,6 +204,7 @@ impl Theme {
196204 let text = |alpha| [self.text[0], self.text[1], self.text[2], alpha];
197205 Self {
198206 strip: [0.0; 4],
207 sidebar: [0.0; 4],
199208 text_dim: text(0.5),
200209 chip: text(0.1),
201210 base: text(0.05),