diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 9cf33b27f..ef38e370b 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -77,7 +77,7 @@ Daemon mode therefore provides a persistent background service that reacts to us - Communicate with `capture::CaptureManager` for screenshot actions. - Exit when `InputState.should_exit` is set (Escape, tray close, etc.). -`WaylandState` coordinates the runtime owners handlers need. `FocusState` owns activation, focus, and startup acquisition; `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer position, board-pan and chrome gestures, cursor, pointer-lock, and single-contact touch protocol lifecycles; `ToolbarChrome` owns toolbar placement, inline interaction, and fade state; `ToolbarDrag` owns built-in and GTK drag lifecycles; `RegionCaptureRuntime` owns region selection generations, active/review/window-snap state, and the window-query and cut-preview workers; `AcquisitionRuntime` owns the capacity-one screen-acquisition and zoom-waiter registries plus eyedropper source correlation; `FrozenState` owns its availability and one-shot startup gate; `SurfaceState` owns output/fullscreen/layer placement and frozen-fullscreen transitions; `OverlaySuppressionState` owns suppression reason, keyboard policy, capture barrier, and clickthrough state; `RenderRuntime` owns the canvas layer cache, reusable image and blur caches, render-profile baseline, and per-effect damage history; `InputHudRuntime` owns system-reader lifecycle and reconciliation; `SpotlightRuntime` owns render memory, warning latches, and wheel timing; `ClipboardRuntime` owns single-flight clipboard workers and queue policy; `PreferenceStores` groups durable preference stores and workers; `UiAnimationClock` owns animation scheduling; and `FontCatalogPrewarm` owns the one-shot font scan. The root retains cross-owner coordination. `handlers::route::SurfaceRouter` is the single classifier for pointer, touch, and stylus surfaces and supplies overlay screen coordinates before modality-specific dispatch. +`WaylandState` coordinates the runtime owners handlers need. `FocusState` owns activation, focus, and startup acquisition; `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer position, board-pan and chrome gestures, cursor, pointer-lock, and single-contact touch protocol lifecycles; `ToolbarChrome` owns toolbar placement, inline interaction, and fade state; `ToolbarDrag` owns built-in and GTK drag lifecycles; `RegionCaptureRuntime` owns region selection generations, active/review/window-snap state, and the window-query and cut-preview workers; `AcquisitionRuntime` owns the capacity-one screen-acquisition and zoom-waiter registries plus eyedropper source correlation; `FrozenState` owns its availability and one-shot startup gate; `SurfaceState` owns output/fullscreen/layer placement and frozen-fullscreen transitions; `OverlaySuppressionState` owns suppression reason, keyboard policy, capture barrier, and clickthrough state; `RenderRuntime` owns the canvas layer cache, reusable image and blur caches, resolved theme, UI paint caches, render-profile baseline, and per-effect damage history; `InputHudRuntime` owns system-reader lifecycle and reconciliation; `SpotlightRuntime` owns render memory, warning latches, and wheel timing; `ClipboardRuntime` owns single-flight clipboard workers and queue policy; `PreferenceStores` groups durable preference stores and workers; `UiAnimationClock` owns animation scheduling; and `FontCatalogPrewarm` owns the one-shot font scan. The root retains cross-owner coordination. `handlers::route::SurfaceRouter` is the single classifier for pointer, touch, and stylus surfaces and supplies overlay screen coordinates before modality-specific dispatch. Within `PointerRuntime`, pending chrome targets and device-owned release suppression have separate lifecycles. Clearing a toast, HUD, or zoom-chip press preserves mouse and touch suppression; cancelling a touch clears only its own release latch. @@ -147,13 +147,19 @@ Freeze capture waits for the overlay-suppression frame, then selects `wlr-screen defers the frame before preparation. `state/render/{prepare,plan,paint,submit}.rs` keep these boundaries explicit; the planner derives canvas policy and screen/world/buffer damage. - `CanvasRenderCtx` borrows frame parameters and the local Cairo target. `RenderRuntime` - owns the canvas layer cache, drawing resources, reusable profile baseline, and effect - damage history. Drawing resources stay outside the owned `FramePlan`. + owns the canvas layer cache, drawing resources, resolved theme, UI paint caches, + reusable profile baseline, and effect damage history. These resources stay outside + the owned `FramePlan`. - `draw::RenderCtx` borrows a Cairo target and explicit `RenderCaches` for image and blur rendering. The overlay shares its cache owner across direct drawing, layer baking, provisional previews, and board thumbnails. Each export job creates its own owner; PDF pages and magnified-page raster passes share that job's image cache. Export blur remains uncached because page backdrops can share a numeric source identifier. + - `ui::UiRenderCtx` borrows the Cairo target, resolved theme, and `UiRenderCaches` for + help and radial painting. Help layout and radial surfaces retain their one-entry + policies; radial reuse also compares theme values and the actual Cairo matrix scale. + Status and zoom painting borrow only the theme they need. The About dialog resolves + and owns its own theme when it starts. Legacy popup colors retain their fixed palette. - Draw order: board background → finalized shapes → spotlight effects → provisional shape → text cursor preview → UI. Color-profile passes surround UI painting according to their selected targets. Main-surface submission precedes toolbar rendering and marking capture @@ -169,6 +175,14 @@ local drawing resources, as do `InputState::render_provisional_shape` and owner. Public PNG/PDF export functions keep their snapshot-based interfaces and create Cairo resources inside the rendering job, after any worker-thread handoff. +The public `ui::render_help_overlay` and `ui::render_radial_menu` wrappers create local +UI cache owners. Only the legacy radial, status-bar, and zoom-chip wrappers read +`ui::theme::current()`, preserving standalone callers that use `ui::theme::init()`. +Those compatibility APIs remain until a deliberate public API migration; new internal +callers use explicit contexts or themes. Overlay and About startup neither initialize +nor read the compatibility theme. Help hit geometry and text measurement retain their +separate lifecycles. + --- ## 5. Capture Pipeline diff --git a/src/about_window/AGENTS.md b/src/about_window/AGENTS.md index e9b066033..c1c5b26c7 100644 --- a/src/about_window/AGENTS.md +++ b/src/about_window/AGENTS.md @@ -21,6 +21,7 @@ - Preserve foreground/fullscreen safety for any launch, focus, or window behavior. - Keep protocol handlers thin and rendering deterministic. - Chrome colors come from `crate::ui::theme`, never hardcoded literals. +- Resolve the configured theme at startup and retain it on the About owner; painting borrows that value independently of the overlay and legacy public theme compatibility state. - Every outbound link points at wayscriber.com (no code-host links); the update card never installs anything. - Nothing leaves the machine without the user asking. "Report a problem" copies diff --git a/src/about_window/mod.rs b/src/about_window/mod.rs index ed1ff3f95..d3e841dfb 100644 --- a/src/about_window/mod.rs +++ b/src/about_window/mod.rs @@ -37,13 +37,13 @@ use layout::Plan; pub fn run_about_window() -> Result<()> { // Chrome colors come from the same `[ui] theme` key as the overlay, so the // dialog matches the toolbars the user already sees. - match Config::load() { - Ok(loaded) => crate::ui::theme::init(loaded.config.ui.theme.to_theme_mode()), + let theme = match Config::load() { + Ok(loaded) => crate::ui::theme::Theme::resolve(loaded.config.ui.theme.to_theme_mode()), Err(err) => { debug!("About dialog falling back to the default theme: {err}"); - crate::ui::theme::init(Config::default().ui.theme.to_theme_mode()); + crate::ui::theme::Theme::resolve(Config::default().ui.theme.to_theme_mode()) } - } + }; let conn = Connection::connect_to_env().context("Failed to connect to Wayland compositor")?; let (globals, mut event_queue) = @@ -82,6 +82,7 @@ pub fn run_about_window() -> Result<()> { window, content, plan, + theme, ); // Join helpers on every return path so ProcessBrokerGuard teardown cannot @@ -128,6 +129,7 @@ fn surface_size(plan: &Plan) -> (u32, u32) { } struct AboutWindowState { + theme: crate::ui::theme::Theme, registry_state: RegistryState, compositor_state: CompositorState, shm: Shm, diff --git a/src/about_window/render/draw.rs b/src/about_window/render/draw.rs index 1d0ff17c8..95f28adb9 100644 --- a/src/about_window/render/draw.rs +++ b/src/about_window/render/draw.rs @@ -63,9 +63,7 @@ impl Frame<'_> { } } -pub(super) fn draw_about(ctx: &cairo::Context, frame: &Frame<'_>) { - let theme = theme::current(); - +pub(super) fn draw_about(ctx: &cairo::Context, theme: &Theme, frame: &Frame<'_>) { backdrop(ctx, frame.plan, theme); header(ctx, frame, theme); update_card(ctx, frame, theme); @@ -437,6 +435,39 @@ mod tests { cairo::Context::new(&surface).unwrap() } + #[test] + fn about_paints_each_explicit_theme_without_changing_plan() { + let content = AboutContent::build(); + let plan = layout::plan(&content); + let update = UpdateState::Checking; + let frame = frame_for(&plan, &content, &update); + let paint = |theme: &Theme| { + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + plan.width.ceil() as i32, + plan.height.ceil() as i32, + ) + .unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + draw_about(&ctx, theme, &frame); + assert_eq!(ctx.status(), Ok(())); + } + surface.flush(); + surface.data().unwrap().to_vec() + }; + let dark = paint(&Theme::dark()); + let light = paint(&Theme::light()); + assert!( + dark != light, + "explicit About themes must change chrome colors" + ); + assert!( + dark == paint(&Theme::dark()), + "another theme must not replace the first owner's theme" + ); + } + /// The dialog is a fixed width, so row wording has to be chosen to fit it. /// An ellipsis here means a row's text was written without checking. #[test] @@ -499,7 +530,7 @@ mod tests { ]; for state in &states { - draw_about(&ctx, &frame_for(&plan, &content, state)); + draw_about(&ctx, &Theme::dark(), &frame_for(&plan, &content, state)); assert_eq!(ctx.status(), Ok(()), "state {state:?} failed to paint"); } } @@ -515,12 +546,12 @@ mod tests { frame.hover = Some(Element::Link(0)); frame.focus = Some(Element::Close); frame.notice = Some("Copied to clipboard"); - draw_about(&ctx, &frame); + draw_about(&ctx, &Theme::dark(), &frame); frame.hover = Some(Element::UpdateCard); frame.focus = Some(Element::Button(0)); frame.notice = None; - draw_about(&ctx, &frame); + draw_about(&ctx, &Theme::dark(), &frame); assert_eq!(ctx.status(), Ok(())); } diff --git a/src/about_window/render/mod.rs b/src/about_window/render/mod.rs index 03d578274..f6c2deff5 100644 --- a/src/about_window/render/mod.rs +++ b/src/about_window/render/mod.rs @@ -62,6 +62,7 @@ impl AboutWindowState { draw::draw_about( &ctx, + &self.theme, &draw::Frame { plan: &self.plan, content: &self.content, diff --git a/src/about_window/state.rs b/src/about_window/state.rs index 23f5138c3..b6adddfda 100644 --- a/src/about_window/state.rs +++ b/src/about_window/state.rs @@ -28,6 +28,7 @@ impl AboutWindowState { window: super::Window, content: AboutContent, plan: Plan, + theme: crate::ui::theme::Theme, ) -> Self { // Opening the dialog costs no network: the row reports whatever the // last background check wrote, and the user can ask for a fresh one. @@ -36,6 +37,7 @@ impl AboutWindowState { let (width, height) = surface_size(&plan); Self { + theme, registry_state, compositor_state, shm, diff --git a/src/backend/wayland/backend/state_init/config.rs b/src/backend/wayland/backend/state_init/config.rs index fa123fba7..4095aca44 100644 --- a/src/backend/wayland/backend/state_init/config.rs +++ b/src/backend/wayland/backend/state_init/config.rs @@ -92,10 +92,6 @@ pub(super) fn load(backend_exit_mode: ExitAfterCaptureMode) -> LoadedConfig { } }; - // Install process-wide UI preferences before any surface renders. The - // daemon spawns fresh overlay processes that re-enter this load path, so - // this single call site covers both direct and daemon-managed overlays. - crate::ui::theme::init(config.ui.theme.to_theme_mode()); crate::ui::anim::set_motion_enabled(config.ui.reduced_motion.motion_enabled()); let exit_after_capture_mode = match backend_exit_mode { diff --git a/src/backend/wayland/state/AGENTS.md b/src/backend/wayland/state/AGENTS.md index 5d3744498..b2cc68d2c 100644 --- a/src/backend/wayland/state/AGENTS.md +++ b/src/backend/wayland/state/AGENTS.md @@ -8,7 +8,7 @@ - This subtree supports live overlay runtime state: buffers, damage, boards, capture routing, clipboard paste, color picker, onboarding, PDF export, render helpers, toolbar plumbing, zoom, and core accessors. - Runtime owners extracted from `WaylandState` live beside it: `focus.rs` (activation, focus, and startup acquisition), `protocol_globals.rs` (bound globals and toolkit handler state), `pointer_runtime.rs` (pointer position, board-pan and chrome gestures, cursor, pointer-lock, and touch lifecycles), `region_capture/runtime.rs` (region generations, active/review/window-snap state, and query/preview workers), `acquisition.rs` (screen acquisition, zoom waiters, and eyedropper source correlation), `input_hud.rs` (system-reader lifecycle and reconciliation), `spotlight_runtime.rs` (render memory, warning latches, and wheel timing), `clipboard_runtime.rs` (single-flight workers and queue policy), `preference_stores.rs` (persistence stores and workers), `ui_animation.rs` (animation scheduling), `font_catalog.rs` (font-catalog prewarm), `text_input.rs` (text-input-v3 lifecycle and commit serials), `tablet_runtime.rs` (tablet-input-v2 objects and stylus contact), `key_repeat.rs` (manual key-repeat timing), and `helper_launch.rs` (About/configurator launches requested by input). - `core/overlay.rs` owns suppression policy and capture-barrier state; `../surface.rs` owns output/fullscreen/layer placement and frozen-fullscreen transitions. -- `render/` separates mutable preparation, pure `FramePlan` decisions, Cairo painting, and buffer submission. `CanvasRenderCtx` borrows frame parameters; `RenderRuntime` owns the canvas layer cache, explicit drawing caches, profile baseline, and per-effect damage history. Direct canvas rendering, layer baking, previews, and board thumbnails borrow that drawing owner in short scopes. `toolbar/` owns `ToolbarChrome` (placement, inline interaction, and fade state), `ToolbarDrag` (built-in and GTK drag lifecycles), and runtime toolbar effects; `clipboard/` owns session paste helpers. +- `render/` separates mutable preparation, pure `FramePlan` decisions, Cairo painting, and buffer submission. `CanvasRenderCtx` borrows frame parameters; `RenderRuntime` owns the canvas layer cache, explicit drawing caches, resolved theme, UI paint caches, profile baseline, and per-effect damage history. Direct canvas rendering, layer baking, previews, and board thumbnails borrow that drawing owner in short scopes. `toolbar/` owns `ToolbarChrome` (placement, inline interaction, and fade state), `ToolbarDrag` (built-in and GTK drag lifecycles), and runtime toolbar effects; `clipboard/` owns session paste helpers. ## Invariants - Preserve snapshot boundaries for export and session actions. diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index c4e2c966f..8e1693abb 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -115,7 +115,9 @@ impl WaylandState { toolbar: ToolbarSurfaceManager::new(), toolbar_chrome, toolbar_drag: super::super::toolbar::ToolbarDrag::new(), - render: super::super::render::RenderRuntime::new(), + render: super::super::render::RenderRuntime::new(crate::ui::theme::Theme::resolve( + config.ui.theme.to_theme_mode(), + )), suppression: Default::default(), shortcut_coach: Default::default(), focus: super::super::focus::FocusState::new(startup_activation_token), diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index a1512bdc0..56d2fc0a4 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -120,20 +120,34 @@ impl UiDamageHistory { pub(in crate::backend::wayland) struct RenderRuntime { canvas_layer_cache: CanvasLayerCache, draw_caches: crate::draw::RenderCaches, + theme: crate::ui::theme::Theme, + ui_caches: crate::ui::UiRenderCaches, ui_damage: UiDamageHistory, profile_ui_baseline: Vec, } impl RenderRuntime { - pub(in crate::backend::wayland) fn new() -> Self { + pub(in crate::backend::wayland) fn new(theme: crate::ui::theme::Theme) -> Self { Self { canvas_layer_cache: CanvasLayerCache::new(), draw_caches: crate::draw::RenderCaches::default(), + theme, + ui_caches: crate::ui::UiRenderCaches::default(), ui_damage: UiDamageHistory::default(), profile_ui_baseline: Vec::new(), } } + pub(in crate::backend::wayland::state) fn theme(&self) -> &crate::ui::theme::Theme { + &self.theme + } + + pub(in crate::backend::wayland::state) fn ui_parts_mut( + &mut self, + ) -> (&crate::ui::theme::Theme, &mut crate::ui::UiRenderCaches) { + (&self.theme, &mut self.ui_caches) + } + pub(in crate::backend::wayland::state) fn canvas_layer_cache_mut( &mut self, ) -> &mut CanvasLayerCache { @@ -171,6 +185,21 @@ mod tests { use super::*; use crate::backend::wayland::state::buffer_damage::{BufferDamageTracker, FullDamageReason}; + #[test] + fn runtime_themes_are_independent_of_other_owners() { + let mut dark = RenderRuntime::new(crate::ui::theme::Theme::resolve( + crate::ui::theme::ThemeMode::Dark, + )); + let light = RenderRuntime::new(crate::ui::theme::Theme::resolve( + crate::ui::theme::ThemeMode::Light, + )); + assert_eq!(dark.theme(), &crate::ui::theme::Theme::dark()); + assert_eq!(light.theme(), &crate::ui::theme::Theme::light()); + let (theme, _caches) = dark.ui_parts_mut(); + assert_eq!(theme, &crate::ui::theme::Theme::dark()); + assert_ne!(dark.theme(), light.theme()); + } + fn rect(x: i32) -> Rect { Rect::new(x, 0, 10, 10).expect("test rectangle") } diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 8cecb0386..6f3d48b86 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -156,8 +156,9 @@ impl WaylandState { ); } if self.input_state.ui_visibility.show_status_bar { - crate::ui::render_status_bar( + crate::ui::render_status_bar_with_theme( ctx, + self.render.theme(), &self.input_state, &self.config.ui.status_bar_style, width, @@ -165,8 +166,9 @@ impl WaylandState { ); } if !capture_picker && self.zoom_chip_visible() { - crate::ui::render_zoom_chip( + crate::ui::render_zoom_chip_with_theme( ctx, + self.render.theme(), &self.input_state, &self.config.ui.status_bar_style, width, @@ -193,8 +195,14 @@ impl WaylandState { ) { if !capture_picker && self.input_state.help_overlay.is_visible() { let bindings = crate::ui::HelpOverlayBindings::from_input_state(&self.input_state); - let scroll_max = crate::ui::render_help_overlay( - ctx, + let (theme, caches) = self.render.ui_parts_mut(); + let mut render = crate::ui::UiRenderCtx { + cairo: ctx, + theme, + caches, + }; + let scroll_max = crate::ui::render_help_overlay_with_context( + &mut render, &self.config.ui.help_overlay_style, width, height, @@ -269,7 +277,18 @@ impl WaylandState { .input_state .radial_menu_mark_painted_if_due(std::time::Instant::now()) { - crate::ui::render_radial_menu(ctx, &self.input_state, width, height); + let (theme, caches) = self.render.ui_parts_mut(); + let mut render = crate::ui::UiRenderCtx { + cairo: ctx, + theme, + caches, + }; + crate::ui::render_radial_menu_with_context( + &mut render, + &self.input_state, + width, + height, + ); } } else { self.input_state.clear_radial_menu_layout(); diff --git a/src/ui.rs b/src/ui.rs index 0bfd7e4a3..254d47bfa 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -19,6 +19,8 @@ mod primitives; pub(crate) use primitives::{draw_rounded_rect, text_extents_for}; mod properties_panel; mod radial_menu; +mod render_context; +pub(crate) use render_context::{UiRenderCaches, UiRenderCtx}; mod region_action_bar; mod region_capture_picker; mod region_resize_handles; @@ -41,6 +43,7 @@ pub use font_picker::render_font_picker; pub use help_overlay::HelpOverlayBindings; #[cfg(test)] pub use help_overlay::install_help_hit_map_for_test; +pub(crate) use help_overlay::render_help_overlay_with_context; #[allow(unused_imports)] pub use help_overlay::{ HelpOverlayRegion, clear_help_overlay_hit_map, help_overlay_region_at, render_help_overlay, @@ -59,6 +62,7 @@ pub(crate) use primitives::ellipsize_to_fit; pub(crate) use primitives::{checkerboard_behind, draw_pill}; pub use properties_panel::render_properties_panel; pub use radial_menu::render_radial_menu; +pub(crate) use radial_menu::render_radial_menu_with_context; pub(crate) use region_action_bar::{ RegionAction, RegionActionAvailability, RegionActionBar, RegionCutStatus, }; @@ -72,11 +76,16 @@ pub(crate) use spotlight_control::render_spotlight_magnification_control; pub use status::{ StatusHudLayout, StatusHudSegmentKind, ZoomChipButtonKind, ZoomChipLayout, ZoomChipPress, compute_status_hud_layout, compute_zoom_chip_layout, render_editing_badge, render_frozen_badge, - render_page_badge, render_pan_badge, render_status_bar, render_zoom_badge, render_zoom_chip, - status_hud_geometry, zoom_chip_geometry, + render_page_badge, render_pan_badge, render_status_bar, render_status_bar_with_theme, + render_zoom_badge, render_zoom_chip, render_zoom_chip_with_theme, status_hud_geometry, + zoom_chip_geometry, }; pub use toasts::{ blocked_feedback_rects, preset_toast_geometry, render_blocked_feedback, render_preset_toast, render_ui_toast, ui_toast_geometry, }; pub use tour::render_tour; + +#[cfg(test)] +#[path = "ui/tests/theme_compatibility.rs"] +mod theme_compatibility; diff --git a/src/ui/AGENTS.md b/src/ui/AGENTS.md index bff3e05ce..1b81a246c 100644 --- a/src/ui/AGENTS.md +++ b/src/ui/AGENTS.md @@ -7,11 +7,13 @@ ## Architecture - Owns Cairo-rendered overlay UI pieces: status, help overlay, command palette, context menu, board picker, properties panel, radial menu, onboarding card, color picker popup, toasts, tour UI, and primitives. - `toolbar/` owns input-side toolbar model/snapshot/apply plumbing, distinct from runtime backend toolbar rendering. +- `render_context.rs` defines the borrowed `UiRenderCtx` and `UiRenderCaches` owner for help layouts and radial surfaces. The runtime owns these resources; help hit geometry and text measurement are separate concerns. ## Invariants - Keep layout calculations deterministic and avoid text overlap on small surfaces. - Prefer shared constants/primitives where present. - Keep UI rendering side-effect-light; durable state changes belong in input/backend owners. +- Internal renderers receive explicit themes and cache owners. Only legacy public radial/status/zoom wrappers may read `theme::current()` to preserve `theme::init()` compatibility; introduce no new ambient callers. Popup accessors keep their fixed palette. ## Coupled Changes - UI changes may affect `src/input/`, `src/backend/wayland/state/render/`, action metadata, keybindings, toolbar rendering, docs, and tests. diff --git a/src/ui/help_overlay/mod.rs b/src/ui/help_overlay/mod.rs index b5d106683..b35a1f45f 100644 --- a/src/ui/help_overlay/mod.rs +++ b/src/ui/help_overlay/mod.rs @@ -14,3 +14,6 @@ pub use render::{ HelpOverlayRegion, clear_help_overlay_hit_map, help_overlay_region_at, render_help_overlay, }; pub use sections::HelpOverlayBindings; + +pub(in crate::ui) use render::HelpLayoutCache; +pub(crate) use render::render_help_overlay_with_context; diff --git a/src/ui/help_overlay/render/cache.rs b/src/ui/help_overlay/render/cache.rs index add30c1cf..ee25e0fc9 100644 --- a/src/ui/help_overlay/render/cache.rs +++ b/src/ui/help_overlay/render/cache.rs @@ -1,6 +1,5 @@ use super::super::sections::HelpOverlayBindings; use super::state::{OverlayLayout, build_overlay_layout}; -use std::cell::RefCell; /// Style fields converted to integers for stable comparison. /// f64 values are stored as hundredths to avoid floating-point comparison issues. @@ -65,59 +64,66 @@ struct CachedLayout { layout: OverlayLayout, } -thread_local! { - static LAYOUT_CACHE: RefCell> = const { RefCell::new(None) }; +/// One help layout retained for one UI rendering owner. +#[derive(Default)] +pub(in crate::ui) struct HelpLayoutCache { + entry: Option, + #[cfg(test)] + builds: usize, } -/// Get or build the overlay layout, using cached version if inputs haven't changed. -/// -/// This avoids expensive text measurement and grid layout computations on every -/// render frame when the help overlay is visible. -#[allow(clippy::too_many_arguments)] -pub(super) fn get_or_build_overlay_layout( - ctx: &cairo::Context, - style: &crate::config::HelpOverlayStyle, - screen_width: u32, - screen_height: u32, - frozen_enabled: bool, - page_index: usize, - bindings: &HelpOverlayBindings, - search_query: &str, - context_filter: bool, - board_enabled: bool, - capture_enabled: bool, - scroll_offset: f64, - title_text: &str, - header: &super::header::HeaderContent<'_>, - note_text_base: &str, - close_hint_text: &str, - quick_mode: bool, -) -> OverlayLayout { - let key = LayoutCacheKey { - style: StyleKey::from_style(style), - screen_width, - screen_height, - frozen_enabled, - page_index, - bindings_key: bindings.cache_key().to_string(), - search_query: search_query.to_string(), - context_filter, - board_enabled, - capture_enabled, - quick_mode, - }; - - LAYOUT_CACHE.with(|cache| { - let mut cache = cache.borrow_mut(); +impl HelpLayoutCache { + /// Get or build the overlay layout, using cached version if inputs haven't changed. + /// + /// This avoids expensive text measurement and grid layout computations on every + /// render frame when the help overlay is visible. + #[allow(clippy::too_many_arguments)] + pub(super) fn get_or_build_overlay_layout( + &mut self, + ctx: &cairo::Context, + style: &crate::config::HelpOverlayStyle, + screen_width: u32, + screen_height: u32, + frozen_enabled: bool, + page_index: usize, + bindings: &HelpOverlayBindings, + search_query: &str, + context_filter: bool, + board_enabled: bool, + capture_enabled: bool, + scroll_offset: f64, + title_text: &str, + header: &super::header::HeaderContent<'_>, + note_text_base: &str, + close_hint_text: &str, + quick_mode: bool, + ) -> OverlayLayout { + let key = LayoutCacheKey { + style: StyleKey::from_style(style), + screen_width, + screen_height, + frozen_enabled, + page_index, + bindings_key: bindings.cache_key().to_string(), + search_query: search_query.to_string(), + context_filter, + board_enabled, + capture_enabled, + quick_mode, + }; // Check if we have a valid cached layout - if let Some(cached) = cache.as_ref().filter(|c| c.key == key) { + if let Some(cached) = self.entry.as_ref().filter(|c| c.key == key) { // Cache hit - just update scroll offset and return let mut layout = cached.layout.clone(); layout.scroll_offset = scroll_offset.clamp(0.0, layout.scroll_max); return layout; } + #[cfg(test)] + { + self.builds += 1; + } // Cache miss - build new layout let layout = build_overlay_layout( ctx, @@ -140,11 +146,15 @@ pub(super) fn get_or_build_overlay_layout( ); // Store in cache - *cache = Some(CachedLayout { + self.entry = Some(CachedLayout { key, layout: layout.clone(), }); layout - }) + } } + +#[cfg(test)] +#[path = "tests/cache.rs"] +mod tests; diff --git a/src/ui/help_overlay/render/mod.rs b/src/ui/help_overlay/render/mod.rs index 0490aeadc..cb87752af 100644 --- a/src/ui/help_overlay/render/mod.rs +++ b/src/ui/help_overlay/render/mod.rs @@ -16,7 +16,7 @@ use super::types::HelpRowHit; use crate::config::{Action, action_label}; use crate::label_format::NOT_BOUND_LABEL; use crate::ui_text::{UiTextStyle, draw_text_baseline}; -use cache::get_or_build_overlay_layout; +pub(in crate::ui) use cache::HelpLayoutCache; use frame::draw_overlay_frame; use header::{HeaderContent, HeaderHint, draw_hints, draw_version_pill}; @@ -34,7 +34,8 @@ const REPLAY_FOOTER_ICON_GAP: f64 = 7.0; /// Gap between the footer pills. const FOOTER_PILL_GAP: f64 = 10.0; -/// Render help overlay showing all keybindings +/// Render help overlay showing all keybindings with call-local paint resources. +/// The overlay runtime uses the explicit-context entry point to retain its layout. #[allow(clippy::too_many_arguments)] pub fn render_help_overlay( ctx: &cairo::Context, @@ -51,6 +52,46 @@ pub fn render_help_overlay( scroll_offset: f64, quick_mode: bool, ) -> f64 { + let mut caches = crate::ui::UiRenderCaches::default(); + let theme = crate::ui::theme::Theme::dark(); + render_help_overlay_with_context( + &mut crate::ui::UiRenderCtx { + cairo: ctx, + theme: &theme, + caches: &mut caches, + }, + style, + screen_width, + screen_height, + frozen_enabled, + page_index, + bindings, + search_query, + context_filter, + board_enabled, + capture_enabled, + scroll_offset, + quick_mode, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_help_overlay_with_context( + render: &mut crate::ui::UiRenderCtx<'_, '_, '_>, + style: &crate::config::HelpOverlayStyle, + screen_width: u32, + screen_height: u32, + frozen_enabled: bool, + page_index: usize, + bindings: &HelpOverlayBindings, + search_query: &str, + context_filter: bool, + board_enabled: bool, + capture_enabled: bool, + scroll_offset: f64, + quick_mode: bool, +) -> f64 { + let ctx = render.cairo; let title_text = if quick_mode { "Quick Reference" } else { @@ -117,7 +158,7 @@ pub fn render_help_overlay( }; let close_hint_text: &str = &close_hint_owned; - let layout = get_or_build_overlay_layout( + let layout = render.caches.help_mut().get_or_build_overlay_layout( ctx, style, screen_width, diff --git a/src/ui/help_overlay/render/tests/cache.rs b/src/ui/help_overlay/render/tests/cache.rs new file mode 100644 index 000000000..65f57258a --- /dev/null +++ b/src/ui/help_overlay/render/tests/cache.rs @@ -0,0 +1,230 @@ +use super::*; +use crate::config::HelpOverlayStyle; + +struct Inputs { + style: HelpOverlayStyle, + width: u32, + height: u32, + frozen: bool, + page: usize, + bindings: HelpOverlayBindings, + query: String, + context_filter: bool, + board: bool, + capture: bool, + quick: bool, +} + +impl Default for Inputs { + fn default() -> Self { + Self { + style: HelpOverlayStyle::default(), + width: 800, + height: 400, + frozen: true, + page: 0, + bindings: HelpOverlayBindings::default(), + query: String::new(), + context_filter: false, + board: true, + capture: true, + quick: false, + } + } +} + +fn layout(cache: &mut HelpLayoutCache, inputs: &Inputs, scroll: f64) -> OverlayLayout { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + cache.get_or_build_overlay_layout( + &ctx, + &inputs.style, + inputs.width, + inputs.height, + inputs.frozen, + inputs.page, + &inputs.bindings, + &inputs.query, + inputs.context_filter, + inputs.board, + inputs.capture, + scroll, + "Wayscriber Controls", + &super::super::header::HeaderContent { + version: "test", + intro: None, + hints: &[], + }, + "Note", + "Esc to close", + inputs.quick, + ) +} + +#[test] +fn scrolling_clamps_a_returned_clone_without_rebuilding_or_mutating_the_entry() { + let inputs = Inputs::default(); + let mut cache = HelpLayoutCache::default(); + let initial = layout(&mut cache, &inputs, 0.0); + assert!(initial.scroll_max > 0.0, "fixture needs scrollable content"); + let middle = layout(&mut cache, &inputs, initial.scroll_max * 0.5); + assert_eq!(middle.scroll_offset, initial.scroll_max * 0.5); + assert_eq!(layout(&mut cache, &inputs, -10.0).scroll_offset, 0.0); + assert_eq!( + layout(&mut cache, &inputs, f64::MAX).scroll_offset, + initial.scroll_max + ); + assert_eq!(cache.builds, 1); + assert_eq!(cache.entry.as_ref().unwrap().layout.scroll_offset, 0.0); +} + +fn assert_rebuilds_from_baseline(changed: Inputs, label: &str) { + let baseline = Inputs::default(); + let mut cache = HelpLayoutCache::default(); + layout(&mut cache, &baseline, 0.0); + let changed_layout = layout(&mut cache, &changed, 0.0); + assert_eq!(cache.builds, 2, "{label} must invalidate independently"); + layout(&mut cache, &changed, 1.0); + assert_eq!(cache.builds, 2, "{label} replacement must be reusable"); + let fresh = layout(&mut HelpLayoutCache::default(), &changed, 0.0); + assert_eq!(changed_layout.box_width, fresh.box_width, "{label}"); + assert_eq!(changed_layout.box_height, fresh.box_height, "{label}"); + assert_eq!(changed_layout.scroll_max, fresh.scroll_max, "{label}"); + assert_eq!(changed_layout.search_lower, fresh.search_lower, "{label}"); + assert_eq!(changed_layout.note_text, fresh.note_text, "{label}"); + layout(&mut cache, &baseline, 0.0); + assert_eq!(cache.builds, 3, "{label}: only one entry is retained"); +} + +#[test] +fn every_layout_key_dimension_invalidates_independently() { + type Change = (&'static str, fn(&mut Inputs)); + let changes: &[Change] = &[ + ("width", |v| v.width += 1), + ("height", |v| v.height += 1), + ("frozen", |v| v.frozen = false), + ("page", |v| v.page = 1), + ("bindings", |v| { + let input = crate::input::state::test_support::make_test_input_state(); + v.bindings = HelpOverlayBindings::from_input_state(&input); + }), + ("query", |v| v.query = "draw".into()), + ("raw whitespace query", |v| v.query = " ".into()), + ("context filter", |v| v.context_filter = true), + ("board", |v| v.board = false), + ("capture", |v| v.capture = false), + ("quick", |v| v.quick = true), + ("font size", |v| v.style.font_size += 1.0), + ("font family", |v| v.style.font_family = "Monospace".into()), + ("line height", |v| v.style.line_height += 1.0), + ("padding", |v| v.style.padding += 1.0), + ("border width", |v| v.style.border_width += 1.0), + ]; + for (label, change) in changes { + let mut changed = Inputs::default(); + change(&mut changed); + assert_rebuilds_from_baseline(changed, label); + } + for channel in 0..4 { + for color in 0..3 { + let mut changed = Inputs::default(); + let channels = match color { + 0 => &mut changed.style.bg_color, + 1 => &mut changed.style.border_color, + _ => &mut changed.style.text_color, + }; + channels[channel] += 0.1; + assert_rebuilds_from_baseline(changed, &format!("color {color}, channel {channel}")); + } + } +} + +#[test] +fn style_quantization_retains_existing_hundredths_policy() { + let mut inputs = Inputs::default(); + let mut cache = HelpLayoutCache::default(); + layout(&mut cache, &inputs, 0.0); + inputs.style.font_size += 0.001; + inputs.style.border_color[0] += 0.001; + layout(&mut cache, &inputs, 0.0); + assert_eq!(cache.builds, 1); + inputs.style.font_size += 0.01; + layout(&mut cache, &inputs, 0.0); + assert_eq!(cache.builds, 2); +} + +fn paint(caches: &mut crate::ui::UiRenderCaches, inputs: &Inputs, scroll: f64) -> (Vec, f64) { + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + inputs.width as i32, + inputs.height as i32, + ) + .unwrap(); + let extent; + { + let cairo = cairo::Context::new(&surface).unwrap(); + let theme = crate::ui::theme::Theme::dark(); + let mut render = crate::ui::UiRenderCtx { + cairo: &cairo, + theme: &theme, + caches, + }; + extent = super::super::render_help_overlay_with_context( + &mut render, + &inputs.style, + inputs.width, + inputs.height, + inputs.frozen, + inputs.page, + &inputs.bindings, + &inputs.query, + inputs.context_filter, + inputs.board, + inputs.capture, + scroll, + inputs.quick, + ); + } + surface.flush(); + (surface.data().unwrap().to_vec(), extent) +} + +#[test] +fn owners_keep_independent_layouts_and_reused_rendering_matches_fresh_pixels() { + let first_input = Inputs::default(); + let second_input = Inputs { + query: "draw".into(), + width: 640, + ..Inputs::default() + }; + let mut first = crate::ui::UiRenderCaches::default(); + let mut second = crate::ui::UiRenderCaches::default(); + let initial = paint(&mut first, &first_input, 0.0); + let other = paint(&mut second, &second_input, 0.0); + assert_eq!(paint(&mut first, &first_input, 0.0), initial); + assert_eq!(paint(&mut second, &second_input, 0.0), other); + assert_eq!(first.help_mut().builds, 1); + assert_eq!(second.help_mut().builds, 1); + assert_eq!( + paint(&mut first, &first_input, 30.0), + paint( + &mut crate::ui::UiRenderCaches::default(), + &first_input, + 30.0 + ) + ); + assert_eq!( + paint(&mut second, &second_input, 0.0), + paint( + &mut crate::ui::UiRenderCaches::default(), + &second_input, + 0.0 + ) + ); + assert_eq!( + first.help_mut().builds, + 1, + "scroll-only painting must reuse layout" + ); + assert!(initial.0.iter().any(|&byte| byte != 0)); +} diff --git a/src/ui/radial_menu/cache.rs b/src/ui/radial_menu/cache.rs index 58d07b08a..84194042d 100644 --- a/src/ui/radial_menu/cache.rs +++ b/src/ui/radial_menu/cache.rs @@ -2,15 +2,12 @@ //! //! The rest-state rings (color swatches, compass wedges with their glyphs, //! labels, and keycap hints, and the size-ring track) are rendered once into -//! a thread-local `ImageSurface` and blitted per frame; hover overlays, the +//! an owner-local `ImageSurface` and blitted per frame; hover overlays, the //! sub-ring, the size value arc, and the center well stay dynamic on top. //! The cache key covers everything the base bakes in — surface resolution, //! palette + recents, binding hints, the slice table, and the active -//! tool/color state — so any change invalidates the surface. The theme is -//! process-fixed (`theme::init` is first-writer-wins), so it is not part of -//! the key. - -use std::cell::RefCell; +//! tool/color state — so any change invalidates the surface. The entry also +//! retains the theme value so changing themes replaces the baked pixels. use cairo::{Context, Format, ImageSurface}; @@ -46,60 +43,78 @@ pub(super) struct BaseKey { struct CachedBase { key: BaseKey, + theme: theme::Theme, surface: ImageSurface, } -thread_local! { - static BASE_CACHE: RefCell> = const { RefCell::new(None) }; +#[derive(Default)] +pub(in crate::ui) struct RadialBaseCache { + cached: Option, } -/// Blit the (possibly rebuilt) static base centered on the layout center. -/// Falls back to drawing the base directly when an offscreen surface cannot -/// be created. -pub(super) fn paint_base( - ctx: &cairo::Context, - input_state: &InputState, - layout: &RadialMenuLayout, - theme: &theme::Theme, - swatches: &[RadialRingSwatch], -) { - let extent = base_extent(layout); - let scale = base_scale(ctx); - let key = base_cache_key(input_state, swatches, extent, scale); - - let surface = BASE_CACHE.with(|cache| { - let mut cache = cache.borrow_mut(); - if let Some(cached) = cache.as_ref().filter(|cached| cached.key == key) { +impl RadialBaseCache { + /// Blit the (possibly rebuilt) static base centered on the layout center. + /// Falls back to drawing the base directly when an offscreen surface cannot + /// be created. + pub(super) fn paint_base( + &mut self, + ctx: &cairo::Context, + input_state: &InputState, + layout: &RadialMenuLayout, + theme: &theme::Theme, + swatches: &[RadialRingSwatch], + ) { + let extent = base_extent(layout); + let surface = self.surface_for(ctx, input_state, layout, theme, swatches); + + match surface { + Some(surface) => { + let _ = ctx.save(); + let _ = ctx.set_source_surface( + &surface, + layout.center_x - extent, + layout.center_y - extent, + ); + let _ = ctx.paint(); + let _ = ctx.restore(); + } + None => super::draw_static_base( + ctx, + input_state, + theme, + layout.center_x, + layout.center_y, + layout, + swatches, + ), + } + } + + fn surface_for( + &mut self, + ctx: &Context, + input_state: &InputState, + layout: &RadialMenuLayout, + theme: &theme::Theme, + swatches: &[RadialRingSwatch], + ) -> Option { + let extent = base_extent(layout); + let scale = base_scale(ctx); + let key = base_cache_key(input_state, swatches, extent, scale); + if let Some(cached) = self + .cached + .as_ref() + .filter(|cached| cached.key == key && cached.theme == *theme) + { return Some(cached.surface.clone()); } let surface = render_base_surface(input_state, layout, theme, swatches, extent, scale)?; - *cache = Some(CachedBase { + self.cached = Some(CachedBase { key, + theme: theme.clone(), surface: surface.clone(), }); Some(surface) - }); - - match surface { - Some(surface) => { - let _ = ctx.save(); - let _ = ctx.set_source_surface( - &surface, - layout.center_x - extent, - layout.center_y - extent, - ); - let _ = ctx.paint(); - let _ = ctx.restore(); - } - None => super::draw_static_base( - ctx, - input_state, - theme, - layout.center_x, - layout.center_y, - layout, - swatches, - ), } } @@ -306,7 +321,7 @@ mod tests { let surface = render_base_surface( &state, &layout, - theme::current(), + &theme::Theme::dark(), &state.radial_ring_swatches(), EXTENT, 1.0, @@ -358,3 +373,6 @@ mod tests { assert_ne!(key_for(&default_state), key_for(&rebound_state)); } } + +#[cfg(test)] +mod owner_tests; diff --git a/src/ui/radial_menu/cache/owner_tests.rs b/src/ui/radial_menu/cache/owner_tests.rs new file mode 100644 index 000000000..54a547fe1 --- /dev/null +++ b/src/ui/radial_menu/cache/owner_tests.rs @@ -0,0 +1,204 @@ +use super::*; +use crate::draw::Color; +use crate::input::Tool; +use crate::input::state::test_support::make_test_input_state; + +fn state() -> InputState { + let mut state = make_test_input_state(); + state.open_radial_menu(250.0, 250.0); + state.update_radial_menu_layout(500, 500); + state +} + +fn paint( + cache: &mut RadialBaseCache, + state: &InputState, + theme: &theme::Theme, + scale: f64, +) -> Vec { + let mut output = ImageSurface::create( + Format::ARgb32, + (500.0 * scale) as i32, + (500.0 * scale) as i32, + ) + .unwrap(); + { + let ctx = Context::new(&output).unwrap(); + ctx.scale(scale, scale); + cache.paint_base( + &ctx, + state, + &state.radial_menu.layout().unwrap(), + theme, + &state.radial_ring_swatches(), + ); + } + output.data().unwrap().to_vec() +} + +fn assert_replacement( + cache: &mut RadialBaseCache, + state: &InputState, + theme: &theme::Theme, + scale: f64, +) -> Vec { + let prior = cache.cached.as_ref().map(|entry| entry.surface.clone()); + let actual = paint(cache, state, theme, scale); + if let Some(prior) = prior { + assert_ne!( + prior.to_raw_none(), + cache.cached.as_ref().unwrap().surface.to_raw_none(), + "changed input must replace the surface" + ); + } + assert_eq!( + actual, + paint(&mut RadialBaseCache::default(), state, theme, scale) + ); + actual +} + +#[test] +fn theme_and_density_replacement_matches_fresh_pixels_and_hits_reuse_surfaces() { + let state = state(); + let mut cache = RadialBaseCache::default(); + for scale in [1.0, 2.0] { + let mut previous = None; + for theme in [ + theme::Theme::dark(), + theme::Theme::light(), + theme::Theme::dark(), + ] { + let actual = assert_replacement(&mut cache, &state, &theme, scale); + assert!(actual.iter().any(|byte| *byte != 0)); + if let Some(previous) = previous { + assert_ne!(actual, previous, "theme switch must change pixels"); + } + let surface = cache.cached.as_ref().unwrap().surface.clone(); + assert_eq!(surface.device_scale(), (scale, scale)); + assert_eq!(paint(&mut cache, &state, &theme, scale), actual); + assert_eq!( + surface.to_raw_none(), + cache.cached.as_ref().unwrap().surface.to_raw_none(), + "unchanged input must reuse the allocation" + ); + previous = Some(actual); + } + } +} + +#[test] +fn independent_owners_keep_their_own_surfaces() { + let state = state(); + let mut first = RadialBaseCache::default(); + let mut second = RadialBaseCache::default(); + let dark = theme::Theme::dark(); + let light = theme::Theme::light(); + let pixels = paint(&mut first, &state, &dark, 1.0); + let surface = first.cached.as_ref().unwrap().surface.clone(); + paint(&mut second, &state, &light, 1.0); + assert_ne!( + surface.to_raw_none(), + second.cached.as_ref().unwrap().surface.to_raw_none() + ); + assert_eq!(paint(&mut first, &state, &dark, 1.0), pixels); + assert_eq!( + surface.to_raw_none(), + first.cached.as_ref().unwrap().surface.to_raw_none() + ); +} + +#[test] +fn palette_recents_and_active_changes_replace_rendered_base() { + let mut state = state(); + let theme = theme::Theme::dark(); + let mut cache = RadialBaseCache::default(); + paint(&mut cache, &state, &theme, 1.0); + state.set_quick_colors(crate::config::QuickColorPalette::from_entries(vec![ + crate::config::QuickColorPaletteEntry { + label: "Transparent".into(), + color: Color::new(1.0, 0.0, 0.0, 0.0), + }, + ])); + assert_replacement(&mut cache, &state, &theme, 1.0); + state + .style + .record_recent_color(Color::new(0.123, 0.456, 0.789, 1.0)); + assert_replacement(&mut cache, &state, &theme, 1.0); + assert!(state.set_tool_override(Some(Tool::Eraser))); + assert_replacement(&mut cache, &state, &theme, 1.0); + assert!(state.set_color(Color::new(0.9, 0.1, 0.2, 1.0))); + assert_replacement(&mut cache, &state, &theme, 1.0); +} + +#[test] +fn bindings_replace_rendered_base() { + use crate::config::{Action, Shortcut}; + use crate::input::state::test_support::make_test_input_state_with_action_bindings; + let state = state(); + let mut bindings = crate::config::KeybindingsConfig::default() + .build_action_bindings() + .unwrap(); + bindings.insert(Action::SelectPenTool, vec![Shortcut::parse("F9").unwrap()]); + let mut rebound = make_test_input_state_with_action_bindings(bindings); + rebound.open_radial_menu(250.0, 250.0); + rebound.update_radial_menu_layout(500, 500); + let mut cache = RadialBaseCache::default(); + let theme = theme::Theme::dark(); + let before = paint(&mut cache, &state, &theme, 1.0); + let after = assert_replacement(&mut cache, &rebound, &theme, 1.0); + assert_ne!(before, after); +} + +#[test] +fn transparent_swatches_keep_checkerboard_across_themes_and_densities() { + let mut state = state(); + state.set_quick_colors(crate::config::QuickColorPalette::from_entries( + (0..8) + .map(|index| crate::config::QuickColorPaletteEntry { + label: format!("Clear {index}"), + color: Color::new(1.0, 0.0, 0.0, 0.0), + }) + .collect(), + )); + let layout = state.radial_menu.layout().unwrap(); + let radius = (layout.color_inner + layout.color_outer) / 2.0; + let mut cache = RadialBaseCache::default(); + for scale in [1.0, 2.0] { + for theme in [theme::Theme::dark(), theme::Theme::light()] { + let pixels = paint(&mut cache, &state, &theme, scale); + let stride = (500.0 * scale) as usize * 4; + let covered = (0..64) + .filter(|index| { + let angle = std::f64::consts::TAU * f64::from(*index) / 64.0; + let x = ((layout.center_x + radius * angle.cos()) * scale).round() as usize; + let y = ((layout.center_y + radius * angle.sin()) * scale).round() as usize; + let offset = y * stride + x * 4; + u32::from_ne_bytes(pixels[offset..offset + 4].try_into().unwrap()) >> 24 > 0 + }) + .count(); + assert!( + covered > 48, + "transparent swatches must paint their checkerboard at {scale}x" + ); + } + } +} + +#[test] +fn density_comes_from_matrix_x_scale_including_reflection() { + let surface = ImageSurface::create(Format::ARgb32, 1, 1).unwrap(); + surface.set_device_scale(3.0, 3.0); + let ctx = Context::new(&surface).unwrap(); + assert_eq!( + base_scale(&ctx), + 1.0, + "surface device density is not the matrix scale" + ); + ctx.scale(-1.5, 2.0); + assert_eq!( + base_scale(&ctx), + 1.5, + "preserve absolute xx even for nonuniform transforms" + ); +} diff --git a/src/ui/radial_menu/mod.rs b/src/ui/radial_menu/mod.rs index 41989074c..31fd4d399 100644 --- a/src/ui/radial_menu/mod.rs +++ b/src/ui/radial_menu/mod.rs @@ -2,6 +2,7 @@ //! with dynamic overlays (hover, sub-ring, size arc, center well) on top. mod cache; +pub(in crate::ui) use cache::RadialBaseCache; use std::f64::consts::PI; @@ -43,8 +44,37 @@ const HINT_LABEL_LIFT: f64 = 6.0; /// wedge has no glyph. const HINT_LABEL_DROP: f64 = 8.0; -/// Render the radial menu overlay. +/// Render a standalone radial menu using the legacy [`theme::init`] preference. +/// Runtime rendering uses explicit resources to retain its cached base. pub fn render_radial_menu(ctx: &cairo::Context, input_state: &InputState, width: u32, height: u32) { + // An ineligible standalone paint must not initialize the first-writer-wins + // compatibility theme before its caller has a chance to install one. + if matches!(input_state.radial_menu.state(), RadialMenuState::Hidden) + || input_state.radial_menu.layout().is_none() + { + return; + } + let mut caches = crate::ui::UiRenderCaches::default(); + render_radial_menu_with_context( + &mut crate::ui::UiRenderCtx { + cairo: ctx, + theme: theme::current(), + caches: &mut caches, + }, + input_state, + width, + height, + ); +} + +pub(crate) fn render_radial_menu_with_context( + render: &mut crate::ui::UiRenderCtx<'_, '_, '_>, + input_state: &InputState, + width: u32, + height: u32, +) { + let ctx = render.cairo; + let theme = render.theme; let (hover, expanded_sub_ring, size_dragging) = match input_state.radial_menu.state() { RadialMenuState::Open { hover, @@ -59,7 +89,6 @@ pub fn render_radial_menu(ctx: &cairo::Context, input_state: &InputState, width: return; }; - let theme = theme::current(); let swatches = input_state.radial_ring_swatches(); let _ = ctx.save(); @@ -73,7 +102,10 @@ pub fn render_radial_menu(ctx: &cairo::Context, input_state: &InputState, width: // ── Static wedge base (cached): rest-state color ring, compass ring, // size-ring track ── - cache::paint_base(ctx, input_state, &layout, theme, &swatches); + render + .caches + .radial_mut() + .paint_base(ctx, input_state, &layout, theme, &swatches); // ── Dynamic overlays ── match hover { diff --git a/src/ui/render_context.rs b/src/ui/render_context.rs new file mode 100644 index 000000000..234652a01 --- /dev/null +++ b/src/ui/render_context.rs @@ -0,0 +1,27 @@ +use super::help_overlay::HelpLayoutCache; +use super::radial_menu::RadialBaseCache; +use super::theme::Theme; + +/// Paint resources retained by one overlay. Drawing caches remain independent. +#[derive(Default)] +pub(crate) struct UiRenderCaches { + help: HelpLayoutCache, + radial: RadialBaseCache, +} + +impl UiRenderCaches { + pub(in crate::ui) fn help_mut(&mut self) -> &mut HelpLayoutCache { + &mut self.help + } + + pub(in crate::ui) fn radial_mut(&mut self) -> &mut RadialBaseCache { + &mut self.radial + } +} + +/// A short UI paint pass borrowing an explicit theme and its owner's resources. +pub(crate) struct UiRenderCtx<'c, 't, 'r> { + pub cairo: &'c cairo::Context, + pub theme: &'t Theme, + pub caches: &'r mut UiRenderCaches, +} diff --git a/src/ui/status/bar.rs b/src/ui/status/bar.rs index a00867069..0690a86b2 100644 --- a/src/ui/status/bar.rs +++ b/src/ui/status/bar.rs @@ -21,7 +21,7 @@ mod measurement; mod render; pub use content::compute_status_hud_layout; -pub use render::render_status_bar; +pub use render::{render_status_bar, render_status_bar_with_theme}; #[cfg(test)] use content::{build_cluster_pieces, build_prefix_text}; diff --git a/src/ui/status/bar/render.rs b/src/ui/status/bar/render.rs index 3b252d0e8..ca2a3d43b 100644 --- a/src/ui/status/bar/render.rs +++ b/src/ui/status/bar/render.rs @@ -13,6 +13,29 @@ pub fn render_status_bar( style: &crate::config::StatusBarStyle, screen_width: u32, screen_height: u32, +) { + // An empty paint must not initialize the legacy first-writer-wins theme. + if super::status_hud_geometry(input_state, screen_width, screen_height).is_none() { + return; + } + render_status_bar_with_theme( + ctx, + theme::current(), + input_state, + style, + screen_width, + screen_height, + ); +} + +/// Paint with an explicit theme; the compatibility entry point uses the legacy process theme. +pub fn render_status_bar_with_theme( + ctx: &cairo::Context, + theme: &theme::Theme, + input_state: &InputState, + style: &crate::config::StatusBarStyle, + screen_width: u32, + screen_height: u32, ) { let Some(layout) = input_state.status_hud_layout() else { return; @@ -36,7 +59,7 @@ pub fn render_status_bar( layout.pill_height, STATUS_BAR_CORNER_RADIUS, (bg_color[0], bg_color[1], bg_color[2], bg_color[3]), - theme::current().border_hairline, + theme.border_hairline, None, ); diff --git a/src/ui/status/mod.rs b/src/ui/status/mod.rs index 8b3a0eb99..f5cd86b08 100644 --- a/src/ui/status/mod.rs +++ b/src/ui/status/mod.rs @@ -6,9 +6,9 @@ pub use badges::render_pan_badge; pub use badges::{render_editing_badge, render_frozen_badge, render_page_badge, render_zoom_badge}; pub use bar::{ StatusHudLayout, StatusHudSegmentKind, compute_status_hud_layout, render_status_bar, - status_hud_geometry, + render_status_bar_with_theme, status_hud_geometry, }; pub use zoom_chip::{ ZoomChipButtonKind, ZoomChipLayout, ZoomChipPress, compute_zoom_chip_layout, render_zoom_chip, - zoom_chip_geometry, + render_zoom_chip_with_theme, zoom_chip_geometry, }; diff --git a/src/ui/status/zoom_chip.rs b/src/ui/status/zoom_chip.rs index 021d12cf6..393d3229d 100644 --- a/src/ui/status/zoom_chip.rs +++ b/src/ui/status/zoom_chip.rs @@ -525,6 +525,29 @@ pub fn render_zoom_chip( style: &StatusBarStyle, screen_width: u32, screen_height: u32, +) { + // An empty paint must not initialize the legacy first-writer-wins theme. + if zoom_chip_geometry(input_state, screen_width, screen_height).is_none() { + return; + } + render_zoom_chip_with_theme( + ctx, + theme::current(), + input_state, + style, + screen_width, + screen_height, + ); +} + +/// Paint with an explicit theme; the compatibility entry point uses the legacy process theme. +pub fn render_zoom_chip_with_theme( + ctx: &cairo::Context, + theme: &theme::Theme, + input_state: &InputState, + style: &StatusBarStyle, + screen_width: u32, + screen_height: u32, ) { let Some(layout) = input_state.zoom_chip_layout() else { return; @@ -548,7 +571,7 @@ pub fn render_zoom_chip( layout.pill_height, ZOOM_CHIP_CORNER_RADIUS, (bg_color[0], bg_color[1], bg_color[2], bg_color[3]), - theme::current().border_hairline, + theme.border_hairline, None, ); @@ -600,7 +623,7 @@ pub fn render_zoom_chip( // locked state reads without a separate label; every other run // uses the shared chip text color. if run.button == Some(ZoomChipButtonKind::Lock) && layout.lock_active { - let (ar, ag, ab, aa) = theme::current().accent; + let (ar, ag, ab, aa) = theme.accent; ctx.set_source_rgba(ar, ag, ab, aa); } else { ctx.set_source_rgba(r, g, b, a); diff --git a/src/ui/tests/theme_compatibility.rs b/src/ui/tests/theme_compatibility.rs new file mode 100644 index 000000000..0316b4b03 --- /dev/null +++ b/src/ui/tests/theme_compatibility.rs @@ -0,0 +1,139 @@ +use crate::config::{StatusBarStyle, StatusPosition}; +use crate::input::InputState; +use crate::ui::theme::{Theme, ThemeMode}; +use crate::ui::*; + +const WIDTH: u32 = 800; +const HEIGHT: u32 = 600; + +fn state() -> InputState { + let mut state = crate::input::state::test_support::make_test_input_state(); + let style = StatusBarStyle::default(); + state.set_zoom_status(true, true, 2.0, (0.0, 0.0)); + state.update_status_hud_layout(StatusPosition::BottomLeft, &style, WIDTH, HEIGHT); + state.update_zoom_chip_layout(&style, WIDTH, HEIGHT); + state +} + +fn pixels(draw: impl FnOnce(&cairo::Context)) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, WIDTH as i32, HEIGHT as i32).unwrap(); + { + let cairo = cairo::Context::new(&surface).unwrap(); + draw(&cairo); + assert_eq!(cairo.status(), Ok(())); + } + surface.flush(); + surface.data().unwrap().to_vec() +} + +fn equal_pixels(actual: &[u8], expected: &[u8], label: &str) { + assert_eq!(actual.len(), expected.len(), "{label}: length"); + assert!( + actual == expected, + "{label}: first differing byte {:?}", + actual.iter().zip(expected).position(|(a, b)| a != b) + ); +} + +#[test] +fn explicit_status_and_zoom_theme_changes_pixels_without_changing_geometry() { + let state = state(); + let style = StatusBarStyle::default(); + let status_bounds = status_hud_geometry(&state, WIDTH, HEIGHT).unwrap(); + let zoom_bounds = zoom_chip_geometry(&state, WIDTH, HEIGHT).unwrap(); + let dark = Theme::dark(); + let light = Theme::light(); + let status = |theme: &Theme| { + pixels(|ctx| render_status_bar_with_theme(ctx, theme, &state, &style, WIDTH, HEIGHT)) + }; + let zoom = |theme: &Theme| { + pixels(|ctx| render_zoom_chip_with_theme(ctx, theme, &state, &style, WIDTH, HEIGHT)) + }; + assert!( + status(&dark) != status(&light), + "status hairline must use supplied theme" + ); + assert!( + zoom(&dark) != zoom(&light), + "zoom hairline must use supplied theme" + ); + let mut accent = dark.clone(); + accent.accent = (1.0, 0.0, 1.0, 1.0); + assert!( + zoom(&dark) != zoom(&accent), + "active Lock text must use supplied accent" + ); + assert_eq!( + status_hud_geometry(&state, WIDTH, HEIGHT), + Some(status_bounds) + ); + assert_eq!(zoom_chip_geometry(&state, WIDTH, HEIGHT), Some(zoom_bounds)); +} + +#[test] +fn legacy_light_wrappers_match_explicit_light_in_isolated_process() { + const CHILD: &str = "WAYSCRIBER_TEST_LEGACY_LIGHT_THEME"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "ui::theme_compatibility::legacy_light_wrappers_match_explicit_light_in_isolated_process", "--nocapture"]) + .env(CHILD, "1") + .output().unwrap(); + assert!( + output.status.success(), + "isolated legacy theme test failed:\n{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + // Invisible calls used to return before consulting the process theme. + // Keep that behavior so a later configured Light theme can still install. + let hidden = crate::input::state::test_support::make_test_input_state(); + let hidden_style = StatusBarStyle::default(); + pixels(|ctx| { + render_status_bar(ctx, &hidden, &hidden_style, WIDTH, HEIGHT); + render_zoom_chip(ctx, &hidden, &hidden_style, WIDTH, HEIGHT); + render_radial_menu(ctx, &hidden, WIDTH, HEIGHT); + }); + theme::init(ThemeMode::Light); + assert_eq!(theme::current(), &Theme::light()); + let mut state = state(); + let style = StatusBarStyle::default(); + let light = Theme::light(); + let dark = Theme::dark(); + let legacy = pixels(|ctx| render_status_bar(ctx, &state, &style, WIDTH, HEIGHT)); + let explicit = + pixels(|ctx| render_status_bar_with_theme(ctx, &light, &state, &style, WIDTH, HEIGHT)); + equal_pixels(&legacy, &explicit, "legacy status"); + assert!( + legacy + != pixels(|ctx| render_status_bar_with_theme( + ctx, &dark, &state, &style, WIDTH, HEIGHT + )) + ); + let legacy = pixels(|ctx| render_zoom_chip(ctx, &state, &style, WIDTH, HEIGHT)); + let explicit = + pixels(|ctx| render_zoom_chip_with_theme(ctx, &light, &state, &style, WIDTH, HEIGHT)); + equal_pixels(&legacy, &explicit, "legacy zoom"); + assert!( + legacy + != pixels(|ctx| render_zoom_chip_with_theme(ctx, &dark, &state, &style, WIDTH, HEIGHT)) + ); + state.open_radial_menu(400.0, 300.0); + state.update_radial_menu_layout(WIDTH, HEIGHT); + let legacy = pixels(|ctx| render_radial_menu(ctx, &state, WIDTH, HEIGHT)); + let explicit = |theme: &Theme| { + pixels(|ctx| { + let mut caches = UiRenderCaches::default(); + let mut render = UiRenderCtx { + cairo: ctx, + theme, + caches: &mut caches, + }; + render_radial_menu_with_context(&mut render, &state, WIDTH, HEIGHT); + }) + }; + equal_pixels(&legacy, &explicit(&light), "legacy radial"); + assert!(legacy != explicit(&dark)); +} diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 1e42f7450..2a1d72423 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -684,6 +684,14 @@ pub struct Theme { } impl Theme { + /// Resolve a startup preference without reading or installing global state. + pub fn resolve(mode: ThemeMode) -> Self { + match mode { + ThemeMode::Light => Self::light(), + ThemeMode::Auto | ThemeMode::Dark => Self::dark(), + } + } + /// The default OSD-dark chrome (canonical values, matching the const /// tokens above). pub fn dark() -> Self { @@ -751,18 +759,17 @@ pub enum ThemeMode { static CURRENT: OnceLock = OnceLock::new(); -/// Install the theme for this process. Call once at startup after config is -/// loaded; later calls are no-ops (first writer wins). +/// Install the theme used by legacy standalone public renderers. +/// Later calls are no-ops (first writer wins). The overlay and About window +/// resolve and own their themes independently. Retained with [`current`] until +/// the legacy public renderers undergo an explicit API migration; new rendering +/// code should borrow an owned [`Theme`] instead. pub fn init(mode: ThemeMode) { - let theme = match mode { - ThemeMode::Light => Theme::light(), - ThemeMode::Auto | ThemeMode::Dark => Theme::dark(), - }; - let _ = CURRENT.set(theme); + let _ = CURRENT.set(Theme::resolve(mode)); } -/// The active theme. Falls back to dark if `init` was never called (tests, -/// early rendering). +/// The legacy standalone theme. Falls back to dark if [`init`] was never called. +/// Runtime rendering borrows owner-local themes instead; see [`init`]. pub fn current() -> &'static Theme { CURRENT.get_or_init(Theme::dark) } @@ -776,65 +783,46 @@ pub fn current() -> &'static Theme { /// tokens; a partial surface-only migration is less usable than a consistently /// dark popup. pub mod popup { - use super::{Rgba, current, overlay}; - - /// Resolution split from the accessors so it can be exercised against - /// both variants: `current()` is a process-wide `OnceLock`, so a test - /// cannot install one theme and then the other. - pub(crate) fn surface_for(_theme: &super::Theme, dark: Rgba) -> Rgba { - dark - } - - pub(crate) fn border_for(_theme: &super::Theme, dark: Rgba) -> Rgba { - dark - } - - fn surface(dark: Rgba) -> Rgba { - surface_for(current(), dark) - } - - fn border(dark: Rgba) -> Rgba { - border_for(current(), dark) - } + use super::{Rgba, overlay}; pub fn bg_context_menu() -> Rgba { - surface(overlay::PANEL_BG_CONTEXT_MENU) + overlay::PANEL_BG_CONTEXT_MENU } pub fn bg_board_picker() -> Rgba { - surface(overlay::PANEL_BG_BOARD_PICKER) + overlay::PANEL_BG_BOARD_PICKER } pub fn bg_properties() -> Rgba { - surface(overlay::PANEL_BG_PROPERTIES) + overlay::PANEL_BG_PROPERTIES } pub fn bg_command_palette() -> Rgba { - surface(overlay::PANEL_BG_COMMAND_PALETTE) + overlay::PANEL_BG_COMMAND_PALETTE } pub fn bg_modal() -> Rgba { - surface(overlay::PANEL_BG_MODAL) + overlay::PANEL_BG_MODAL } pub fn border_context_menu() -> Rgba { - border(overlay::BORDER_CONTEXT_MENU) + overlay::BORDER_CONTEXT_MENU } pub fn border_board_picker() -> Rgba { - border(overlay::BORDER_BOARD_PICKER) + overlay::BORDER_BOARD_PICKER } pub fn border_properties() -> Rgba { - border(overlay::BORDER_PROPERTIES) + overlay::BORDER_PROPERTIES } pub fn border_command_palette() -> Rgba { - border(overlay::BORDER_COMMAND_PALETTE) + overlay::BORDER_COMMAND_PALETTE } pub fn border_modal() -> Rgba { - border(overlay::BORDER_MODAL) + overlay::BORDER_MODAL } } @@ -1054,68 +1042,36 @@ mod tests { #[cfg(test)] mod popup_theme_tests { - use super::{Theme, overlay, popup}; + use super::{Theme, ThemeMode, overlay, popup}; - /// Dark chrome must be byte-identical to the const tokens it shipped - /// with: the popups deliberately differ in tint and translucency, and - /// this change is about light mode, not a dark restyle. #[test] - fn dark_popups_keep_their_exact_const_tokens() { - let dark = Theme::dark(); - for (resolved, token) in [ - ( - popup::surface_for(&dark, overlay::PANEL_BG_CONTEXT_MENU), - overlay::PANEL_BG_CONTEXT_MENU, - ), - ( - popup::surface_for(&dark, overlay::PANEL_BG_BOARD_PICKER), - overlay::PANEL_BG_BOARD_PICKER, - ), - ( - popup::surface_for(&dark, overlay::PANEL_BG_PROPERTIES), - overlay::PANEL_BG_PROPERTIES, - ), + fn resolving_themes_is_independent_and_auto_stays_dark() { + assert_eq!(Theme::resolve(ThemeMode::Auto), Theme::dark()); + assert_eq!(Theme::resolve(ThemeMode::Light), Theme::light()); + assert_eq!(Theme::resolve(ThemeMode::Dark), Theme::dark()); + } + + #[test] + fn popup_accessors_keep_the_complete_legacy_palette() { + for (actual, expected) in [ + (popup::bg_context_menu(), overlay::PANEL_BG_CONTEXT_MENU), + (popup::bg_board_picker(), overlay::PANEL_BG_BOARD_PICKER), + (popup::bg_properties(), overlay::PANEL_BG_PROPERTIES), ( - popup::surface_for(&dark, overlay::PANEL_BG_COMMAND_PALETTE), + popup::bg_command_palette(), overlay::PANEL_BG_COMMAND_PALETTE, ), + (popup::bg_modal(), overlay::PANEL_BG_MODAL), + (popup::border_context_menu(), overlay::BORDER_CONTEXT_MENU), + (popup::border_board_picker(), overlay::BORDER_BOARD_PICKER), + (popup::border_properties(), overlay::BORDER_PROPERTIES), ( - popup::surface_for(&dark, overlay::PANEL_BG_MODAL), - overlay::PANEL_BG_MODAL, - ), - ( - popup::border_for(&dark, overlay::BORDER_MODAL), - overlay::BORDER_MODAL, + popup::border_command_palette(), + overlay::BORDER_COMMAND_PALETTE, ), + (popup::border_modal(), overlay::BORDER_MODAL), ] { - assert_eq!(resolved, token); + assert_eq!(actual, expected); } } - - /// A popup cannot migrate only its outer surface: its foreground, input, - /// hover, divider, and nested-card tokens have to move as one palette or - /// the light surface leaves the existing near-white text unreadable. - #[test] - fn light_mode_keeps_the_complete_legacy_popup_palette_together() { - let light = Theme::light(); - - for token in [ - overlay::PANEL_BG_CONTEXT_MENU, - overlay::PANEL_BG_BOARD_PICKER, - overlay::PANEL_BG_PROPERTIES, - overlay::PANEL_BG_COMMAND_PALETTE, - overlay::PANEL_BG_MODAL, - ] { - assert_eq!( - popup::surface_for(&light, token), - token, - "surface-only theming would make the legacy light text unreadable" - ); - } - - assert_eq!( - popup::border_for(&light, overlay::BORDER_MODAL), - overlay::BORDER_MODAL - ); - } }