From 65151622f3cf9507de824024a9d74b195c940f08 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 27 Oct 2026 11:08:18 +0000 Subject: [PATCH 1/4] Add a Panel theme choice with light and dark panels The keyboard, the mouse panel, Home and the action menus now follow a shared Panel theme in scanning Appearance: Match system, Light or Dark. Match system reads the main window's theme at startup and on every ThemeChanged event, and each tile carries its resolved appearance so the next tick redraws it. Light panels use a deeper shade of the scanner colour, with white drawn as dark grey, so highlights stay visible. Closes #1213 Co-Authored-By: Claude Fable 5.1 --- docs/panel-sizing.md | 6 + .../content/docs/using/scanning-settings.md | 1 + src-tauri/src/lib.rs | 14 ++ src-tauri/src/modifier_overlay_windows.rs | 27 ++- src-tauri/src/panel_theme.rs | 158 ++++++++++++++++++ src-tauri/src/point_workflow.rs | 6 + src-tauri/src/portable_settings.rs | 1 + src-tauri/src/scan_host.rs | 11 +- src-tauri/src/scan_keyboard.rs | 4 + src-tauri/src/scan_menu.rs | 4 + src-tauri/src/scan_mouse.rs | 4 + src-tauri/src/scan_panel.rs | 13 ++ src-tauri/src/scan_preferences.rs | 41 +++++ src-tauri/src/scan_tile.rs | 120 ++++++++++--- src-tauri/src/scanning.rs | 17 +- src/PointScan.test.tsx | 20 +++ src/scanning/preferences.ts | 6 +- src/settings/ScannerPreferences.tsx | 15 +- src/ui/app.css | 7 + 19 files changed, 434 insertions(+), 41 deletions(-) create mode 100644 src-tauri/src/panel_theme.rs diff --git a/docs/panel-sizing.md b/docs/panel-sizing.md index cccd6b40..f0ba27f9 100644 --- a/docs/panel-sizing.md +++ b/docs/panel-sizing.md @@ -17,4 +17,10 @@ $env:SWITCHIFY_PANEL_FIXTURES = Join-Path $PWD '.cache/panel-fixtures' cargo test --manifest-path src-tauri/Cargo.toml export_panel_size_fixtures -- --ignored --nocapture ``` +Each panel is exported twice: dark as `name-Size.png` and light as `name-Size-light.png`, so the Panel theme palettes can be checked side by side. + +```powershell +# Example: .cache/panel-fixtures/mouse-Medium.png and mouse-Medium-light.png +``` + Windows Small and Medium mouse and Medium keyboard fixtures were visually checked for labels, spacing, direction alignment and reserved prediction slots. macOS visual inspection is not available on this Windows machine; cross-platform compilation and tests are covered by CI. This change does not modify platform focus or click-through behavior. diff --git a/site/src/content/docs/using/scanning-settings.md b/site/src/content/docs/using/scanning-settings.md index 24053a3e..6925d3db 100644 --- a/site/src/content/docs/using/scanning-settings.md +++ b/site/src/content/docs/using/scanning-settings.md @@ -34,6 +34,7 @@ Choosing **Back** in a menu opens the menu above it afresh, scanning from the be - **Scanner colour**: red, green, blue, yellow or white. - **Highlight thickness**: thin, standard or thick. - **Panel size**: small, medium or large. +- **Panel theme**: match system, light or dark. This colours the keyboard, the mouse panel, Home and the action menus. **Match system** follows your computer's light or dark setting, and the panels change when it does. On light panels the scanner colour is drawn in a deeper shade so the highlight stays easy to see, and white becomes dark grey. - Panels can move away from the pointer. ## Different settings for menus and the keyboard diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 65b3dc3b..ad8d9d9b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ mod modifier_overlay; mod mouse_repeat; mod overlay; mod panel_position; +mod panel_theme; mod point_scan; mod point_scan_activation; mod point_scan_runtime; @@ -1896,6 +1897,12 @@ pub fn run() { .telemetry .start(&state.version, &state.capabilities.platform); } + if let Some(theme) = app + .get_webview_window("main") + .and_then(|window| window.theme().ok()) + { + panel_theme::set_system_light(theme == tauri::Theme::Light); + } app.manage(overlay::CursorOverlay::install( app.handle().clone(), overlay_shared.clone(), @@ -1951,6 +1958,13 @@ pub fn run() { Ok(()) }) .on_window_event(|window, event| { + // The panels follow the computer's light or dark setting through + // the main window, which reports the change even while hidden. + if let tauri::WindowEvent::ThemeChanged(theme) = event { + if window.label() == "main" { + panel_theme::set_system_light(*theme == tauri::Theme::Light); + } + } if window.label() == "main" && matches!(event, tauri::WindowEvent::Focused(false)) { window .app_handle() diff --git a/src-tauri/src/modifier_overlay_windows.rs b/src-tauri/src/modifier_overlay_windows.rs index 103f45fb..d37a53d7 100644 --- a/src-tauri/src/modifier_overlay_windows.rs +++ b/src-tauri/src/modifier_overlay_windows.rs @@ -412,6 +412,7 @@ fn present(window: HWND, labels: &[String], layout: &Layout) -> Result<(), Strin pixmap.data(), labels.iter().any(|label| label.contains('\n')), false, + [255, 255, 255], ) } @@ -474,6 +475,7 @@ fn present_pixmap_with_text( rgba: &[u8], wrap: bool, center_wrapped: bool, + text_rgb: [u8; 3], ) -> Result<(), String> { unsafe { let screen = ScreenDc(GetDC(None)); @@ -540,7 +542,12 @@ fn present_pixmap_with_text( previous: SelectObject(memory.0, font.0), }; SetBkMode(memory.0, TRANSPARENT); - SetTextColor(memory.0, COLORREF(0x00ff_ffff)); + SetTextColor( + memory.0, + COLORREF( + u32::from(text_rgb[0]) | u32::from(text_rgb[1]) << 8 | u32::from(text_rgb[2]) << 16, + ), + ); for (label, chip) in labels.iter().zip(&layout.chips) { if label.is_empty() { continue; @@ -673,6 +680,7 @@ pub(crate) fn present_scan_tile( pixmap.data(), true, true, + crate::scan_tile::text_rgb(tile), ) } @@ -728,6 +736,7 @@ mod tests { &pixels, wrapped, wrapped, + [255, 255, 255], ); assert!(result.is_err()); } @@ -888,7 +897,7 @@ mod tests { fn export_panel_size_fixtures() { use crate::{ scan_panel::Dock, - scan_preferences::{PanelSize, Resolved}, + scan_preferences::{PanelSize, PanelTheme, Resolved}, scanning::{Rect, ScannerColor}, }; let directory = @@ -900,10 +909,14 @@ mod tests { width: 1920.0, height: 1080.0, }; - for size in [PanelSize::Small, PanelSize::Medium, PanelSize::Large] { + let presets = [PanelSize::Small, PanelSize::Medium, PanelSize::Large] + .into_iter() + .flat_map(|size| [(size, PanelTheme::Dark), (size, PanelTheme::Light)]); + for (size, theme) in presets { let mut mouse = crate::scan_mouse::MousePanel::new( Resolved { panel_size: size, + panel_theme: theme, ..Default::default() }, 2, @@ -913,6 +926,7 @@ mod tests { let mut keyboard = crate::scan_keyboard::Keyboard::new(false); keyboard.dock = mouse.dock; keyboard.set_panel_size(size); + keyboard.set_panel_theme(theme); keyboard.enable_predictions(true); let keyboard_frame = keyboard.frame(screen, 1.0, ScannerColor::default()); let mut page = |page| { @@ -951,9 +965,10 @@ mod tests { .borrow_mut() .take() .unwrap() - .save_png( - std::path::Path::new(&directory).join(format!("{name}-{size:?}.png")), - ) + .save_png(std::path::Path::new(&directory).join(match theme { + PanelTheme::Light => format!("{name}-{size:?}-light.png"), + _ => format!("{name}-{size:?}.png"), + })) .unwrap() }); } diff --git a/src-tauri/src/panel_theme.rs b/src-tauri/src/panel_theme.rs new file mode 100644 index 00000000..875eb54d --- /dev/null +++ b/src-tauri/src/panel_theme.rs @@ -0,0 +1,158 @@ +//! Light and dark colours for the native panels: the keyboard, the mouse +//! panel, Home and the action menus. Point scanning lines, pointer markers +//! and the floating prompt keep their own colours. +use crate::scanning::ScannerColor; +use serde::{Deserialize, Serialize}; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// Whether the computer is using a light appearance. Set from the main +/// window's theme at startup and whenever the system setting changes; +/// read when a panel frame is built, so panels follow the change on their +/// next tick without being restarted. +static SYSTEM_LIGHT: AtomicBool = AtomicBool::new(false); + +pub fn set_system_light(light: bool) { + SYSTEM_LIGHT.store(light, Ordering::Relaxed); +} +pub fn system_light() -> bool { + SYSTEM_LIGHT.load(Ordering::Relaxed) +} + +/// The saved choice. `System` follows the computer's light or dark setting. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PanelTheme { + #[default] + System, + Light, + Dark, +} +impl PanelTheme { + pub fn appearance(self) -> Appearance { + self.appearance_when(system_light()) + } + /// Pure, so tests never have to touch the shared system flag. + pub fn appearance_when(self, system_light: bool) -> Appearance { + match self { + Self::Light => Appearance::Light, + Self::Dark => Appearance::Dark, + Self::System if system_light => Appearance::Light, + Self::System => Appearance::Dark, + } + } +} + +/// What a panel is drawn with once the system setting has been consulted. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum Appearance { + #[default] + Dark, + Light, +} +impl Appearance { + pub fn palette(self) -> Palette { + match self { + Self::Dark => Palette { + panel: [20, 24, 32], + band: [30, 36, 48], + character: [43, 51, 66], + utility: [34, 41, 54], + menu_bar: [52, 61, 79], + other: [25, 30, 40], + border: [62, 72, 89], + band_border: [74, 86, 108], + text: [255, 255, 255], + highlight_base: [30, 35, 46], + }, + Self::Light => Palette { + panel: [240, 242, 246], + band: [226, 230, 237], + character: [255, 255, 255], + utility: [236, 239, 244], + menu_bar: [214, 219, 228], + other: [244, 246, 249], + border: [196, 203, 214], + band_border: [176, 185, 200], + text: [20, 24, 32], + highlight_base: [255, 255, 255], + }, + } + } + /// The scanner colour as drawn on panels of this appearance. Light + /// panels use a deeper shade so every choice stays visible; white + /// becomes dark grey. + pub fn highlight(self, color: ScannerColor) -> [u8; 3] { + match self { + Self::Dark => color.rgb(), + Self::Light => match color { + ScannerColor::Red => [198, 40, 40], + ScannerColor::Green => [27, 140, 60], + ScannerColor::Blue => [25, 103, 210], + ScannerColor::Yellow => [214, 140, 0], + ScannerColor::White => [58, 64, 76], + }, + } + } + /// The fill of a highlighted key: the panel's base tinted with the highlight. + pub fn highlight_fill(self, color: ScannerColor) -> [u8; 3] { + let base = self.palette().highlight_base.map(u16::from); + let highlight = self.highlight(color).map(u16::from); + std::array::from_fn(|i| ((base[i] * 3 + highlight[i]) / 4) as u8) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Palette { + pub panel: [u8; 3], + /// The menu bar band along the top of every panel. + pub band: [u8; 3], + pub character: [u8; 3], + pub utility: [u8; 3], + pub menu_bar: [u8; 3], + pub other: [u8; 3], + pub border: [u8; 3], + pub band_border: [u8; 3], + pub text: [u8; 3], + highlight_base: [u8; 3], +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dark_highlights_are_the_scanner_colours_and_light_ones_are_deeper() { + for color in [ + ScannerColor::Red, + ScannerColor::Green, + ScannerColor::Blue, + ScannerColor::Yellow, + ScannerColor::White, + ] { + assert_eq!(Appearance::Dark.highlight(color), color.rgb()); + let [r, g, b] = color.rgb().map(u16::from); + assert_eq!( + Appearance::Dark.highlight_fill(color), + [(90 + r) / 4, (105 + g) / 4, (138 + b) / 4].map(|c| c as u8) + ); + let light = Appearance::Light.highlight(color); + let luma = + |[r, g, b]: [u8; 3]| u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114; + // Visible against a white key and darker than the dark-panel shade. + assert!(luma(light) < luma([200, 200, 200]), "{color:?}"); + assert!(luma(light) < luma(color.rgb()), "{color:?}"); + assert_ne!(Appearance::Light.highlight_fill(color), [255, 255, 255]); + } + } + + #[test] + fn the_chosen_theme_wins_and_system_follows_the_computer() { + assert_eq!(PanelTheme::Light.appearance(), Appearance::Light); + assert_eq!(PanelTheme::Dark.appearance(), Appearance::Dark); + assert_eq!(PanelTheme::default(), PanelTheme::System); + assert_eq!(PanelTheme::System.appearance_when(true), Appearance::Light); + assert_eq!(PanelTheme::System.appearance_when(false), Appearance::Dark); + assert_eq!(PanelTheme::Dark.appearance_when(true), Appearance::Dark); + assert_eq!(PanelTheme::Light.appearance_when(false), Appearance::Light); + } +} diff --git a/src-tauri/src/point_workflow.rs b/src-tauri/src/point_workflow.rs index cfffeebe..03346ee7 100644 --- a/src-tauri/src/point_workflow.rs +++ b/src-tauri/src/point_workflow.rs @@ -301,6 +301,12 @@ impl Workflow { .set_panel_size(self.point.config.mouse_scan.panel_size); self.menu .set_panel_size(self.point.config.menu_scan.panel_size); + self.keyboard + .set_panel_theme(self.point.config.keyboard_scan.panel_theme); + self.mouse + .set_panel_theme(self.point.config.mouse_scan.panel_theme); + self.menu + .set_panel_theme(self.point.config.menu_scan.panel_theme); self.keyboard.set_layout(self.point.config.keyboard_layout); self.menu diff --git a/src-tauri/src/portable_settings.rs b/src-tauri/src/portable_settings.rs index 941049ec..045ac59f 100644 --- a/src-tauri/src/portable_settings.rs +++ b/src-tauri/src/portable_settings.rs @@ -745,6 +745,7 @@ mod tests { "mouse", "nextScan", "panelSize", + "panelTheme", "passLimit", "pattern", "point", diff --git a/src-tauri/src/scan_host.rs b/src-tauri/src/scan_host.rs index faceaa56..c77effc3 100644 --- a/src-tauri/src/scan_host.rs +++ b/src-tauri/src/scan_host.rs @@ -312,6 +312,7 @@ mod platform { }, 1.0, Default::default(), + Default::default(), )) .unwrap(); // Reuse the prompt created before the opaque panel, including an @@ -344,6 +345,7 @@ mod platform { let key = |role| crate::scanning::FrameTile { thickness: Default::default(), color: Default::default(), + appearance: Default::default(), text: "Settings".into(), rect, scale: 1.0, @@ -913,7 +915,7 @@ mod platform { tile: &crate::scanning::FrameTile, frame: &Rect, ) -> Result<(), String> { - use objc2_app_kit::NSFont; + use objc2_app_kit::{NSColor, NSFont}; use objc2_foundation::NSString; let pixels = crate::scan_tile::bitmap(tile)?; let image = crate::overlay::platform::image_from_rgba_rect( @@ -936,6 +938,13 @@ mod platform { } let label = &view.label; label.setStringValue(&NSString::from_str(&tile.text)); + let [r, g, b] = tile.appearance.palette().text; + label.setTextColor(Some(&NSColor::colorWithSRGBRed_green_blue_alpha( + f64::from(r) / 255.0, + f64::from(g) / 255.0, + f64::from(b) / 255.0, + 1.0, + ))); label.setFont(Some(&NSFont::boldSystemFontOfSize( crate::scan_tile::keyboard_font_size(tile) * tile.scale, ))); diff --git a/src-tauri/src/scan_keyboard.rs b/src-tauri/src/scan_keyboard.rs index 3ebaae81..905a5d85 100644 --- a/src-tauri/src/scan_keyboard.rs +++ b/src-tauri/src/scan_keyboard.rs @@ -310,6 +310,9 @@ impl Keyboard { pub fn set_panel_size(&mut self, size: crate::scan_preferences::PanelSize) { self.scan.options.panel_size = size; } + pub fn set_panel_theme(&mut self, theme: crate::scan_preferences::PanelTheme) { + self.scan.options.panel_theme = theme; + } #[cfg(test)] pub fn new(mac: bool) -> Self { @@ -1119,6 +1122,7 @@ impl Keyboard { .then(|| self.scan.highlight(&self.rows)), row_scan: highlight_row, thickness: Default::default(), + appearance: self.scan.options.panel_theme.appearance(), } .frame(screen, units, color) } diff --git a/src-tauri/src/scan_menu.rs b/src-tauri/src/scan_menu.rs index b9d6ce4c..a848493d 100644 --- a/src-tauri/src/scan_menu.rs +++ b/src-tauri/src/scan_menu.rs @@ -127,6 +127,9 @@ impl Menu { pub fn set_panel_size(&mut self, size: crate::scan_preferences::PanelSize) { self.scan.options.panel_size = size; } + pub fn set_panel_theme(&mut self, theme: crate::scan_preferences::PanelTheme) { + self.scan.options.panel_theme = theme; + } #[cfg(test)] pub fn new(kind: Kind, period: u64) -> Self { @@ -216,6 +219,7 @@ impl Menu { row_scan: self.scan.row_scan() || self.scan.group_scan() || escaping, row_label: None, thickness: options.thickness, + appearance: options.panel_theme.appearance(), } .frame(screen, units, options.color) } diff --git a/src-tauri/src/scan_mouse.rs b/src-tauri/src/scan_mouse.rs index e53253aa..691df336 100644 --- a/src-tauri/src/scan_mouse.rs +++ b/src-tauri/src/scan_mouse.rs @@ -52,6 +52,9 @@ impl MousePanel { pub fn set_panel_size(&mut self, size: crate::scan_preferences::PanelSize) { self.scan.options.panel_size = size; } + pub fn set_panel_theme(&mut self, theme: crate::scan_preferences::PanelTheme) { + self.scan.options.panel_theme = theme; + } pub fn new(options: Resolved, displays: usize, speed_percent: u16) -> Self { let rows = Self::rows(false, false, displays); @@ -302,6 +305,7 @@ impl MousePanel { row_scan: highlight_row, row_label: None, thickness: self.scan.options.thickness, + appearance: self.scan.options.panel_theme.appearance(), } .frame(screen, units, color) } diff --git a/src-tauri/src/scan_panel.rs b/src-tauri/src/scan_panel.rs index 9bb42068..53a1ef78 100644 --- a/src-tauri/src/scan_panel.rs +++ b/src-tauri/src/scan_panel.rs @@ -1,6 +1,7 @@ //! Shared layout for switch-scanned panels: a docked background, a slim menu //! bar and weighted rows of keys. Panels supply content; geometry lives here. use crate::{ + panel_theme::Appearance, scan_items::ItemScanner, scan_preferences::{PanelSize, Thickness}, scanning::{ @@ -240,6 +241,7 @@ pub struct Panel { /// keys are all hidden; never a scan target. pub row_label: Option<(usize, String)>, pub thickness: Thickness, + pub appearance: Appearance, } impl Panel { @@ -281,6 +283,7 @@ impl Panel { }, scale, color, + self.appearance, )); let x = x + padding; let width = content_width; @@ -292,6 +295,7 @@ impl Panel { frame.tiles.push(FrameTile { thickness: self.thickness, color, + appearance: self.appearance, text: MENU_BAR_TITLE.into(), style: Some(TileStyle { role: TileRole::Title, @@ -310,6 +314,7 @@ impl Panel { frame.tiles.push(FrameTile { thickness: self.thickness, color, + appearance: self.appearance, text: SETTINGS_ITEM.into(), style: Some(TileStyle { role: TileRole::MenuBar, @@ -346,6 +351,7 @@ impl Panel { frame.tiles.push(FrameTile { thickness: self.thickness, color, + appearance: self.appearance, text: key.text.clone(), style: Some(TileStyle { role: key.role, @@ -371,6 +377,7 @@ impl Panel { frame.tiles.push(FrameTile { thickness: self.thickness, color, + appearance: self.appearance, text: text.clone(), style: Some(TileStyle { role: TileRole::Status, @@ -462,6 +469,7 @@ mod tests { row_scan: false, row_label: None, thickness: Thickness::Thick, + appearance: Appearance::Dark, } .frame( Rect { @@ -494,6 +502,7 @@ mod tests { row_scan: true, row_label: None, thickness: Thickness::Thick, + appearance: Appearance::Dark, }; let screen = Rect { x: -1920.0, @@ -562,6 +571,7 @@ mod tests { row_scan: true, row_label: None, thickness: Thickness::default(), + appearance: Appearance::Dark, }; let screen = Rect { x: 0.0, @@ -605,6 +615,7 @@ mod tests { row_scan: false, row_label: None, thickness: Thickness::default(), + appearance: Appearance::Dark, } .frame(screen, 1.0, ScannerColor::default()); let panel = frame.tiles[0].rect; @@ -762,6 +773,7 @@ mod tests { row_scan: false, row_label: None, thickness: Thickness::default(), + appearance: Appearance::Dark, } .frame(screen, 1.0, ScannerColor::default()); let key_offset = frame.tiles[1].rect.y - frame.tiles[0].rect.y; @@ -794,6 +806,7 @@ mod tests { row_scan: false, row_label: None, thickness: Thickness::default(), + appearance: Appearance::Dark, }; let rect = panel.frame(screen, 1.0, ScannerColor::default()).tiles[0].rect; let left = rect.x - screen.x; diff --git a/src-tauri/src/scan_preferences.rs b/src-tauri/src/scan_preferences.rs index c0f29f21..939fee75 100644 --- a/src-tauri/src/scan_preferences.rs +++ b/src-tauri/src/scan_preferences.rs @@ -1,3 +1,4 @@ +pub use crate::panel_theme::PanelTheme; use crate::scanning::ScannerColor; use serde::{Deserialize, Serialize}; @@ -100,6 +101,8 @@ pub struct Overrides { #[serde(rename_all = "camelCase", default)] pub struct Preferences { pub panel_size: PanelSize, + /// Shared by every panel, like `panel_size`. + pub panel_theme: PanelTheme, pub direction: Direction, pub pass_limit: usize, pub pattern: Pattern, @@ -116,6 +119,7 @@ impl Default for Preferences { fn default() -> Self { Self { panel_size: PanelSize::Medium, + panel_theme: PanelTheme::System, direction: Direction::Forward, pass_limit: 3, pattern: Pattern::Grouped, @@ -133,6 +137,7 @@ impl Default for Preferences { #[derive(Debug, Clone, Copy, PartialEq)] pub struct Resolved { pub panel_size: PanelSize, + pub panel_theme: PanelTheme, pub automatic: bool, pub interval_ms: u64, pub direction: Direction, @@ -174,6 +179,7 @@ impl Preferences { }; Resolved { panel_size: self.panel_size, + panel_theme: self.panel_theme, automatic: local.automatic.unwrap_or(automatic), interval_ms: local .interval_ms @@ -236,6 +242,7 @@ fn forget_unknown_choices(value: &mut serde_json::Value, optional: bool) { }; fields.retain(|name, choice| match name.as_str() { "panelSize" => !optional && PanelSize::deserialize(&*choice).is_ok(), + "panelTheme" => !optional && PanelTheme::deserialize(&*choice).is_ok(), "nextScan" => (optional && choice.is_null()) || NextScan::deserialize(&*choice).is_ok(), "startFrom" => (optional && choice.is_null()) || StartFrom::deserialize(&*choice).is_ok(), "pattern" => (optional && choice.is_null()) || Pattern::deserialize(&*choice).is_ok(), @@ -281,6 +288,40 @@ mod tests { } } + #[test] + fn panel_theme_defaults_to_system_and_is_shared_by_every_panel() { + for value in [ + serde_json::json!({}), + serde_json::json!({"panelTheme":"sepia", "direction":"reverse"}), + serde_json::json!({"panelTheme":null}), + ] { + let preferences = read(value.clone()); + assert_eq!(preferences.panel_theme, PanelTheme::System); + if value.get("direction").is_some() { + assert_eq!(preferences.direction, Direction::Reverse); + } + } + for theme in [PanelTheme::System, PanelTheme::Light, PanelTheme::Dark] { + let preferences = Preferences { + panel_theme: theme, + ..Default::default() + }; + let mut value = serde_json::to_value(&preferences).unwrap(); + assert!(value["panelTheme"].is_string()); + // An area never has a theme of its own. + value["keyboard"] = serde_json::json!({"panelTheme":"light"}); + let restored = read(value); + for area in [Area::Point, Area::Menu, Area::Mouse, Area::Keyboard] { + assert_eq!( + restored + .resolve(area, false, 1000, ScannerColor::Blue) + .panel_theme, + theme + ); + } + } + } + #[test] fn after_selection_choices_default_to_standard_and_round_trip() { let saved = read(serde_json::json!({"direction":"reverse"})); diff --git a/src-tauri/src/scan_tile.rs b/src-tauri/src/scan_tile.rs index e06be4d5..46cd6772 100644 --- a/src-tauri/src/scan_tile.rs +++ b/src-tauri/src/scan_tile.rs @@ -2,12 +2,16 @@ use crate::scanning::FrameTile; use tiny_skia::{Color, FillRule, Paint, PathBuilder, Pixmap, Stroke, Transform}; -/// The colour of the menu bar band along the top of every panel. -const BAND: [u8; 3] = [30, 36, 48]; +/// The colour of a tile's label, for the platform text painters. +pub fn text_rgb(tile: &FrameTile) -> [u8; 3] { + tile.appearance.palette().text +} pub fn bitmap(tile: &FrameTile) -> Result { use crate::scanning::TileRole; let style = tile.style; + let palette = tile.appearance.palette(); + let rgb = |[r, g, b]: [u8; 3]| Color::from_rgba8(r, g, b, 255); if tile.is_panel_background() { let mut bitmap = Pixmap::new( tile.rect.width.round().max(1.0) as u32, @@ -15,7 +19,7 @@ pub fn bitmap(tile: &FrameTile) -> Result { ) .ok_or("Cannot allocate panel background")?; let mut paint = Paint::default(); - paint.set_color_rgba8(20, 24, 32, 255); + paint.set_color(rgb(palette.panel)); let path = key_outline( bitmap.width() as f32, bitmap.height() as f32, @@ -32,7 +36,7 @@ pub fn bitmap(tile: &FrameTile) -> Result { // The menu bar band along the top, inside the rounded corners. let band = (crate::scan_panel::MENU_BAR_HEIGHT * tile.scale) as f32; if band < bitmap.height() as f32 { - paint.set_color_rgba8(BAND[0], BAND[1], BAND[2], 255); + paint.set_color(rgb(palette.band)); let band = band_outline(bitmap.width() as f32, band, (18.0 * tile.scale) as f32); bitmap.fill_path( &band, @@ -51,23 +55,23 @@ pub fn bitmap(tile: &FrameTile) -> Result { .ok_or("Cannot allocate keyboard key")?; // The title sits flat on the band. if style.is_some_and(|s| s.role == TileRole::Title) { - bitmap.fill(Color::from_rgba8(BAND[0], BAND[1], BAND[2], 255)); + bitmap.fill(rgb(palette.band)); return Ok(bitmap); } let on_band = style.is_some_and(|s| s.role == TileRole::MenuBar); let base = match style.map(|s| s.role) { - Some(TileRole::Character) => [43, 51, 66], - Some(TileRole::Utility) => [34, 41, 54], - Some(TileRole::MenuBar) => [52, 61, 79], - _ => [25, 30, 40], + Some(TileRole::Character) => palette.character, + Some(TileRole::Utility) => palette.utility, + Some(TileRole::MenuBar) => palette.menu_bar, + _ => palette.other, }; bitmap.fill(if on_band { - Color::from_rgba8(BAND[0], BAND[1], BAND[2], 255) + rgb(palette.band) } else { - Color::from_rgba8(20, 24, 32, 255) + rgb(palette.panel) }); - let rgb = if tile.selected { - tile.color.menu_fill() + let fill = if tile.selected { + tile.appearance.highlight_fill(tile.color) } else { base }; @@ -87,7 +91,7 @@ pub fn bitmap(tile: &FrameTile) -> Result { let height = bitmap.height() as f32; let path = key_outline(width, height, inset.min(width.min(height) / 4.0), radius); let mut paint = Paint::default(); - paint.set_color_rgba8(rgb[0], rgb[1], rgb[2], 255); + paint.set_color(rgb(fill)); bitmap.fill_path( &path, &paint, @@ -95,14 +99,13 @@ pub fn bitmap(tile: &FrameTile) -> Result { Transform::identity(), None, ); - let [r, g, b] = if tile.selected { - tile.color.rgb() + paint.set_color(rgb(if tile.selected { + tile.appearance.highlight(tile.color) } else if on_band { - [74, 86, 108] + palette.band_border } else { - [62, 72, 89] - }; - paint.set_color_rgba8(r, g, b, 255); + palette.border + })); bitmap.stroke_path( &path, &paint, @@ -114,8 +117,7 @@ pub fn bitmap(tile: &FrameTile) -> Result { None, ); if style.is_some_and(|s| s.active) { - let [r, g, b] = tile.color.rgb(); - paint.set_color_rgba8(r, g, b, 255); + paint.set_color(rgb(tile.appearance.highlight(tile.color))); if let Some(rect) = tiny_skia::Rect::from_xywh( width * 0.35, height - 5.0 * tile.scale as f32, @@ -197,6 +199,7 @@ mod tests { }, scale: 1.0, selected: false, + appearance: Default::default(), }; assert_eq!( keyboard_font_size(&tile(TileRole::Utility, "\u{232B}")), @@ -244,10 +247,14 @@ mod tests { }, scale: 1.0, selected: true, + appearance: Default::default(), }; let image = bitmap(&tile).unwrap(); let pixel = ((28 * 176 + 88) * 4) as usize; - assert_eq!(&image.data()[pixel..pixel + 3], &color.menu_fill()); + assert_eq!( + &image.data()[pixel..pixel + 3], + &crate::panel_theme::Appearance::Dark.highlight_fill(color) + ); let previous = tile.clone(); tile.color = if color == Blue { Red } else { Blue }; assert_ne!(tile, previous); @@ -255,8 +262,73 @@ mod tests { tile.selected = false; assert_ne!( &bitmap(&tile).unwrap().data()[pixel..pixel + 3], - &color.menu_fill() + &crate::panel_theme::Appearance::Dark.highlight_fill(color) ); } } + + #[test] + fn light_panels_invert_the_palette_and_deepen_the_highlight() { + use crate::{ + panel_theme::Appearance, + scanning::{Rect, ScannerColor, TileRole, TileStyle}, + }; + let tile = |appearance, role, selected| FrameTile { + thickness: Default::default(), + style: Some(TileStyle { + role, + active: false, + row_scan: false, + }), + color: ScannerColor::White, + appearance, + text: "a".into(), + rect: Rect { + x: 0.0, + y: 0.0, + width: 64.0, + height: 56.0, + }, + scale: 1.0, + selected, + }; + let at = |image: &Pixmap, x: usize, y: usize| { + let pixel = (y * 64 + x) * 4; + [ + image.data()[pixel], + image.data()[pixel + 1], + image.data()[pixel + 2], + ] + }; + let luma = + |[r, g, b]: [u8; 3]| u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114; + // The panel behind the keys, and every key face, flips from dark to light. + let dark = bitmap(&tile(Appearance::Dark, TileRole::Panel, false)).unwrap(); + let light = bitmap(&tile(Appearance::Light, TileRole::Panel, false)).unwrap(); + assert!(luma(at(&light, 32, 40)) > luma(at(&dark, 32, 40))); + for role in [TileRole::Character, TileRole::Utility, TileRole::MenuBar] { + let dark = bitmap(&tile(Appearance::Dark, role, false)).unwrap(); + let light = bitmap(&tile(Appearance::Light, role, false)).unwrap(); + assert!(luma(at(&light, 32, 28)) > luma([200, 200, 200]), "{role:?}"); + assert!(luma(at(&dark, 32, 28)) < luma([80, 80, 80]), "{role:?}"); + // The surround outside the rounded key follows the panel too. + assert!(luma(at(&light, 0, 0)) > luma(at(&dark, 0, 0)), "{role:?}"); + assert_ne!(dark, light); + } + // A white highlight on a light key is a grey tint, never white on white. + let selected = bitmap(&tile(Appearance::Light, TileRole::Utility, true)).unwrap(); + assert_eq!( + at(&selected, 32, 28), + Appearance::Light.highlight_fill(ScannerColor::White) + ); + assert!(luma(at(&selected, 32, 28)) < luma([255, 255, 255])); + assert_eq!( + text_rgb(&tile(Appearance::Light, TileRole::Utility, false)), + [20, 24, 32] + ); + assert_eq!( + text_rgb(&tile(Appearance::Dark, TileRole::Utility, false)), + [255, 255, 255] + ); + } } diff --git a/src-tauri/src/scanning.rs b/src-tauri/src/scanning.rs index 0999d889..c4537c31 100644 --- a/src-tauri/src/scanning.rs +++ b/src-tauri/src/scanning.rs @@ -145,10 +145,6 @@ impl ScannerColor { Self::White => [255, 255, 255], } } - pub fn menu_fill(self) -> [u8; 3] { - let base = [30u16, 35, 46]; - std::array::from_fn(|i| ((base[i] * 3 + self.rgb()[i] as u16) / 4) as u8) - } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VisualRole { @@ -283,6 +279,9 @@ pub struct TileStyle { pub struct FrameTile { pub thickness: crate::scan_preferences::Thickness, pub color: ScannerColor, + /// Light or dark, already resolved from the panel theme and the system + /// setting, so a change redraws every tile. + pub appearance: crate::panel_theme::Appearance, pub text: String, pub rect: Rect, pub scale: f64, @@ -290,11 +289,17 @@ pub struct FrameTile { pub selected: bool, } impl FrameTile { - /// Dark rounded panel behind a panel's keys. - pub fn panel_background(rect: Rect, scale: f64, color: ScannerColor) -> Self { + /// Rounded panel behind a panel's keys. + pub fn panel_background( + rect: Rect, + scale: f64, + color: ScannerColor, + appearance: crate::panel_theme::Appearance, + ) -> Self { Self { thickness: Default::default(), color, + appearance, text: String::new(), style: Some(TileStyle { role: TileRole::Panel, diff --git a/src/PointScan.test.tsx b/src/PointScan.test.tsx index b5c6ef4b..33bc077c 100644 --- a/src/PointScan.test.tsx +++ b/src/PointScan.test.tsx @@ -664,6 +664,26 @@ it("defaults old settings to Medium and saves one shared panel size", async () = expect(screen.queryByText("Panel size")).not.toBeInTheDocument(); }); +it("defaults old settings to Match system and saves one shared panel theme", async () => { + const oldPreferences = { ...defaultScanPreferences, panelTheme: undefined }; + mocks.invoke.mockImplementation(async (command, args) => command === "get_point_scan" + ? { ...initial, config: { ...initial.config, scanPreferences: oldPreferences } } + : { ...initial, ...args }); + render(); + await screen.findByText(initial.message); + more(); + const group = screen.getByRole("group", { name: "Panel theme" }); + expect(within(group).getByRole("button", { name: "Match system" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("img", { name: /scanner highlight sample/ })).not.toHaveClass("light"); + fireEvent.click(within(group).getByRole("button", { name: "Light" })); + await waitFor(() => expect(mocks.invoke).toHaveBeenLastCalledWith("configure_point_scan", { + config: expect.objectContaining({ scanPreferences: expect.objectContaining({ panelTheme: "light", panelSize: "medium" }) }) + })); + expect(screen.getByRole("img", { name: /scanner highlight sample/ })).toHaveClass("light"); + fireEvent.click(screen.getByRole("button", { name: "Customise mouse scanning" })); + expect(screen.queryByText("Panel theme")).not.toBeInTheDocument(); +}); + it("saves row groups beside the pattern older versions read and lets an area choose its own", async () => { // The keyboard's own pattern was saved before row groups existed. const saved = { ...initial, config: { ...initial.config, scanPreferences: { ...defaultScanPreferences, keyboard: { pattern: "grouped" as const } } } }; diff --git a/src/scanning/preferences.ts b/src/scanning/preferences.ts index 0ca676b8..42606cc5 100644 --- a/src/scanning/preferences.ts +++ b/src/scanning/preferences.ts @@ -1,5 +1,7 @@ import type { PointScanConfig, ScannerColor } from './useScanning'; export type PanelSize = 'small' | 'medium' | 'large'; +/** Light or dark panels. `system` follows the computer's setting. */ +export type PanelTheme = 'system' | 'light' | 'dark'; export type ScanArea = 'point' | 'menu' | 'keyboard' | 'mouse'; export type ScanOptions = { automatic: boolean; @@ -16,7 +18,7 @@ export type ScanOptions = { startFrom: 'standard' | 'beginning' | 'selection'; }; export type ScanPreferences = Pick - & { panelSize?: PanelSize } & Partial> & Record>; + & { panelSize?: PanelSize; panelTheme?: PanelTheme } & Partial> & Record>; /** The Item scanning choice, made of `pattern` and `rowGroups`. */ export type ItemScanning = 'grouped' | 'rowGroups' | 'linear'; export const itemScanningLabels: Record = { @@ -26,7 +28,7 @@ export function itemScanning(options: Pick return options.pattern === 'grouped' && options.rowGroups ? 'rowGroups' : options.pattern; } export const defaultScanPreferences: ScanPreferences = { - panelSize: 'medium', direction: 'forward', passLimit: 3, pattern: 'grouped', thickness: 'standard', + panelSize: 'medium', panelTheme: 'system', direction: 'forward', passLimit: 3, pattern: 'grouped', thickness: 'standard', point: {}, menu: {}, keyboard: {}, mouse: {}, }; /** Wait after typing, saved before the choice existed for every scanner, is still in effect. */ diff --git a/src/settings/ScannerPreferences.tsx b/src/settings/ScannerPreferences.tsx index 43e5646e..855a21af 100644 --- a/src/settings/ScannerPreferences.tsx +++ b/src/settings/ScannerPreferences.tsx @@ -2,12 +2,17 @@ import { Button, Input, Select, MoreOptions } from "../ui/controls"; import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { ChevronRight } from 'lucide-react'; import { lastModeName, mouseRepeatStopInstruction, selectOpensHome, type ScanningController, type ScannerColor, type PointScanConfig } from '../scanning/useScanning'; -import { areaOptions, sharedOptions, defaultScanPreferences, usualAfterSelection, savedKeyboardWait, itemScanning, itemScanningLabels, type ItemScanning, type PanelSize, type ScanArea, type ScanOptions } from '../scanning/preferences'; +import { areaOptions, sharedOptions, defaultScanPreferences, usualAfterSelection, savedKeyboardWait, itemScanning, itemScanningLabels, type ItemScanning, type PanelSize, type PanelTheme, type ScanArea, type ScanOptions } from '../scanning/preferences'; import { SettingGroup, Toggle, OptionGroup } from './controls'; import { api } from '../api'; import type { AppSettings } from '../types'; const speedOptions = ["Very slow", "Slow", "Medium", "Fast", "Very fast"].map((label, value) => ({ label, value })); +/** Whether the sample should show light panels, as the panels themselves decide. */ +function lightPanels(theme: PanelTheme | undefined) { + if (theme === 'light' || theme === 'dark') return theme === 'light'; + return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: light)').matches; +} export function ScannerPreferences({ controller, settings: appSettings, onSettingsChange }: { controller: ScanningController; settings?: AppSettings; onSettingsChange?: (next: AppSettings) => void }) { const { config, state, update, chooseKeyboardLayout, pending } = controller; @@ -242,11 +247,17 @@ export function ScannerPreferences({ controller, settings: appSettings, onSettin onChange={panelSize => update('scanPreferences', { ...settings, panelSize })} note={{ summary: 'Sizes the keyboard, the mouse panel, Home and the action menus. Each panel fits its controls and shrinks to fit the screen.' }} />} + {area === 'shared' && + legend="Panel theme" columns="three" disabled={disabled} value={settings.panelTheme ?? 'system'} + options={[{ value: 'system', label: 'Match system' }, { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }]} + onChange={panelTheme => update('scanPreferences', { ...settings, panelTheme })} + note={{ summary: 'Colours the keyboard, the mouse panel, Home and the action menus. Match system follows your computer\'s light or dark setting. On light panels the scanner colour is a deeper shade so it stays easy to see, and white becomes dark grey.' }} + />} {field('color', 'Scanner colour', locked =>
Scanner colour
{(['red', 'green', 'blue', 'yellow', 'white'] as const).map(colour => )}
)} {field('thickness', 'Highlight thickness', locked => legend="Highlight thickness" disabled={locked} value={effective.thickness} options={(['thin', 'standard', 'thick'] as const).map(value => ({ value, label: value[0].toUpperCase() + value.slice(1) }))} onChange={value => change('thickness', value)} />)} -
+
Selected area
diff --git a/src/ui/app.css b/src/ui/app.css index 1ec458a0..6737ed0e 100644 --- a/src/ui/app.css +++ b/src/ui/app.css @@ -486,6 +486,13 @@ fieldset:disabled :is(.color-options, .scanner-colours) label:not(:has(input:che .scanner-sample.green { --scanner-colour: #84ff91; } .scanner-sample.yellow { --scanner-colour: #ffd166; } .scanner-sample.white { --scanner-colour: #ffffff; } +/* Light panels use the deeper highlight shades the native panels draw. */ +.scanner-sample.light { background: #f0f2f6; --scanner-colour: #1967d2; } +.scanner-sample.light .scanner-sample-selection { color: #141820; } +.scanner-sample.light.red { --scanner-colour: #c62828; } +.scanner-sample.light.green { --scanner-colour: #1b8c3c; } +.scanner-sample.light.yellow { --scanner-colour: #d68c00; } +.scanner-sample.light.white { --scanner-colour: #3a404c; } .scanner-sample-selection { display: flex; align-items: center; justify-content: center; height: 100%; border: 3px solid var(--scanner-colour); color: white; background: color-mix(in srgb, var(--scanner-colour) 25%, transparent); } .scanner-sample-selection.thin { border-width: 1px; } .scanner-sample-selection.standard { border-width: 2px; } From 03dc895a2c7243425ecdf97dec0fe2825b401e1a Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 27 Oct 2026 11:10:06 +0000 Subject: [PATCH 2/4] Use the shared text colour helper for macOS labels Keeps text_rgb in use on every platform so the macOS build has no dead code. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/scan_host.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/scan_host.rs b/src-tauri/src/scan_host.rs index c77effc3..45332b0c 100644 --- a/src-tauri/src/scan_host.rs +++ b/src-tauri/src/scan_host.rs @@ -938,7 +938,7 @@ mod platform { } let label = &view.label; label.setStringValue(&NSString::from_str(&tile.text)); - let [r, g, b] = tile.appearance.palette().text; + let [r, g, b] = crate::scan_tile::text_rgb(tile); label.setTextColor(Some(&NSColor::colorWithSRGBRed_green_blue_alpha( f64::from(r) / 255.0, f64::from(g) / 255.0, From 2e22a63135ecb40d9298db88e58d0a01b51f0c79 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 27 Oct 2026 11:16:44 +0000 Subject: [PATCH 3/4] Bump the sync schema to 5 for the panel theme and deepen light yellow Synced documents round-trip exactly, so a new scan preference needs a schema step: version 4 documents gain panelTheme: system on the way in, and older installs are told to update instead of seeing an invalid document. The light-panel yellow highlight is darker so it clears 3:1 against a white key. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/panel_theme.rs | 2 +- src-tauri/src/portable_settings.rs | 97 ++++++++++++++++++++++++++++-- src/ui/app.css | 2 +- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/panel_theme.rs b/src-tauri/src/panel_theme.rs index 875eb54d..0729de4b 100644 --- a/src-tauri/src/panel_theme.rs +++ b/src-tauri/src/panel_theme.rs @@ -88,7 +88,7 @@ impl Appearance { ScannerColor::Red => [198, 40, 40], ScannerColor::Green => [27, 140, 60], ScannerColor::Blue => [25, 103, 210], - ScannerColor::Yellow => [214, 140, 0], + ScannerColor::Yellow => [176, 112, 0], ScannerColor::White => [58, 64, 76], }, } diff --git a/src-tauri/src/portable_settings.rs b/src-tauri/src/portable_settings.rs index 045ac59f..d861aa71 100644 --- a/src-tauri/src/portable_settings.rs +++ b/src-tauri/src/portable_settings.rs @@ -16,7 +16,7 @@ use serde_json::Value; use std::collections::HashSet; use tauri::{AppHandle, Manager}; -pub const SCHEMA_VERSION: u32 = 4; +pub const SCHEMA_VERSION: u32 = 5; const MAX_CUSTOM_PROFILES: usize = 32; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -195,9 +195,10 @@ pub fn parse(mut value: Value, schema_version: u32) -> Result value = upgrade_v3(upgrade_v2(upgrade_v1(value)?)?)?, - 2 => value = upgrade_v3(upgrade_v2(value)?)?, - 3 => value = upgrade_v3(value)?, + 1 => value = upgrade_v4(upgrade_v3(upgrade_v2(upgrade_v1(value)?)?)?)?, + 2 => value = upgrade_v4(upgrade_v3(upgrade_v2(value)?)?)?, + 3 => value = upgrade_v4(upgrade_v3(value)?)?, + 4 => value = upgrade_v4(value)?, SCHEMA_VERSION => {} _ => { return Err(ParseError::Invalid(format!( @@ -276,6 +277,21 @@ fn upgrade_v3(mut value: Value) -> Result { .filter(|point_scan| !point_scan.contains_key("radarSpeed")) .ok_or_else(|| ParseError::Invalid("it does not match version 3 settings".into()))? .insert("radarSpeed".into(), Value::Null); + value["schemaVersion"] = Value::from(4); + Ok(value) +} + +/// Version 4 had no panel theme, so the panels follow the computer. +fn upgrade_v4(mut value: Value) -> Result { + if value.get("schemaVersion") != Some(&Value::from(4)) { + return Err(ParseError::Invalid("version mismatch".into())); + } + value + .pointer_mut("/pointScan/scanPreferences") + .and_then(Value::as_object_mut) + .filter(|preferences| !preferences.contains_key("panelTheme")) + .ok_or_else(|| ParseError::Invalid("it does not match version 4 settings".into()))? + .insert("panelTheme".into(), Value::from("system")); value["schemaVersion"] = Value::from(SCHEMA_VERSION); Ok(value) } @@ -469,6 +485,13 @@ pub fn apply(app: &AppHandle, incoming: Document) -> Result { /// A current document as an older version wrote it. #[cfg(test)] pub(crate) fn written_by(mut value: Value, version: u32) -> Value { + if version < 5 { + value + .pointer_mut("/pointScan/scanPreferences") + .and_then(Value::as_object_mut) + .unwrap() + .remove("panelTheme"); + } if version < 4 { value["pointScan"] .as_object_mut() @@ -642,6 +665,72 @@ mod tests { written_by(serde_json::to_value(local().document()).unwrap(), 1) } + #[test] + fn version_four_documents_read_with_panels_matching_the_system() { + use crate::scan_preferences::PanelTheme; + let mut local = local(); + local.point_scan.scan_preferences.panel_theme = PanelTheme::Light; + let mut cloud = local.clone(); + cloud.point_scan.scan_preferences.panel_theme = PanelTheme::System; + let version_four = written_by(serde_json::to_value(cloud.document()).unwrap(), 4); + assert!(version_four["pointScan"]["scanPreferences"] + .get("panelTheme") + .is_none()); + let parsed = parse(version_four.clone(), 4).unwrap(); + assert_eq!(parsed.schema_version, SCHEMA_VERSION); + assert_eq!( + parsed.point_scan.scan_preferences.panel_theme, + PanelTheme::System + ); + let point_scan = plan(&local, parsed).unwrap().point_scan.unwrap(); + assert_eq!(point_scan.scan_preferences.panel_theme, PanelTheme::System); + + // Version 4 never had a panel theme, and a version 5 document needs it. + let mut early = version_four.clone(); + early["pointScan"]["scanPreferences"]["panelTheme"] = json!("light"); + assert!(matches!(parse(early, 4), Err(ParseError::Invalid(_)))); + let mut missing = version_four.clone(); + missing["schemaVersion"] = json!(SCHEMA_VERSION); + assert!(matches!( + parse(missing, SCHEMA_VERSION), + Err(ParseError::Invalid(_)) + )); + // Every older version still climbs to the current one. + for version in 1..SCHEMA_VERSION { + let old = written_by(serde_json::to_value(cloud.document()).unwrap(), version); + let parsed = parse(old, version).unwrap(); + assert_eq!(parsed.schema_version, SCHEMA_VERSION); + assert_eq!( + parsed.point_scan.scan_preferences.panel_theme, + PanelTheme::System + ); + } + // A merge base saved by version 4 still loads. + let base: Document = serde_json::from_value(written_by( + serde_json::to_value(local.document()).unwrap(), + 4, + )) + .unwrap(); + assert_eq!( + base.point_scan.scan_preferences.panel_theme, + PanelTheme::System + ); + + // A chosen theme syncs, and an unknown one is refused rather than reset. + let mut chosen = serde_json::to_value(cloud.document()).unwrap(); + chosen["pointScan"]["scanPreferences"]["panelTheme"] = json!("dark"); + let parsed = parse(chosen.clone(), SCHEMA_VERSION).unwrap(); + assert_eq!( + parsed.point_scan.scan_preferences.panel_theme, + PanelTheme::Dark + ); + chosen["pointScan"]["scanPreferences"]["panelTheme"] = json!("sepia"); + assert!(matches!( + parse(chosen, SCHEMA_VERSION), + Err(ParseError::Invalid(_)) + )); + } + #[test] fn version_three_documents_read_with_the_radar_following_line_speed() { let mut local = local(); diff --git a/src/ui/app.css b/src/ui/app.css index 6737ed0e..f63c2375 100644 --- a/src/ui/app.css +++ b/src/ui/app.css @@ -491,7 +491,7 @@ fieldset:disabled :is(.color-options, .scanner-colours) label:not(:has(input:che .scanner-sample.light .scanner-sample-selection { color: #141820; } .scanner-sample.light.red { --scanner-colour: #c62828; } .scanner-sample.light.green { --scanner-colour: #1b8c3c; } -.scanner-sample.light.yellow { --scanner-colour: #d68c00; } +.scanner-sample.light.yellow { --scanner-colour: #b07000; } .scanner-sample.light.white { --scanner-colour: #3a404c; } .scanner-sample-selection { display: flex; align-items: center; justify-content: center; height: 100%; border: 3px solid var(--scanner-colour); color: white; background: color-mix(in srgb, var(--scanner-colour) 25%, transparent); } .scanner-sample-selection.thin { border-width: 1px; } From add02d6c130cea7a88f713018560d0ffb0eac6cb Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 27 Oct 2026 11:22:03 +0000 Subject: [PATCH 4/4] Restore alpha on Windows panel labels so dark text survives compositing GDI writes text pixels with alpha 0, which the premultiplied layered window adds to whatever is behind the panel. White text got away with it; dark text on light panels would vanish over a light window. Copy the source alpha back after drawing, in production as well as in fixtures. Co-Authored-By: Claude Fable 5.1 --- src-tauri/src/modifier_overlay_windows.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/modifier_overlay_windows.rs b/src-tauri/src/modifier_overlay_windows.rs index d37a53d7..65a13ba7 100644 --- a/src-tauri/src/modifier_overlay_windows.rs +++ b/src-tauri/src/modifier_overlay_windows.rs @@ -579,16 +579,19 @@ fn present_pixmap_with_text( ); } + // GDI writes text with alpha 0. The layered window composites + // premultiplied alpha, so such a pixel adds its colour to whatever is + // behind the panel: white text happened to stay white, but dark text + // on a light panel would vanish over a light window. Text only lands + // on opaque key faces, so the source alpha is the right alpha. + for (pixel, source) in output.chunks_exact_mut(4).zip(rgba.chunks_exact(4)) { + pixel[3] = source[3]; + } + #[cfg(test)] if PANEL_FIXTURE.with(|fixture| fixture.borrow().is_some()) { let mut pixels = vec![0; output.len()]; copy_rgba_to_bgra(output, &mut pixels)?; - // GDI clears alpha on text pixels; the panel underneath is opaque. - for pixel in pixels.chunks_exact_mut(4) { - if pixel[0] != 0 || pixel[1] != 0 || pixel[2] != 0 { - pixel[3] = 255; - } - } let tile = Pixmap::from_vec( pixels, tiny_skia::IntSize::from_wh(layout.width as u32, layout.height as u32).unwrap(),