From 11e1e55be06ffdc0db1cdbda66c82b6c113ad2a2 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:27:17 +0200 Subject: [PATCH 01/42] refactor(help): return owned interaction geometry from painting --- src/help_overlay_interaction.rs | 149 +++++++++++++++++++ src/lib.rs | 1 + src/ui.rs | 3 +- src/ui/help_overlay/mod.rs | 3 +- src/ui/help_overlay/render/entry.rs | 117 +++++++++++++++ src/ui/help_overlay/render/footer.rs | 136 ++++++++++++++++++ src/ui/help_overlay/render/hit.rs | 147 +++++-------------- src/ui/help_overlay/render/mod.rs | 208 +++------------------------ tests/ui.rs | 59 ++++++++ 9 files changed, 521 insertions(+), 302 deletions(-) create mode 100644 src/help_overlay_interaction.rs create mode 100644 src/ui/help_overlay/render/entry.rs create mode 100644 src/ui/help_overlay/render/footer.rs diff --git a/src/help_overlay_interaction.rs b/src/help_overlay_interaction.rs new file mode 100644 index 000000000..293ca47a5 --- /dev/null +++ b/src/help_overlay_interaction.rs @@ -0,0 +1,149 @@ +//! Owned interaction geometry produced by a help-overlay paint pass. + +use crate::config::Action; + +/// What sits under a point inside the help overlay. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HelpOverlayRegion { + /// The search input well. + Search, + /// A clickable action row or footer action. + Row(Action), + /// Overlay chrome outside an interactive element. + Inside, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct HitRect { + x: f64, + y: f64, + w: f64, + h: f64, +} + +impl HitRect { + fn from_tuple((x, y, w, h): (f64, f64, f64, f64)) -> Self { + Self { x, y, w, h } + } + + fn contains(self, x: f64, y: f64) -> bool { + x >= self.x && x <= self.x + self.w && y >= self.y && y <= self.y + self.h + } +} + +/// Last-painted screen-space geometry for one help overlay. +/// +/// The default map is empty. Rows are tested in insertion order before the +/// search well and bare chrome, with the outer bounds checked first. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct HelpHitMap { + box_rect: Option, + search_rect: Option, + rows: Vec<(HitRect, Action)>, +} + +impl HelpHitMap { + /// Own the actual rectangles produced by painting. Coordinates are logical + /// screen pixels; rectangle tuples are `(x, y, width, height)`. + pub fn new( + box_rect: (f64, f64, f64, f64), + search_rect: Option<(f64, f64, f64, f64)>, + rows: impl IntoIterator, + ) -> Self { + Self { + box_rect: Some(HitRect::from_tuple(box_rect)), + search_rect: search_rect.map(HitRect::from_tuple), + rows: rows + .into_iter() + .map(|(rect, action)| (HitRect::from_tuple(rect), action)) + .collect(), + } + } + + /// Return the most specific target at a point, including rectangle edges. + pub fn region_at(&self, x: f64, y: f64) -> Option { + if !self.box_rect?.contains(x, y) { + return None; + } + for &(rect, action) in &self.rows { + if rect.contains(x, y) { + return Some(HelpOverlayRegion::Row(action)); + } + } + if self.search_rect.is_some_and(|rect| rect.contains(x, y)) { + return Some(HelpOverlayRegion::Search); + } + Some(HelpOverlayRegion::Inside) + } +} + +/// Owned scroll extent and interaction geometry from a single paint pass. +#[derive(Clone, Debug, PartialEq)] +pub struct HelpRenderResult { + pub scroll_max: f64, + pub hit_map: HelpHitMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn outer_bounds_reject_even_rows_that_extend_outside() { + let map = HelpHitMap::new( + (10.0, 10.0, 20.0, 20.0), + None, + [((0.0, 0.0, 40.0, 40.0), Action::ToggleHelp)], + ); + assert_eq!(map.region_at(9.0, 15.0), None); + assert_eq!(map.region_at(31.0, 15.0), None); + assert_eq!( + map.region_at(10.0, 10.0), + Some(HelpOverlayRegion::Row(Action::ToggleHelp)) + ); + assert_eq!( + map.region_at(30.0, 30.0), + Some(HelpOverlayRegion::Row(Action::ToggleHelp)) + ); + } + + #[test] + fn overlapping_rows_keep_order_and_precede_search_and_chrome() { + let map = HelpHitMap::new( + (0.0, 0.0, 100.0, 100.0), + Some((10.0, 10.0, 60.0, 60.0)), + [ + ((20.0, 20.0, 20.0, 20.0), Action::ToggleHelp), + ((20.0, 20.0, 30.0, 30.0), Action::OpenAbout), + ], + ); + assert_eq!( + map.region_at(30.0, 30.0), + Some(HelpOverlayRegion::Row(Action::ToggleHelp)) + ); + assert_eq!( + map.region_at(45.0, 45.0), + Some(HelpOverlayRegion::Row(Action::OpenAbout)) + ); + assert_eq!(map.region_at(60.0, 60.0), Some(HelpOverlayRegion::Search)); + assert_eq!(map.region_at(90.0, 90.0), Some(HelpOverlayRegion::Inside)); + } + + #[test] + fn independent_maps_and_empty_map_keep_their_own_geometry() { + let first = HelpHitMap::new((0.0, 0.0, 20.0, 20.0), None, []); + let second = HelpHitMap::new((100.0, 100.0, 20.0, 20.0), None, []); + let retained = first.clone(); + drop(first); + assert_eq!(HelpHitMap::default().region_at(0.0, 0.0), None); + assert_eq!( + retained.region_at(5.0, 5.0), + Some(HelpOverlayRegion::Inside) + ); + assert_eq!(second.region_at(5.0, 5.0), None); + assert_eq!( + second.region_at(105.0, 105.0), + Some(HelpOverlayRegion::Inside) + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 253cccf87..dc9a947e0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ pub mod draw; pub mod durable_io; pub mod env_vars; pub(crate) mod file_uri; +pub mod help_overlay_interaction; pub(crate) mod image_decode; pub mod input; mod label_format; diff --git a/src/ui.rs b/src/ui.rs index 254d47bfa..81dbb3461 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -46,7 +46,8 @@ 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, + HelpHitMap, HelpOverlayRegion, HelpRenderResult, clear_help_overlay_hit_map, + help_overlay_region_at, render_help_overlay, render_help_overlay_result, }; pub use input_hud::{input_hud_geometry, render_input_hud}; pub(crate) use measure_badge::{ diff --git a/src/ui/help_overlay/mod.rs b/src/ui/help_overlay/mod.rs index b35a1f45f..756f6d923 100644 --- a/src/ui/help_overlay/mod.rs +++ b/src/ui/help_overlay/mod.rs @@ -11,7 +11,8 @@ mod types; #[cfg(test)] pub use render::install_help_hit_map_for_test; pub use render::{ - HelpOverlayRegion, clear_help_overlay_hit_map, help_overlay_region_at, render_help_overlay, + HelpHitMap, HelpOverlayRegion, HelpRenderResult, clear_help_overlay_hit_map, + help_overlay_region_at, render_help_overlay, render_help_overlay_result, }; pub use sections::HelpOverlayBindings; diff --git a/src/ui/help_overlay/render/entry.rs b/src/ui/help_overlay/render/entry.rs new file mode 100644 index 000000000..0e5b25461 --- /dev/null +++ b/src/ui/help_overlay/render/entry.rs @@ -0,0 +1,117 @@ +use super::{HelpOverlayBindings, HelpRenderResult, hit, render_help_overlay_result_with_context}; + +/// 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, + 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 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, + ) +} + +/// Paint once and return owned scroll and hit geometry without changing the legacy map. +#[allow(clippy::too_many_arguments)] +pub fn render_help_overlay_result( + 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, + quick_mode: bool, +) -> HelpRenderResult { + let mut caches = crate::ui::UiRenderCaches::default(); + let theme = crate::ui::theme::Theme::dark(); + render_help_overlay_result_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 result = render_help_overlay_result_with_context( + render, + style, + screen_width, + screen_height, + frozen_enabled, + page_index, + bindings, + search_query, + context_filter, + board_enabled, + capture_enabled, + scroll_offset, + quick_mode, + ); + hit::store_help_hit_map(result.hit_map); + result.scroll_max +} diff --git a/src/ui/help_overlay/render/footer.rs b/src/ui/help_overlay/render/footer.rs new file mode 100644 index 000000000..428425f48 --- /dev/null +++ b/src/ui/help_overlay/render/footer.rs @@ -0,0 +1,136 @@ +use super::super::types::HelpRowHit; +use super::header; +use crate::config::{Action, action_label}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for}; +use crate::ui_text::{UiTextStyle, draw_text_baseline}; + +/// Horizontal padding inside the "Replay tour" footer pill, between its border +/// and the icon/label content. +const REPLAY_FOOTER_PAD_X: f64 = 12.0; +/// Gap between the refresh icon and the "Replay tour" label inside the pill. +const REPLAY_FOOTER_ICON_GAP: f64 = 7.0; +/// Gap between the footer pills. +const FOOTER_PILL_GAP: f64 = 10.0; + +/// One footer pill: an action plus the glyph drawn beside its registry label. +pub(super) struct FooterPill { + pub(super) action: Action, + pub(super) icon: crate::toolbar_icons::ToolbarIconPainter, +} + +/// Geometry and styling shared by every footer pill. +pub(super) struct FooterPillLayout<'a> { + pub(super) inner_x: f64, + pub(super) inner_width: f64, + pub(super) top_y: f64, + pub(super) pill_height: f64, + pub(super) font_size: f64, + pub(super) font_family: &'a str, + pub(super) accent: [f64; 4], + pub(super) accent_muted: [f64; 4], +} + +/// Draw the footer pills as one centred row and return their clickable rects, +/// each tagged with the action a click should run. +pub(super) fn draw_footer_pills( + ctx: &cairo::Context, + layout: FooterPillLayout<'_>, + pills: &[FooterPill], +) -> Vec { + let icon_size = layout.font_size; + let label_style = UiTextStyle { + family: layout.font_family, + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: layout.font_size, + }; + + // Measure first so the row can be centred as a group rather than pill by + // pill. + let measured: Vec<(&FooterPill, &str, f64)> = pills + .iter() + .map(|pill| { + let label = action_label(pill.action); + let label_width = text_extents_for( + ctx, + layout.font_family, + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + layout.font_size, + label, + ) + .width(); + let width = + icon_size + REPLAY_FOOTER_ICON_GAP + label_width + REPLAY_FOOTER_PAD_X * 2.0; + (pill, label, width) + }) + .collect(); + + let total_width: f64 = measured.iter().map(|(_, _, width)| width).sum::() + + FOOTER_PILL_GAP * measured.len().saturating_sub(1) as f64; + let mut pill_x = layout.inner_x + (layout.inner_width - total_width) / 2.0; + + let mut hits = Vec::with_capacity(measured.len()); + for (pill, label, pill_width) in measured { + draw_rounded_rect( + ctx, + pill_x, + layout.top_y, + pill_width, + layout.pill_height, + header::PILL_RADIUS, + ); + ctx.set_source_rgba(layout.accent[0], layout.accent[1], layout.accent[2], 0.14); + let _ = ctx.fill(); + draw_rounded_rect( + ctx, + pill_x, + layout.top_y, + pill_width, + layout.pill_height, + header::PILL_RADIUS, + ); + ctx.set_source_rgba(layout.accent[0], layout.accent[1], layout.accent[2], 0.38); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + let content_x = pill_x + REPLAY_FOOTER_PAD_X; + let icon_y = layout.top_y + (layout.pill_height - icon_size) / 2.0; + let _ = ctx.save(); + ctx.set_source_rgba( + layout.accent_muted[0], + layout.accent_muted[1], + layout.accent_muted[2], + layout.accent_muted[3], + ); + (pill.icon)(ctx, content_x, icon_y, icon_size); + let _ = ctx.restore(); + + let label_baseline = layout.top_y + layout.pill_height / 2.0 + layout.font_size * 0.35; + ctx.set_source_rgba( + layout.accent_muted[0], + layout.accent_muted[1], + layout.accent_muted[2], + layout.accent_muted[3], + ); + draw_text_baseline( + ctx, + label_style, + label, + content_x + icon_size + REPLAY_FOOTER_ICON_GAP, + label_baseline, + None, + ); + + hits.push(HelpRowHit { + x: pill_x, + y: layout.top_y, + w: pill_width, + h: layout.pill_height, + action: pill.action, + }); + pill_x += pill_width + FOOTER_PILL_GAP; + } + + hits +} diff --git a/src/ui/help_overlay/render/hit.rs b/src/ui/help_overlay/render/hit.rs index ccf23f743..46d21a14f 100644 --- a/src/ui/help_overlay/render/hit.rs +++ b/src/ui/help_overlay/render/hit.rs @@ -1,144 +1,63 @@ -//! Pointer hit map for the help overlay. -//! -//! The overlay's geometry is measured with real text metrics, so the only place -//! the true row/search rectangles exist is inside the render pass. Each frame -//! stores the drawn rectangles here (screen space); pointer releases and cursor -//! hints then test against the actual layout instead of an approximate bounding -//! box. Populated and read on the single Wayland event-loop thread, so a plain -//! thread-local is sufficient. +//! Temporary owner-free interaction adapter for existing help callers. +//! New result-returning paint entry points keep their hit maps with the caller. +use crate::help_overlay_interaction::{HelpHitMap, HelpOverlayRegion}; use std::cell::RefCell; -use super::super::types::HelpRowHit; -use crate::config::Action; - -/// What sits under a point inside the help overlay. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HelpOverlayRegion { - /// Over the search input well (I-beam / text cursor). - Search, - /// Over a clickable action row (or the "Replay tour" footer); carries the - /// action a click should run. - Row(Action), - /// Inside the overlay chrome but not over an interactive element. - Inside, -} - -#[derive(Clone, Copy)] -struct HitRect { - x: f64, - y: f64, - w: f64, - h: f64, -} - -impl HitRect { - fn contains(&self, px: f64, py: f64) -> bool { - px >= self.x && px <= self.x + self.w && py >= self.y && py <= self.y + self.h - } -} - -struct HelpHitMap { - box_rect: HitRect, - search_rect: Option, - rows: Vec<(HitRect, Action)>, -} - thread_local! { static HIT_MAP: RefCell> = const { RefCell::new(None) }; } -fn rect(tuple: (f64, f64, f64, f64)) -> HitRect { - HitRect { - x: tuple.0, - y: tuple.1, - w: tuple.2, - h: tuple.3, - } +pub(super) fn store_help_hit_map(map: HelpHitMap) { + HIT_MAP.with(|cell| *cell.borrow_mut() = Some(map)); } -/// Store the rectangles drawn this frame so pointer code can hit-test the real -/// layout. Called once at the end of the render pass. -pub(super) fn store_help_hit_map( - box_rect: (f64, f64, f64, f64), - search_rect: Option<(f64, f64, f64, f64)>, - rows: &[HelpRowHit], -) { - let rows = rows - .iter() - .map(|hit| { - ( - HitRect { - x: hit.x, - y: hit.y, - w: hit.w, - h: hit.h, - }, - hit.action, - ) - }) - .collect(); - HIT_MAP.with(|cell| { - *cell.borrow_mut() = Some(HelpHitMap { - box_rect: rect(box_rect), - search_rect: search_rect.map(rect), - rows, - }); - }); -} - -/// Region under `(x, y)` in the last-rendered help overlay, or `None` when the -/// point is outside the overlay box (or the overlay has not rendered yet). -/// -/// Rows win over the search well, which wins over bare chrome, so the most -/// specific interactive target is always reported. +/// Region under a point in the last overlay painted through the legacy renderer. +/// Result-returning renderers instead provide an independent owned map. pub fn help_overlay_region_at(x: f64, y: f64) -> Option { - HIT_MAP.with(|cell| { - let map = cell.borrow(); - let map = map.as_ref()?; - if !map.box_rect.contains(x, y) { - return None; - } - for (rect, action) in &map.rows { - if rect.contains(x, y) { - return Some(HelpOverlayRegion::Row(*action)); - } - } - if let Some(search) = &map.search_rect - && search.contains(x, y) - { - return Some(HelpOverlayRegion::Search); - } - Some(HelpOverlayRegion::Inside) - }) + HIT_MAP.with(|cell| cell.borrow().as_ref()?.region_at(x, y)) } -/// Drop the stored hit map (called when the overlay closes) so stale rectangles -/// can never answer a hit test. +/// Clear the legacy renderer's last-painted interaction geometry. pub fn clear_help_overlay_hit_map() { HIT_MAP.with(|cell| *cell.borrow_mut() = None); } -/// Install a known hit map for tests that exercise the pointer plumbing without -/// a real render pass. Takes plain tuples so callers outside this module need -/// not name the crate-private [`HelpRowHit`]. +/// Install geometry for tests of the legacy pointer plumbing. #[cfg(test)] pub fn install_help_hit_map_for_test( box_rect: (f64, f64, f64, f64), search_rect: Option<(f64, f64, f64, f64)>, - rows: &[(f64, f64, f64, f64, Action)], + rows: &[(f64, f64, f64, f64, crate::config::Action)], ) { - let rows: Vec = rows - .iter() - .map(|&(x, y, w, h, action)| HelpRowHit { x, y, w, h, action }) - .collect(); - store_help_hit_map(box_rect, search_rect, &rows); + store_help_hit_map(HelpHitMap::new( + box_rect, + search_rect, + rows.iter() + .map(|&(x, y, w, h, action)| ((x, y, w, h), action)), + )); } #[cfg(test)] mod tests { use super::*; + use super::super::super::types::HelpRowHit; + use crate::config::Action; + + fn store_help_hit_map( + box_rect: (f64, f64, f64, f64), + search_rect: Option<(f64, f64, f64, f64)>, + rows: &[HelpRowHit], + ) { + super::store_help_hit_map(HelpHitMap::new( + box_rect, + search_rect, + rows.iter() + .map(|hit| ((hit.x, hit.y, hit.w, hit.h), hit.action)), + )); + } + fn row_hit(x: f64, y: f64, w: f64, h: f64, action: Action) -> HelpRowHit { HelpRowHit { x, y, w, h, action } } diff --git a/src/ui/help_overlay/render/mod.rs b/src/ui/help_overlay/render/mod.rs index cb87752af..824efb197 100644 --- a/src/ui/help_overlay/render/mod.rs +++ b/src/ui/help_overlay/render/mod.rs @@ -4,6 +4,8 @@ use super::nav::{NavDrawStyle, draw_nav}; use super::sections::HelpOverlayBindings; mod cache; +mod entry; +mod footer; mod frame; mod header; mod hit; @@ -11,72 +13,26 @@ mod metrics; mod palette; mod state; -use super::super::primitives::{draw_rounded_rect, text_extents_for}; 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}; pub(in crate::ui) use cache::HelpLayoutCache; +pub(crate) use entry::render_help_overlay_with_context; +pub use entry::{render_help_overlay, render_help_overlay_result}; +use footer::{FooterPill, FooterPillLayout, draw_footer_pills}; use frame::draw_overlay_frame; use header::{HeaderContent, HeaderHint, draw_hints, draw_version_pill}; +pub use crate::help_overlay_interaction::{HelpHitMap, HelpOverlayRegion, HelpRenderResult}; #[cfg(test)] pub use hit::install_help_hit_map_for_test; -pub use hit::{HelpOverlayRegion, clear_help_overlay_hit_map, help_overlay_region_at}; +pub use hit::{clear_help_overlay_hit_map, help_overlay_region_at}; const BULLET: &str = "\u{2022}"; -/// Horizontal padding inside the "Replay tour" footer pill, between its border -/// and the icon/label content. -const REPLAY_FOOTER_PAD_X: f64 = 12.0; -/// Gap between the refresh icon and the "Replay tour" label inside the pill. -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 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, - 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 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( +pub(crate) fn render_help_overlay_result_with_context( render: &mut crate::ui::UiRenderCtx<'_, '_, '_>, style: &crate::config::HelpOverlayStyle, screen_width: u32, @@ -90,7 +46,7 @@ pub(crate) fn render_help_overlay_with_context( capture_enabled: bool, scroll_offset: f64, quick_mode: bool, -) -> f64 { +) -> HelpRenderResult { let ctx = render.cairo; let title_text = if quick_mode { "Quick Reference" @@ -436,139 +392,19 @@ pub(crate) fn render_help_overlay_with_context( None, ); - hit::store_help_hit_map( - ( - layout.box_x, - layout.box_y, - layout.box_width, - layout.box_height, + HelpRenderResult { + scroll_max: layout.scroll_max, + hit_map: HelpHitMap::new( + ( + layout.box_x, + layout.box_y, + layout.box_width, + layout.box_height, + ), + Some(search_rect), + row_hits + .into_iter() + .map(|hit| ((hit.x, hit.y, hit.w, hit.h), hit.action)), ), - Some(search_rect), - &row_hits, - ); - - layout.scroll_max -} - -/// One footer pill: an action plus the glyph drawn beside its registry label. -struct FooterPill { - action: Action, - icon: crate::toolbar_icons::ToolbarIconPainter, -} - -/// Geometry and styling shared by every footer pill. -struct FooterPillLayout<'a> { - inner_x: f64, - inner_width: f64, - top_y: f64, - pill_height: f64, - font_size: f64, - font_family: &'a str, - accent: [f64; 4], - accent_muted: [f64; 4], -} - -/// Draw the footer pills as one centred row and return their clickable rects, -/// each tagged with the action a click should run. -fn draw_footer_pills( - ctx: &cairo::Context, - layout: FooterPillLayout<'_>, - pills: &[FooterPill], -) -> Vec { - let icon_size = layout.font_size; - let label_style = UiTextStyle { - family: layout.font_family, - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Bold, - size: layout.font_size, - }; - - // Measure first so the row can be centred as a group rather than pill by - // pill. - let measured: Vec<(&FooterPill, &str, f64)> = pills - .iter() - .map(|pill| { - let label = action_label(pill.action); - let label_width = text_extents_for( - ctx, - layout.font_family, - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, - layout.font_size, - label, - ) - .width(); - let width = - icon_size + REPLAY_FOOTER_ICON_GAP + label_width + REPLAY_FOOTER_PAD_X * 2.0; - (pill, label, width) - }) - .collect(); - - let total_width: f64 = measured.iter().map(|(_, _, width)| width).sum::() - + FOOTER_PILL_GAP * measured.len().saturating_sub(1) as f64; - let mut pill_x = layout.inner_x + (layout.inner_width - total_width) / 2.0; - - let mut hits = Vec::with_capacity(measured.len()); - for (pill, label, pill_width) in measured { - draw_rounded_rect( - ctx, - pill_x, - layout.top_y, - pill_width, - layout.pill_height, - header::PILL_RADIUS, - ); - ctx.set_source_rgba(layout.accent[0], layout.accent[1], layout.accent[2], 0.14); - let _ = ctx.fill(); - draw_rounded_rect( - ctx, - pill_x, - layout.top_y, - pill_width, - layout.pill_height, - header::PILL_RADIUS, - ); - ctx.set_source_rgba(layout.accent[0], layout.accent[1], layout.accent[2], 0.38); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); - - let content_x = pill_x + REPLAY_FOOTER_PAD_X; - let icon_y = layout.top_y + (layout.pill_height - icon_size) / 2.0; - let _ = ctx.save(); - ctx.set_source_rgba( - layout.accent_muted[0], - layout.accent_muted[1], - layout.accent_muted[2], - layout.accent_muted[3], - ); - (pill.icon)(ctx, content_x, icon_y, icon_size); - let _ = ctx.restore(); - - let label_baseline = layout.top_y + layout.pill_height / 2.0 + layout.font_size * 0.35; - ctx.set_source_rgba( - layout.accent_muted[0], - layout.accent_muted[1], - layout.accent_muted[2], - layout.accent_muted[3], - ); - draw_text_baseline( - ctx, - label_style, - label, - content_x + icon_size + REPLAY_FOOTER_ICON_GAP, - label_baseline, - None, - ); - - hits.push(HelpRowHit { - x: pill_x, - y: layout.top_y, - w: pill_width, - h: layout.pill_height, - action: pill.action, - }); - pill_x += pill_width + FOOTER_PILL_GAP; } - - hits } diff --git a/tests/ui.rs b/tests/ui.rs index d965ad2fb..01b37f5f9 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -294,3 +294,62 @@ fn help_overlay_footer_offers_clickable_replay_and_about() { ); wayscriber::ui::clear_help_overlay_hit_map(); } + +#[test] +fn help_result_owns_rendered_footer_hits_and_preserves_legacy_paint_pixels() { + use wayscriber::ui::HelpOverlayRegion; + + let style = HelpOverlayStyle::default(); + let input = make_input_state(); + let bindings = wayscriber::ui::HelpOverlayBindings::from_input_state(&input); + let (mut owned_surface, ctx) = surface_with_context(1400, 1000); + wayscriber::ui::clear_help_overlay_hit_map(); + let result = wayscriber::ui::render_help_overlay_result( + &ctx, &style, 1400, 1000, true, 0, &bindings, "", false, true, true, 0.0, false, + ); + drop(ctx); + + let mut replay = None; + let mut about = None; + for y in 0..1000 { + for x in 0..1400 { + match result.hit_map.region_at(x as f64, y as f64) { + Some(HelpOverlayRegion::Row(Action::ReplayTour)) => replay = Some((x, y)), + Some(HelpOverlayRegion::Row(Action::OpenAbout)) => about = Some((x, y)), + _ => {} + } + } + } + for point in [ + replay.expect("rendered Replay tour target"), + about.expect("rendered About target"), + ] { + assert_eq!( + wayscriber::ui::help_overlay_region_at(point.0 as f64, point.1 as f64), + None, + "owned result must not install the legacy singleton" + ); + } + + let (mut legacy_surface, ctx) = surface_with_context(1400, 1000); + let scroll = wayscriber::ui::render_help_overlay( + &ctx, &style, 1400, 1000, true, 0, &bindings, "", false, true, true, 0.0, false, + ); + drop(ctx); + assert_eq!(result.scroll_max, scroll); + owned_surface.flush(); + legacy_surface.flush(); + let owned = owned_surface.data().unwrap(); + let legacy = legacy_surface.data().unwrap(); + assert!( + owned[..] == legacy[..], + "result and legacy paths must paint identical pixels" + ); + wayscriber::ui::clear_help_overlay_hit_map(); + let (x, y) = about.unwrap(); + assert_eq!( + result.hit_map.region_at(x as f64, y as f64), + Some(HelpOverlayRegion::Row(Action::OpenAbout)), + "clearing the legacy map must not erase the owned result" + ); +} From ff8ac446696fa98b01010af71327753bec8c964d Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:27:38 +0200 Subject: [PATCH 02/42] refactor(text): give About an explicit UI text engine --- src/about_window/mod.rs | 1 + src/about_window/render/draw.rs | 268 +++++++----------------- src/about_window/render/draw/tests.rs | 166 +++++++++++++++ src/about_window/render/mod.rs | 1 + src/about_window/render/text.rs | 7 +- src/about_window/state.rs | 1 + src/ui.rs | 2 +- src/ui/primitives.rs | 41 +++- src/ui_text.rs | 283 +++++++++++--------------- src/ui_text/tests.rs | 265 ++++++++++++++++++++++++ 10 files changed, 666 insertions(+), 369 deletions(-) create mode 100644 src/about_window/render/draw/tests.rs create mode 100644 src/ui_text/tests.rs diff --git a/src/about_window/mod.rs b/src/about_window/mod.rs index d3e841dfb..fb9722b17 100644 --- a/src/about_window/mod.rs +++ b/src/about_window/mod.rs @@ -129,6 +129,7 @@ fn surface_size(plan: &Plan) -> (u32, u32) { } struct AboutWindowState { + ui_text: crate::ui_text::UiTextEngine, theme: crate::ui::theme::Theme, registry_state: RegistryState, compositor_state: CompositorState, diff --git a/src/about_window/render/draw.rs b/src/about_window/render/draw.rs index 95f28adb9..9107c2328 100644 --- a/src/about_window/render/draw.rs +++ b/src/about_window/render/draw.rs @@ -5,9 +5,9 @@ //! module only decides color and paint order, which is why the other three can //! be tested without a compositor. -use crate::ui::ellipsize_to_fit; +use crate::ui::ellipsize_to_fit_with_engine; use crate::ui::theme::{self, Rgba, Theme}; -use crate::ui_text::{UiTextStyle, measure_text}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::super::content::{AboutAction, AboutContent, UpdateState}; use super::super::interaction::Element; @@ -63,14 +63,19 @@ impl Frame<'_> { } } -pub(super) fn draw_about(ctx: &cairo::Context, theme: &Theme, frame: &Frame<'_>) { +pub(super) fn draw_about( + engine: &UiTextEngine, + ctx: &cairo::Context, + theme: &Theme, + frame: &Frame<'_>, +) { backdrop(ctx, frame.plan, theme); - header(ctx, frame, theme); - update_card(ctx, frame, theme); - link_rows(ctx, frame, theme); - meta_lines(ctx, frame, theme); - buttons(ctx, frame, theme); - footer(ctx, frame, theme); + header(engine, ctx, frame, theme); + update_card(engine, ctx, frame, theme); + link_rows(engine, ctx, frame, theme); + meta_lines(engine, ctx, frame, theme); + buttons(engine, ctx, frame, theme); + footer(engine, ctx, frame, theme); } fn backdrop(ctx: &cairo::Context, plan: &Plan, theme: &Theme) { @@ -81,7 +86,7 @@ fn backdrop(ctx: &cairo::Context, plan: &Plan, theme: &Theme) { stroke_rounded_rect(ctx, rect, WINDOW_RADIUS, theme.border_hairline, 1.0); } -fn header(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { +fn header(engine: &UiTextEngine, ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { let plan = frame.plan; match frame.icon { @@ -91,8 +96,9 @@ fn header(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { // decoded. fill_rounded_rect(ctx, plan.icon, CARD_RADIUS, theme.accent); let style = style(20.0, cairo::FontWeight::Bold); - let x = plan.icon.0 + (plan.icon.2 - advance(style, "W")) / 2.0; + let x = plan.icon.0 + (plan.icon.2 - advance(engine, style, "W")) / 2.0; label( + engine, ctx, style, (x, plan.icon.1 + plan.icon.3 * 0.72), @@ -103,6 +109,7 @@ fn header(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { } label( + engine, ctx, style(TITLE_SIZE, cairo::FontWeight::Bold), plan.title, @@ -110,6 +117,7 @@ fn header(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { frame.content.title, ); label( + engine, ctx, style(TAGLINE_SIZE, cairo::FontWeight::Normal), plan.tagline, @@ -117,6 +125,7 @@ fn header(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { frame.content.tagline, ); label( + engine, ctx, style(META_SIZE, cairo::FontWeight::Normal), plan.version, @@ -133,7 +142,7 @@ fn header(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { ); } -fn update_card(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { +fn update_card(engine: &UiTextEngine, ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { let rect = frame.plan.update_card; let state = frame.state_of(Element::UpdateCard); let available = frame.update.is_update_available(); @@ -192,9 +201,10 @@ fn update_card(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { Some(_) => { // The card doubles as the "check now" button whenever there is // nothing to open, so it says so. - let width = advance(action_style, CHECK_NOW); + let width = advance(engine, action_style, CHECK_NOW); let x = rect.0 + rect.2 - PADDING - width; label( + engine, ctx, action_style, (x, rect.1 + rect.3 / 2.0 + action_style.size * 0.36), @@ -215,22 +225,24 @@ fn update_card(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { let headline = style(CARD_TITLE_SIZE, cairo::FontWeight::Bold); let detail = style(DETAIL_SIZE, cairo::FontWeight::Normal); label( + engine, ctx, headline, (text_left, rect.1 + 21.0), theme.text_primary, - &fit(ctx, &frame.update.headline(), headline, max_width), + &fit(engine, ctx, &frame.update.headline(), headline, max_width), ); label( + engine, ctx, detail, (text_left, rect.1 + 36.0), theme.text_tertiary, - &fit(ctx, &frame.update.detail(), detail, max_width), + &fit(engine, ctx, &frame.update.detail(), detail, max_width), ); } -fn link_rows(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { +fn link_rows(engine: &UiTextEngine, ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { let title_style = style(ROW_TITLE_SIZE, cairo::FontWeight::Normal); let detail_style = style(DETAIL_SIZE, cairo::FontWeight::Normal); @@ -266,6 +278,7 @@ fn link_rows(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { let (text_left, max_width) = row_text_bounds(*rect); label( + engine, ctx, title_style, (text_left, rect.1 + 16.0), @@ -274,14 +287,15 @@ fn link_rows(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { } else { theme.text_primary }, - &fit(ctx, link.title, title_style, max_width), + &fit(engine, ctx, link.title, title_style, max_width), ); label( + engine, ctx, detail_style, (text_left, rect.1 + 30.0), theme.text_tertiary, - &fit(ctx, &link.detail, detail_style, max_width), + &fit(engine, ctx, &link.detail, detail_style, max_width), ); } } @@ -298,7 +312,7 @@ fn row_text_bounds(rect: Rect) -> (f64, f64) { (text_left, (chevron_x(rect) - 8.0 - text_left).max(0.0)) } -fn meta_lines(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { +fn meta_lines(engine: &UiTextEngine, ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { let meta_style = style(META_SIZE, cairo::FontWeight::Normal); let max_width = frame.plan.width - frame.plan.icon.0 * 2.0; @@ -309,16 +323,17 @@ fn meta_lines(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { .zip(frame.content.meta_lines.iter()) { label( + engine, ctx, meta_style, *baseline, theme.text_tertiary, - &fit(ctx, line, meta_style, max_width), + &fit(engine, ctx, line, meta_style, max_width), ); } } -fn buttons(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { +fn buttons(engine: &UiTextEngine, ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { let button_style = style(BUTTON_SIZE, cairo::FontWeight::Normal); let specs = frame.content.buttons(); @@ -334,9 +349,10 @@ fn buttons(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { theme.accent_bright, ); - let label_text = fit(ctx, spec.label, button_style, rect.2 - 16.0); - let x = rect.0 + (rect.2 - advance(button_style, &label_text)) / 2.0; + let label_text = fit(engine, ctx, spec.label, button_style, rect.2 - 16.0); + let x = rect.0 + (rect.2 - advance(engine, button_style, &label_text)) / 2.0; label( + engine, ctx, button_style, (x, baseline_in(*rect, button_style.size)), @@ -350,7 +366,7 @@ fn buttons(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { } } -fn footer(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { +fn footer(engine: &UiTextEngine, ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { let hint_style = style(HINT_SIZE, cairo::FontWeight::Normal); let (text, color) = match frame.notice { Some(notice) => (notice, theme.accent_bright), @@ -358,11 +374,12 @@ fn footer(ctx: &cairo::Context, frame: &Frame<'_>, theme: &Theme) { }; let max_width = frame.plan.width - frame.plan.icon.0 * 2.0; label( + engine, ctx, hint_style, frame.plan.hint, color, - &fit(ctx, text, hint_style, max_width), + &fit(engine, ctx, text, hint_style, max_width), ); } @@ -375,19 +392,42 @@ fn style(size: f64, weight: cairo::FontWeight) -> UiTextStyle<'static> { } } -fn label(ctx: &cairo::Context, style: UiTextStyle<'_>, at: (f64, f64), color: Rgba, text: &str) { +fn label( + engine: &UiTextEngine, + ctx: &cairo::Context, + style: UiTextStyle<'_>, + at: (f64, f64), + color: Rgba, + text: &str, +) { set_color(ctx, color); - draw_text(ctx, style, at.0, at.1, text); + draw_text(engine, ctx, style, at.0, at.1, text); } /// Trim to the available width so a long version string or install-source name /// cannot run under a chevron or off the surface. -fn fit(ctx: &cairo::Context, text: &str, style: UiTextStyle<'_>, max_width: f64) -> String { - ellipsize_to_fit(ctx, text, style.family, style.size, style.weight, max_width) +fn fit( + engine: &UiTextEngine, + ctx: &cairo::Context, + text: &str, + style: UiTextStyle<'_>, + max_width: f64, +) -> String { + ellipsize_to_fit_with_engine( + engine, + ctx, + text, + style.family, + style.size, + style.weight, + max_width, + ) } -fn advance(style: UiTextStyle<'_>, text: &str) -> f64 { - measure_text(style, text, None).map_or(0.0, |extents| extents.x_advance()) +fn advance(engine: &UiTextEngine, style: UiTextStyle<'_>, text: &str) -> f64 { + engine + .measure(style, text, None) + .map_or(0.0, |extents| extents.x_advance()) } /// Vertically centered baseline inside `rect` for text of `size`. @@ -404,168 +444,4 @@ fn opaque(color: Rgba) -> Rgba { } #[cfg(test)] -mod tests { - use super::*; - use crate::about_window::layout; - use crate::update_check::{AvailableUpdate, DEFAULT_NOTES_URL, DEFAULT_UPDATE_URL}; - - fn frame_for<'a>( - plan: &'a Plan, - content: &'a AboutContent, - update: &'a UpdateState, - ) -> Frame<'a> { - Frame { - plan, - content, - update, - icon: None, - hover: None, - focus: None, - notice: None, - } - } - - fn context(plan: &Plan) -> cairo::Context { - let surface = cairo::ImageSurface::create( - cairo::Format::ARgb32, - plan.width as i32, - plan.height as i32, - ) - .unwrap(); - 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] - fn link_row_wording_fits_without_being_ellipsized() { - let content = AboutContent::build(); - let plan = layout::plan(&content); - let ctx = context(&plan); - - let title_style = style(ROW_TITLE_SIZE, cairo::FontWeight::Normal); - let detail_style = style(DETAIL_SIZE, cairo::FontWeight::Normal); - - for (rect, link) in plan.link_rows.iter().zip(content.links.iter()) { - let (_, max_width) = row_text_bounds(*rect); - - assert_eq!( - fit(&ctx, link.title, title_style, max_width), - link.title, - "row title does not fit" - ); - assert_eq!( - fit(&ctx, &link.detail, detail_style, max_width), - link.detail, - "detail of the {:?} row does not fit", - link.title - ); - } - } - - #[test] - fn every_update_state_paints_cleanly() { - let content = AboutContent::build(); - let plan = layout::plan(&content); - let ctx = context(&plan); - - let states = [ - UpdateState::Unavailable, - UpdateState::Unknown(crate::update_check::Freshness::default()), - UpdateState::Checking, - UpdateState::UpToDate(crate::update_check::Freshness { - checked_seconds_ago: Some(3_600), - last_attempt_failed: false, - }), - UpdateState::UpToDate(crate::update_check::Freshness { - checked_seconds_ago: Some(3_600), - last_attempt_failed: true, - }), - UpdateState::Available { - update: Box::new(AvailableUpdate { - version: "0.9.23".to_string(), - released: Some("2026-07-20".to_string()), - update_url: DEFAULT_UPDATE_URL.to_string(), - notes_url: DEFAULT_NOTES_URL.to_string(), - }), - freshness: crate::update_check::Freshness { - checked_seconds_ago: Some(0), - last_attempt_failed: false, - }, - }, - UpdateState::Failed("Network unreachable".to_string()), - ]; - - for state in &states { - draw_about(&ctx, &Theme::dark(), &frame_for(&plan, &content, state)); - assert_eq!(ctx.status(), Ok(()), "state {state:?} failed to paint"); - } - } - - #[test] - fn hover_focus_and_notice_paint_cleanly() { - let content = AboutContent::build(); - let plan = layout::plan(&content); - let ctx = context(&plan); - let update = UpdateState::Unknown(crate::update_check::Freshness::default()); - - let mut frame = frame_for(&plan, &content, &update); - frame.hover = Some(Element::Link(0)); - frame.focus = Some(Element::Close); - frame.notice = Some("Copied to clipboard"); - draw_about(&ctx, &Theme::dark(), &frame); - - frame.hover = Some(Element::UpdateCard); - frame.focus = Some(Element::Button(0)); - frame.notice = None; - draw_about(&ctx, &Theme::dark(), &frame); - - assert_eq!(ctx.status(), Ok(())); - } - - #[test] - fn text_is_trimmed_to_the_width_it_is_given() { - let content = AboutContent::build(); - let plan = layout::plan(&content); - let ctx = context(&plan); - let narrow = style(ROW_TITLE_SIZE, cairo::FontWeight::Normal); - - let trimmed = fit(&ctx, "Setup, config, troubleshooting", narrow, 40.0); - - assert!(trimmed.len() < "Setup, config, troubleshooting".len()); - assert!(advance(narrow, &trimmed) <= 40.0); - } -} +mod tests; diff --git a/src/about_window/render/draw/tests.rs b/src/about_window/render/draw/tests.rs new file mode 100644 index 000000000..a57b79066 --- /dev/null +++ b/src/about_window/render/draw/tests.rs @@ -0,0 +1,166 @@ +use super::*; +use crate::about_window::layout; +use crate::update_check::{AvailableUpdate, DEFAULT_NOTES_URL, DEFAULT_UPDATE_URL}; + +fn frame_for<'a>(plan: &'a Plan, content: &'a AboutContent, update: &'a UpdateState) -> Frame<'a> { + Frame { + plan, + content, + update, + icon: None, + hover: None, + focus: None, + notice: None, + } +} + +fn context(plan: &Plan) -> cairo::Context { + let surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, plan.width as i32, plan.height as i32) + .unwrap(); + cairo::Context::new(&surface).unwrap() +} + +#[test] +fn about_paints_each_explicit_theme_without_changing_plan() { + let engine = &UiTextEngine::default(); + 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(engine, &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] +fn link_row_wording_fits_without_being_ellipsized() { + let engine = &UiTextEngine::default(); + let content = AboutContent::build(); + let plan = layout::plan(&content); + let ctx = context(&plan); + + let title_style = style(ROW_TITLE_SIZE, cairo::FontWeight::Normal); + let detail_style = style(DETAIL_SIZE, cairo::FontWeight::Normal); + + for (rect, link) in plan.link_rows.iter().zip(content.links.iter()) { + let (_, max_width) = row_text_bounds(*rect); + + assert_eq!( + fit(engine, &ctx, link.title, title_style, max_width), + link.title, + "row title does not fit" + ); + assert_eq!( + fit(engine, &ctx, &link.detail, detail_style, max_width), + link.detail, + "detail of the {:?} row does not fit", + link.title + ); + } +} + +#[test] +fn every_update_state_paints_cleanly() { + let engine = &UiTextEngine::default(); + let content = AboutContent::build(); + let plan = layout::plan(&content); + let ctx = context(&plan); + + let states = [ + UpdateState::Unavailable, + UpdateState::Unknown(crate::update_check::Freshness::default()), + UpdateState::Checking, + UpdateState::UpToDate(crate::update_check::Freshness { + checked_seconds_ago: Some(3_600), + last_attempt_failed: false, + }), + UpdateState::UpToDate(crate::update_check::Freshness { + checked_seconds_ago: Some(3_600), + last_attempt_failed: true, + }), + UpdateState::Available { + update: Box::new(AvailableUpdate { + version: "0.9.23".to_string(), + released: Some("2026-07-20".to_string()), + update_url: DEFAULT_UPDATE_URL.to_string(), + notes_url: DEFAULT_NOTES_URL.to_string(), + }), + freshness: crate::update_check::Freshness { + checked_seconds_ago: Some(0), + last_attempt_failed: false, + }, + }, + UpdateState::Failed("Network unreachable".to_string()), + ]; + + for state in &states { + draw_about( + engine, + &ctx, + &Theme::dark(), + &frame_for(&plan, &content, state), + ); + assert_eq!(ctx.status(), Ok(()), "state {state:?} failed to paint"); + } +} + +#[test] +fn hover_focus_and_notice_paint_cleanly() { + let engine = &UiTextEngine::default(); + let content = AboutContent::build(); + let plan = layout::plan(&content); + let ctx = context(&plan); + let update = UpdateState::Unknown(crate::update_check::Freshness::default()); + + let mut frame = frame_for(&plan, &content, &update); + frame.hover = Some(Element::Link(0)); + frame.focus = Some(Element::Close); + frame.notice = Some("Copied to clipboard"); + draw_about(engine, &ctx, &Theme::dark(), &frame); + + frame.hover = Some(Element::UpdateCard); + frame.focus = Some(Element::Button(0)); + frame.notice = None; + draw_about(engine, &ctx, &Theme::dark(), &frame); + + assert_eq!(ctx.status(), Ok(())); +} + +#[test] +fn text_is_trimmed_to_the_width_it_is_given() { + let engine = &UiTextEngine::default(); + let content = AboutContent::build(); + let plan = layout::plan(&content); + let ctx = context(&plan); + let narrow = style(ROW_TITLE_SIZE, cairo::FontWeight::Normal); + + let trimmed = fit(engine, &ctx, "Setup, config, troubleshooting", narrow, 40.0); + + assert!(trimmed.len() < "Setup, config, troubleshooting".len()); + assert!(advance(engine, narrow, &trimmed) <= 40.0); +} diff --git a/src/about_window/render/mod.rs b/src/about_window/render/mod.rs index f6c2deff5..77b495833 100644 --- a/src/about_window/render/mod.rs +++ b/src/about_window/render/mod.rs @@ -61,6 +61,7 @@ impl AboutWindowState { } draw::draw_about( + &self.ui_text, &ctx, &self.theme, &draw::Frame { diff --git a/src/about_window/render/text.rs b/src/about_window/render/text.rs index 7cced42fa..4f1f041cd 100644 --- a/src/about_window/render/text.rs +++ b/src/about_window/render/text.rs @@ -1,15 +1,14 @@ -use crate::ui_text::{UiTextStyle, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; pub(super) fn draw_text( + engine: &UiTextEngine, ctx: &cairo::Context, style: UiTextStyle<'_>, x: f64, y: f64, text: &str, ) -> (f64, f64, f64, f64) { - let layout = text_layout(ctx, style, text, None); - let extents = layout.ink_extents(); - layout.show_at_baseline(ctx, x, y); + let extents = engine.draw_baseline(ctx, style, text, x, y, None); ( x + extents.x_bearing(), y + extents.y_bearing(), diff --git a/src/about_window/state.rs b/src/about_window/state.rs index b6adddfda..0ca45089b 100644 --- a/src/about_window/state.rs +++ b/src/about_window/state.rs @@ -37,6 +37,7 @@ impl AboutWindowState { let (width, height) = surface_size(&plan); Self { + ui_text: crate::ui_text::UiTextEngine::default(), theme, registry_state, compositor_state, diff --git a/src/ui.rs b/src/ui.rs index 81dbb3461..1f3a9a8b9 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -59,7 +59,7 @@ pub(crate) use ocr_scan::{ pub use onboarding_card::{OnboardingCard, OnboardingChecklistItem, render_onboarding_card}; pub use precision_entry::render_precision_entry_popup; /// Shared measured-text trimming, also used by the standalone about dialog. -pub(crate) use primitives::ellipsize_to_fit; +pub(crate) use primitives::ellipsize_to_fit_with_engine; pub(crate) use primitives::{checkerboard_behind, draw_pill}; pub use properties_panel::render_properties_panel; pub use radial_menu::render_radial_menu; diff --git a/src/ui/primitives.rs b/src/ui/primitives.rs index 9b8c49368..b292521ad 100644 --- a/src/ui/primitives.rs +++ b/src/ui/primitives.rs @@ -1,7 +1,7 @@ use std::f64::consts::{FRAC_PI_2, PI}; use crate::ui::theme::{self, Rgba}; -use crate::ui_text::{UiTextStyle, measure_text, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle, measure_text, text_layout, with_legacy_engine}; pub(crate) fn text_extents_for( ctx: &cairo::Context, @@ -11,7 +11,21 @@ pub(crate) fn text_extents_for( size: f64, text: &str, ) -> cairo::TextExtents { - let layout = text_layout( + with_legacy_engine(|engine| { + text_extents_for_with_engine(engine, ctx, family, slant, weight, size, text) + }) +} + +pub(crate) fn text_extents_for_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + family: &str, + slant: cairo::FontSlant, + weight: cairo::FontWeight, + size: f64, + text: &str, +) -> cairo::TextExtents { + let layout = engine.layout( ctx, UiTextStyle { family, @@ -39,7 +53,22 @@ pub(crate) fn ellipsize_to_fit( weight: cairo::FontWeight, max_width: f64, ) -> String { - let extents = text_extents_for( + with_legacy_engine(|engine| { + ellipsize_to_fit_with_engine(engine, ctx, text, font_family, font_size, weight, max_width) + }) +} + +pub(crate) fn ellipsize_to_fit_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + text: &str, + font_family: &str, + font_size: f64, + weight: cairo::FontWeight, + max_width: f64, +) -> String { + let extents = text_extents_for_with_engine( + engine, ctx, font_family, cairo::FontSlant::Normal, @@ -52,7 +81,8 @@ pub(crate) fn ellipsize_to_fit( } let ellipsis = ELLIPSIS; - let ellipsis_extents = text_extents_for( + let ellipsis_extents = text_extents_for_with_engine( + engine, ctx, font_family, cairo::FontSlant::Normal, @@ -71,7 +101,8 @@ pub(crate) fn ellipsize_to_fit( continue; } let candidate = format!("{}{}", &text[..end], ellipsis); - let candidate_extents = text_extents_for( + let candidate_extents = text_extents_for_with_engine( + engine, ctx, font_family, cairo::FontSlant::Normal, diff --git a/src/ui_text.rs b/src/ui_text.rs index 924644b8a..c58e5d43f 100644 --- a/src/ui_text.rs +++ b/src/ui_text.rs @@ -135,29 +135,59 @@ impl UiLayoutCache { } } +/// Shaped UI text and context-free measurement resources for one owner. +/// Cairo resources are created only when first used. Keep this owner on its +/// rendering thread, outside snapshots and input state. +pub(crate) struct UiTextEngine { + layouts: RefCell, + measurement: RefCell>, +} + +impl Default for UiTextEngine { + fn default() -> Self { + Self { + layouts: RefCell::new(UiLayoutCache::new(512)), + measurement: RefCell::new(None), + } + } +} + +// Temporary migration bridge for unmigrated overlay/toolbar/export roots. +// Remove when every production caller receives an explicit engine. thread_local! { - static UI_LAYOUT_CACHE: RefCell = RefCell::new(UiLayoutCache::new(512)); - /// Shared 1x1 surface + context for measuring text without a target surface. - static MEASUREMENT_CONTEXT: RefCell> = const { RefCell::new(None) }; + static LEGACY_UI_TEXT: UiTextEngine = UiTextEngine::default(); +} + +pub(crate) fn with_legacy_engine(f: impl FnOnce(&UiTextEngine) -> T) -> T { + LEGACY_UI_TEXT.with(f) } -/// Measure UI text without a rendering context (e.g. for damage computation -/// before a frame buffer exists). Goes through the same layout cache as -/// `text_layout`, so measurements agree exactly with subsequent rendering. pub(crate) fn measure_text( style: UiTextStyle<'_>, text: &str, wrap_width: Option, ) -> Option { - MEASUREMENT_CONTEXT.with(|cell| { - let mut ctx_ref = cell.borrow_mut(); - if ctx_ref.is_none() { - let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).ok()?; - *ctx_ref = cairo::Context::new(&surface).ok(); - } - let ctx = ctx_ref.as_ref()?; - Some(text_layout(ctx, style, text, wrap_width).ink_extents()) - }) + with_legacy_engine(|engine| engine.measure(style, text, wrap_width)) +} + +impl UiTextEngine { + /// Measure before a target exists, using the same layout cache as painting. + pub(crate) fn measure( + &self, + style: UiTextStyle<'_>, + text: &str, + wrap_width: Option, + ) -> Option { + let ctx = { + let mut stored = self.measurement.borrow_mut(); + if stored.is_none() { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).ok()?; + *stored = cairo::Context::new(&surface).ok(); + } + stored.as_ref()?.clone() + }; + Some(self.layout(&ctx, style, text, wrap_width).ink_extents()) + } } fn slant_key(slant: cairo::FontSlant) -> u8 { @@ -181,70 +211,92 @@ pub(crate) fn text_layout( text: &str, wrap_width: Option, ) -> UiTextLayout { - let wrap_units = wrap_width.map_or(-1, |width| to_pango_units(width.max(1.0))); - let key = UiLayoutCacheKey { - family: style.family.to_string(), - slant: slant_key(style.slant), - weight: weight_key(style.weight), - size_hundredths: (style.size * 100.0).round() as i64, - wrap_units, - text: text.to_string(), - }; - - let cached = UI_LAYOUT_CACHE.with(|cache| cache.borrow_mut().get(&key)); - if let Some(layout) = cached { - // Re-bind the cached layout to the current Cairo context (font options, - // resolution, transformation) without re-shaping the text. - pangocairo::functions::update_layout(ctx, &layout); - let (ink_rect, logical_rect, baseline) = layout_metrics(&layout); - return UiTextLayout { - layout, - ink_rect, - logical_rect, - baseline, + with_legacy_engine(|engine| engine.layout(ctx, style, text, wrap_width)) +} + +pub(crate) fn draw_text_baseline( + ctx: &cairo::Context, + style: UiTextStyle<'_>, + text: &str, + x: f64, + y: f64, + wrap_width: Option, +) -> UiTextExtents { + with_legacy_engine(|engine| engine.draw_baseline(ctx, style, text, x, y, wrap_width)) +} + +impl UiTextEngine { + pub(crate) fn layout( + &self, + ctx: &cairo::Context, + style: UiTextStyle<'_>, + text: &str, + wrap_width: Option, + ) -> UiTextLayout { + let wrap_units = wrap_width.map_or(-1, |width| to_pango_units(width.max(1.0))); + let key = UiLayoutCacheKey { + family: style.family.to_string(), + slant: slant_key(style.slant), + weight: weight_key(style.weight), + size_hundredths: (style.size * 100.0).round() as i64, + wrap_units, + text: text.to_string(), }; - } - let layout = pangocairo::functions::create_layout(ctx); - let font_desc = font_description(style); - layout.set_font_description(Some(&font_desc)); - layout.set_text(text); - if wrap_units >= 0 { - layout.set_width(wrap_units); - layout.set_wrap(pango::WrapMode::WordChar); - } - let (ink_rect, logical_rect, baseline) = layout_metrics(&layout); + let cached = self.layouts.borrow_mut().get(&key); + if let Some(layout) = cached { + // Re-bind the cached layout to the current Cairo context (font options, + // resolution, transformation) without re-shaping the text. + pangocairo::functions::update_layout(ctx, &layout); + let (ink_rect, logical_rect, baseline) = layout_metrics(&layout); + return UiTextLayout { + layout, + ink_rect, + logical_rect, + baseline, + }; + } - UI_LAYOUT_CACHE.with(|cache| { - cache.borrow_mut().insert( + let layout = pangocairo::functions::create_layout(ctx); + let font_desc = font_description(style); + layout.set_font_description(Some(&font_desc)); + layout.set_text(text); + if wrap_units >= 0 { + layout.set_width(wrap_units); + layout.set_wrap(pango::WrapMode::WordChar); + } + let (ink_rect, logical_rect, baseline) = layout_metrics(&layout); + + self.layouts.borrow_mut().insert( key, CachedUiLayout { layout: layout.clone(), last_used: 0, }, ); - }); - UiTextLayout { - layout, - ink_rect, - logical_rect, - baseline, + UiTextLayout { + layout, + ink_rect, + logical_rect, + baseline, + } } -} -pub(crate) fn draw_text_baseline( - ctx: &cairo::Context, - style: UiTextStyle<'_>, - text: &str, - x: f64, - y: f64, - wrap_width: Option, -) -> UiTextExtents { - let layout = text_layout(ctx, style, text, wrap_width); - let extents = layout.ink_extents(); - layout.show_at_baseline(ctx, x, y); - extents + pub(crate) fn draw_baseline( + &self, + ctx: &cairo::Context, + style: UiTextStyle<'_>, + text: &str, + x: f64, + y: f64, + wrap_width: Option, + ) -> UiTextExtents { + let layout = self.layout(ctx, style, text, wrap_width); + let extents = layout.ink_extents(); + layout.show_at_baseline(ctx, x, y); + extents + } } fn rect_to_extents( @@ -306,99 +358,4 @@ fn layout_metrics(layout: &pango::Layout) -> (pango::Rectangle, pango::Rectangle } #[cfg(test)] -mod tests { - use super::*; - - fn style(size: f64) -> UiTextStyle<'static> { - UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Bold, - size, - } - } - - fn uncached_text_extents( - ctx: &cairo::Context, - style: UiTextStyle<'_>, - text: &str, - wrap_width: Option, - ) -> UiTextExtents { - let layout = pangocairo::functions::create_layout(ctx); - let font_desc = font_description(style); - layout.set_font_description(Some(&font_desc)); - layout.set_text(text); - if let Some(width) = wrap_width { - layout.set_width(to_pango_units(width.max(1.0))); - layout.set_wrap(pango::WrapMode::WordChar); - } - let (ink_rect, logical_rect, baseline) = layout_metrics(&layout); - rect_to_extents(ink_rect, logical_rect, baseline) - } - - fn assert_extents_eq(actual: UiTextExtents, expected: UiTextExtents) { - assert_eq!(actual.width(), expected.width()); - assert_eq!(actual.height(), expected.height()); - assert_eq!(actual.x_bearing(), expected.x_bearing()); - assert_eq!(actual.y_bearing(), expected.y_bearing()); - } - - #[test] - fn cached_layout_returns_identical_extents() { - let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - - let first = text_layout(&ctx, style(15.0), "cache me", None).ink_extents(); - let second = text_layout(&ctx, style(15.0), "cache me", None).ink_extents(); - - assert_eq!(first.width(), second.width()); - assert_eq!(first.height(), second.height()); - assert_eq!(first.x_bearing(), second.x_bearing()); - assert_eq!(first.y_bearing(), second.y_bearing()); - } - - #[test] - fn cached_layout_recomputes_extents_after_context_update() { - let first_surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 64, 64).unwrap(); - let first_ctx = cairo::Context::new(&first_surface).unwrap(); - let scaled_surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 64, 64).unwrap(); - let scaled_ctx = cairo::Context::new(&scaled_surface).unwrap(); - scaled_ctx.scale(2.0, 2.0); - - let style = style(18.0); - let text = "cache scale"; - let _ = text_layout(&first_ctx, style, text, None).ink_extents(); - let expected_scaled = uncached_text_extents(&scaled_ctx, style, text, None); - - let cached_scaled = text_layout(&scaled_ctx, style, text, None).ink_extents(); - assert_extents_eq(cached_scaled, expected_scaled); - } - - #[test] - fn measure_text_matches_rendered_layout_extents() { - let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - - let measured = measure_text(style(16.0), "toast body", None).expect("measurement"); - let rendered = text_layout(&ctx, style(16.0), "toast body", None).ink_extents(); - - assert_eq!(measured.width(), rendered.width()); - assert_eq!(measured.height(), rendered.height()); - } - - #[test] - fn different_styles_produce_distinct_cache_entries() { - let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - - let small = text_layout(&ctx, style(10.0), "sized", None).ink_extents(); - let large = text_layout(&ctx, style(30.0), "sized", None).ink_extents(); - assert!(large.width() > small.width()); - - // Wrap width participates in the key: same text, different layouts. - let unwrapped = text_layout(&ctx, style(12.0), "wrap wrap wrap wrap", None).ink_extents(); - let wrapped = - text_layout(&ctx, style(12.0), "wrap wrap wrap wrap", Some(40.0)).ink_extents(); - assert!(wrapped.height() >= unwrapped.height()); - } -} +mod tests; diff --git a/src/ui_text/tests.rs b/src/ui_text/tests.rs new file mode 100644 index 000000000..fa248c6e7 --- /dev/null +++ b/src/ui_text/tests.rs @@ -0,0 +1,265 @@ +use super::*; + +fn style(size: f64) -> UiTextStyle<'static> { + UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size, + } +} + +fn uncached_text_extents( + ctx: &cairo::Context, + style: UiTextStyle<'_>, + text: &str, + wrap_width: Option, +) -> UiTextExtents { + let layout = pangocairo::functions::create_layout(ctx); + let font_desc = font_description(style); + layout.set_font_description(Some(&font_desc)); + layout.set_text(text); + if let Some(width) = wrap_width { + layout.set_width(to_pango_units(width.max(1.0))); + layout.set_wrap(pango::WrapMode::WordChar); + } + let (ink_rect, logical_rect, baseline) = layout_metrics(&layout); + rect_to_extents(ink_rect, logical_rect, baseline) +} + +fn assert_extents_eq(actual: UiTextExtents, expected: UiTextExtents) { + assert_eq!(actual.width(), expected.width()); + assert_eq!(actual.height(), expected.height()); + assert_eq!(actual.x_bearing(), expected.x_bearing()); + assert_eq!(actual.y_bearing(), expected.y_bearing()); + assert_eq!(actual.x_advance(), expected.x_advance()); + assert_eq!(actual.y_advance, expected.y_advance); +} + +#[test] +fn cached_layout_returns_identical_extents() { + let engine = UiTextEngine::default(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + + let first = engine + .layout(&ctx, style(15.0), "cache me", None) + .ink_extents(); + let second = engine + .layout(&ctx, style(15.0), "cache me", None) + .ink_extents(); + + assert_eq!(first.width(), second.width()); + assert_eq!(first.height(), second.height()); + assert_eq!(first.x_bearing(), second.x_bearing()); + assert_eq!(first.y_bearing(), second.y_bearing()); +} + +#[test] +fn cached_layout_recomputes_extents_after_context_update() { + let engine = UiTextEngine::default(); + let first_surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 64, 64).unwrap(); + let first_ctx = cairo::Context::new(&first_surface).unwrap(); + let scaled_surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 64, 64).unwrap(); + let scaled_ctx = cairo::Context::new(&scaled_surface).unwrap(); + scaled_ctx.scale(2.0, 2.0); + + let style = style(18.0); + let text = "cache scale"; + let _ = engine.layout(&first_ctx, style, text, None).ink_extents(); + let expected_scaled = uncached_text_extents(&scaled_ctx, style, text, None); + + let cached_scaled = engine.layout(&scaled_ctx, style, text, None).ink_extents(); + assert_extents_eq(cached_scaled, expected_scaled); +} + +#[test] +fn measure_text_matches_rendered_layout_extents() { + let engine = UiTextEngine::default(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + + let measured = engine + .measure(style(16.0), "toast body", None) + .expect("measurement"); + let rendered = engine + .layout(&ctx, style(16.0), "toast body", None) + .ink_extents(); + + assert_eq!(measured.width(), rendered.width()); + assert_eq!(measured.height(), rendered.height()); +} + +#[test] +fn different_styles_produce_distinct_cache_entries() { + let engine = UiTextEngine::default(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + + let small = engine + .layout(&ctx, style(10.0), "sized", None) + .ink_extents(); + let large = engine + .layout(&ctx, style(30.0), "sized", None) + .ink_extents(); + assert!(large.width() > small.width()); + + // Wrap width participates in the key: same text, different layouts. + let unwrapped = engine + .layout(&ctx, style(12.0), "wrap wrap wrap wrap", None) + .ink_extents(); + let wrapped = engine + .layout(&ctx, style(12.0), "wrap wrap wrap wrap", Some(40.0)) + .ink_extents(); + assert!(wrapped.height() >= unwrapped.height()); +} + +#[test] +fn owners_create_measurement_contexts_lazily_and_keep_layouts_independent() { + let first = UiTextEngine::default(); + let second = UiTextEngine::default(); + assert!(first.measurement.borrow().is_none()); + assert!(second.measurement.borrow().is_none()); + assert!(first.layouts.borrow().entries.is_empty()); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + let a = first.layout(&ctx, style(16.0), "owner", None); + let hit = first.layout(&ctx, style(16.0), "owner", None); + let b = second.layout(&ctx, style(16.0), "owner", None); + assert_eq!( + a.layout, hit.layout, + "one owner reuses its shaped allocation" + ); + assert_ne!( + a.layout, b.layout, + "independent owners must not alias layouts" + ); + assert!( + first.measurement.borrow().is_none(), + "painting does not require dummy context" + ); + first.measure(style(16.0), "owner", None).unwrap(); + assert!(first.measurement.borrow().is_some()); + assert!(second.measurement.borrow().is_none()); +} + +#[test] +fn lru_retains_512_layouts_and_hits_promote_before_eviction() { + let engine = UiTextEngine::default(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + let first = engine.layout(&ctx, style(12.0), "entry 0", None); + let second = engine.layout(&ctx, style(12.0), "entry 1", None); + for index in 2..512 { + engine.layout(&ctx, style(12.0), &format!("entry {index}"), None); + } + assert_eq!(engine.layouts.borrow().entries.len(), 512); + assert_eq!( + engine.layout(&ctx, style(12.0), "entry 0", None).layout, + first.layout + ); + engine.layout(&ctx, style(12.0), "overflow", None); + assert_eq!(engine.layouts.borrow().entries.len(), 512); + assert_eq!( + engine.layout(&ctx, style(12.0), "entry 0", None).layout, + first.layout + ); + assert_ne!( + engine.layout(&ctx, style(12.0), "entry 1", None).layout, + second.layout + ); +} + +#[test] +fn measure_scaled_paint_measure_restores_canonical_context_metrics() { + let engine = UiTextEngine::default(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 400, 160).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(1.5, 2.0); + let mut options = cairo::FontOptions::new().unwrap(); + options.set_hint_metrics(cairo::HintMetrics::Off); + options.set_antialias(cairo::Antialias::Gray); + ctx.set_font_options(&options); + for text in [ + "", + "Hello العربية עברית", + "Wrap words across multiple visual lines", + ] { + for wrap in [None, Some(70.0)] { + let before = engine.measure(style(18.0), text, wrap).unwrap(); + let painted = engine.draw_baseline(&ctx, style(18.0), text, 2.0, 30.0, wrap); + assert_extents_eq( + painted, + uncached_text_extents(&ctx, style(18.0), text, wrap), + ); + let after = engine.measure(style(18.0), text, wrap).unwrap(); + assert_extents_eq(after, before); + } + } +} + +#[test] +fn cache_keys_keep_font_categories_quantized_size_and_wrap_units() { + let engine = UiTextEngine::default(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + let baseline = style(16.0); + let first = engine.layout(&ctx, baseline, "key", None); + let quantized = UiTextStyle { + size: 16.001, + ..baseline + }; + assert_eq!( + first.layout, + engine.layout(&ctx, quantized, "key", None).layout + ); + for changed in [ + UiTextStyle { + family: "Monospace", + ..baseline + }, + UiTextStyle { + slant: cairo::FontSlant::Italic, + ..baseline + }, + UiTextStyle { + weight: cairo::FontWeight::Normal, + ..baseline + }, + UiTextStyle { + size: 17.0, + ..baseline + }, + ] { + assert_ne!( + first.layout, + engine.layout(&ctx, changed, "key", None).layout + ); + } + assert_ne!( + first.layout, + engine.layout(&ctx, baseline, "other", None).layout + ); + let minimum = engine.layout(&ctx, baseline, "key", Some(1.0)); + assert_eq!( + minimum.layout, + engine.layout(&ctx, baseline, "key", Some(-10.0)).layout + ); + assert_ne!( + minimum.layout, + engine.layout(&ctx, baseline, "key", Some(2.0)).layout + ); +} + +#[test] +fn temporary_legacy_bridge_retains_layouts_and_matches_an_explicit_owner() { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + let first = text_layout(&ctx, style(14.0), "legacy bridge", Some(50.0)); + let second = text_layout(&ctx, style(14.0), "legacy bridge", Some(50.0)); + assert_eq!(first.layout, second.layout); + let engine = UiTextEngine::default(); + let explicit = engine.layout(&ctx, style(14.0), "legacy bridge", Some(50.0)); + assert_ne!(first.layout, explicit.layout); + assert_extents_eq(first.ink_extents(), explicit.ink_extents()); +} From f4ecd7768a9968e18f28153f153c67791e0c1580 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:27:43 +0200 Subject: [PATCH 03/42] refactor(draw): extract canonical text measurement ownership --- src/draw/shape/text_cache.rs | 496 +++-------------------- src/draw/shape/text_cache/cache.rs | 76 ++++ src/draw/shape/text_cache/cursor.rs | 179 ++++++++ src/draw/shape/text_cache/owner.rs | 87 ++++ src/draw/shape/text_cache/owner/tests.rs | 192 +++++++++ src/draw/shape/text_cache/tests.rs | 190 +++++++++ 6 files changed, 778 insertions(+), 442 deletions(-) create mode 100644 src/draw/shape/text_cache/cache.rs create mode 100644 src/draw/shape/text_cache/cursor.rs create mode 100644 src/draw/shape/text_cache/owner.rs create mode 100644 src/draw/shape/text_cache/owner/tests.rs create mode 100644 src/draw/shape/text_cache/tests.rs diff --git a/src/draw/shape/text_cache.rs b/src/draw/shape/text_cache.rs index b8b9e7053..83992ce0c 100644 --- a/src/draw/shape/text_cache.rs +++ b/src/draw/shape/text_cache.rs @@ -1,5 +1,9 @@ -use std::cell::RefCell; -use std::collections::HashMap; +mod cache; +mod cursor; +mod owner; + +use cache::{TextCacheKey, TextMeasurementCache}; +use owner::TextMeasurer; /// Cached text measurement results from Pango layout. #[derive(Clone, Debug)] @@ -43,107 +47,13 @@ impl TextMeasurement { } } -/// Cache key for text measurements. -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -struct TextCacheKey { - text: String, - font_desc_str: String, - /// Size in hundredths of points for stable hashing - size_hundredths: i32, - /// Wrap width in pixels, or -1 for no wrap - wrap_width: i32, -} - -impl TextCacheKey { - fn new(text: &str, font_desc_str: &str, size: f64, wrap_width: Option) -> Self { - Self { - text: text.to_string(), - font_desc_str: font_desc_str.to_string(), - size_hundredths: (size * 100.0).round() as i32, - wrap_width: wrap_width.unwrap_or(-1), - } - } -} - -/// Thread-local cache for text measurements. -/// Uses an LRU-style eviction when cache exceeds max size. -struct TextMeasurementCache { - entries: HashMap, - access_order: Vec, - max_entries: usize, -} - -impl TextMeasurementCache { - fn new(max_entries: usize) -> Self { - Self { - entries: HashMap::with_capacity(max_entries), - access_order: Vec::with_capacity(max_entries), - max_entries, - } - } - - fn get(&mut self, key: &TextCacheKey) -> Option { - if let Some(measurement) = self.entries.get(key) { - // Move to end of access order (most recently used) - if let Some(pos) = self.access_order.iter().position(|k| k == key) { - self.access_order.remove(pos); - self.access_order.push(key.clone()); - } - Some(measurement.clone()) - } else { - None - } - } - - fn insert(&mut self, key: TextCacheKey, measurement: TextMeasurement) { - // If key already exists, update it and move to end of access order - if self.entries.contains_key(&key) { - self.entries.insert(key.clone(), measurement); - if let Some(pos) = self.access_order.iter().position(|k| k == &key) { - self.access_order.remove(pos); - } - self.access_order.push(key); - return; - } - - // Evict oldest entries if at capacity - while self.entries.len() >= self.max_entries && !self.access_order.is_empty() { - let oldest = self.access_order.remove(0); - self.entries.remove(&oldest); - } - - self.entries.insert(key.clone(), measurement); - self.access_order.push(key); - } - - #[allow(dead_code)] - fn clear(&mut self) { - self.entries.clear(); - self.access_order.clear(); - } -} - thread_local! { - static TEXT_CACHE: RefCell = RefCell::new(TextMeasurementCache::new(256)); - /// Shared dummy surface for measurement when no context available - static MEASUREMENT_SURFACE: RefCell> = const { RefCell::new(None) }; + // Temporary bridge for callers being migrated to explicit ownership. + static LEGACY_TEXT_MEASURER: TextMeasurer = TextMeasurer::default(); } -/// Get or create a measurement context (reuses a single surface instead of creating new ones). -fn with_measurement_context(f: F) -> Option -where - F: FnOnce(&cairo::Context) -> R, -{ - MEASUREMENT_SURFACE.with(|cell| { - let mut surface_ref = cell.borrow_mut(); - if surface_ref.is_none() { - let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).ok()?; - let ctx = cairo::Context::new(&surface).ok()?; - ctx.set_antialias(cairo::Antialias::Best); - *surface_ref = Some((surface, ctx)); - } - surface_ref.as_ref().map(|(_, ctx)| f(ctx)) - }) +fn with_legacy_measurer(f: impl FnOnce(&TextMeasurer) -> R) -> R { + LEGACY_TEXT_MEASURER.with(f) } /// Build a Pango layout configured exactly like the measurement and render @@ -189,51 +99,14 @@ pub(crate) fn measure_text_cached( size: f64, wrap_width: Option, ) -> Option { - if text.is_empty() { - return None; - } - - let key = TextCacheKey::new(text, font_desc_str, size, wrap_width); - - // Check cache first - let cached = TEXT_CACHE.with(|cache| cache.borrow_mut().get(&key)); - if let Some(measurement) = cached { - return Some(measurement); - } - - // Measure using shared context - let measurement = with_measurement_context(|ctx| { - let layout = configured_layout(ctx, text, font_desc_str, wrap_width); - - let (ink_rect, logical_rect) = layout.extents(); - let scale = pango::SCALE as f64; - - TextMeasurement { - ink_x: ink_rect.x() as f64 / scale, - ink_y: ink_rect.y() as f64 / scale, - ink_width: ink_rect.width() as f64 / scale, - ink_height: ink_rect.height() as f64 / scale, - logical_x: logical_rect.x() as f64 / scale, - logical_y: logical_rect.y() as f64 / scale, - logical_width: logical_rect.width() as f64 / scale, - logical_height: logical_rect.height() as f64 / scale, - baseline: layout.baseline() as f64 / scale, - } - })?; - - // Cache the result - TEXT_CACHE.with(|cache| { - cache.borrow_mut().insert(key, measurement.clone()); - }); - - Some(measurement) + with_legacy_measurer(|measurer| measurer.measure(text, font_desc_str, size, wrap_width)) } /// Measure text using cached measurements. /// The `_ctx` parameter is kept for API compatibility but measurements always /// use a shared context for consistency across different rendering contexts. -/// Pango measurements are resolution-independent (in Pango units), so using -/// a consistent measurement context ensures cache correctness. +/// Geometry stays stable because destination settings are ignored and all +/// measurements use the same canonical context. pub(crate) fn measure_text_with_context( _ctx: &cairo::Context, text: &str, @@ -241,10 +114,7 @@ pub(crate) fn measure_text_with_context( size: f64, wrap_width: Option, ) -> Option { - // Delegate to measure_text_cached for consistent measurements. - // Pango units are resolution-independent, so the measurement context - // settings (scale, font options) don't affect the results. - measure_text_cached(text, font_desc_str, size, wrap_width) + with_legacy_measurer(|measurer| measurer.measure(text, font_desc_str, size, wrap_width)) } /// Hit-test a point against a rendered text run, returning the caret byte @@ -261,26 +131,14 @@ pub(crate) fn hit_test_text( local_x: f64, local_y_from_baseline: f64, ) -> Option { - if text.is_empty() { - return Some(0); - } - with_measurement_context(|ctx| { - let layout = configured_layout(ctx, text, font_desc_str, wrap_width); - - let scale = pango::SCALE as f64; - // Convert the baseline-relative y into the layout's top-left frame. - let local_y = local_y_from_baseline + layout.baseline() as f64 / scale; - let x_pu = (local_x * scale) - .round() - .clamp(i32::MIN as f64, i32::MAX as f64) as i32; - let y_pu = (local_y * scale) - .round() - .clamp(i32::MIN as f64, i32::MAX as f64) as i32; - let (_inside, index, trailing) = layout.xy_to_index(x_pu, y_pu); - - // Advance past `trailing` characters so a click on a glyph's right half - // lands the caret after it, keeping the result on a char boundary. - hit_position_to_byte(text, index, trailing) + with_legacy_measurer(|measurer| { + measurer.hit_test_text( + text, + font_desc_str, + wrap_width, + local_x, + local_y_from_baseline, + ) }) } @@ -311,23 +169,14 @@ pub(crate) fn caret_on_adjacent_visual_position( byte_index: usize, direction: VisualCaretDirection, ) -> Option { - with_measurement_context(|ctx| { - let layout = configured_layout(ctx, text, font_desc_str, wrap_width); - - let index = snap_char_boundary(text, byte_index); - let old_index = i32::try_from(index).unwrap_or(i32::MAX); - let direction = match direction { - VisualCaretDirection::Left => -1, - VisualCaretDirection::Right => 1, - }; - let (new_index, trailing) = layout.move_cursor_visually(true, old_index, 0, direction); - - // Pango uses sentinels when movement would leave either visual edge of - // the layout. Keep the current logical position at those boundaries. - if new_index < 0 || new_index == i32::MAX { - return index; - } - hit_position_to_byte(text, new_index, trailing) + with_legacy_measurer(|measurer| { + measurer.caret_on_adjacent_visual_position( + text, + font_desc_str, + wrap_width, + byte_index, + direction, + ) }) } @@ -342,27 +191,15 @@ pub(crate) fn caret_at_visual_selection_edge( end: usize, direction: VisualCaretDirection, ) -> Option { - with_measurement_context(|ctx| { - let layout = configured_layout(ctx, text, font_desc_str, wrap_width); - - let start = start.min(text.len()); - let end = end.min(text.len()); - let (start_line, start_x) = - layout.index_to_line_x(i32::try_from(start).unwrap_or(i32::MAX), false); - let (end_line, end_x) = - layout.index_to_line_x(i32::try_from(end).unwrap_or(i32::MAX), false); - if start_line != end_line { - return match direction { - VisualCaretDirection::Left => start, - VisualCaretDirection::Right => end, - }; - } - match direction { - VisualCaretDirection::Left if start_x <= end_x => start, - VisualCaretDirection::Left => end, - VisualCaretDirection::Right if start_x >= end_x => start, - VisualCaretDirection::Right => end, - } + with_legacy_measurer(|measurer| { + measurer.caret_at_visual_selection_edge( + text, + font_desc_str, + wrap_width, + start, + end, + direction, + ) }) } @@ -375,26 +212,8 @@ pub(crate) fn caret_on_visual_line_edge( byte_index: usize, edge: VisualLineEdge, ) -> Option { - with_measurement_context(|ctx| { - let layout = configured_layout(ctx, text, font_desc_str, wrap_width); - - let index = snap_char_boundary(text, byte_index); - let (line_index, _) = - layout.index_to_line_x(i32::try_from(index).unwrap_or(i32::MAX), false); - let Some(line) = layout.line_readonly(line_index) else { - return index; - }; - let start = usize::try_from(line.start_index()).unwrap_or(0); - let mut end = start - .saturating_add(usize::try_from(line.length()).unwrap_or(0)) - .min(text.len()); - if end > start && text.as_bytes().get(end - 1) == Some(&b'\n') { - end -= 1; - } - match edge { - VisualLineEdge::Start => start, - VisualLineEdge::End => end, - } + with_legacy_measurer(|measurer| { + measurer.caret_on_visual_line_edge(text, font_desc_str, wrap_width, byte_index, edge) }) } @@ -409,25 +228,14 @@ pub(crate) fn caret_on_adjacent_visual_line( byte_index: usize, direction: VisualLineDirection, ) -> Option { - with_measurement_context(|ctx| { - let layout = configured_layout(ctx, text, font_desc_str, wrap_width); - - let index = snap_char_boundary(text, byte_index); - let index_i32 = i32::try_from(index).unwrap_or(i32::MAX); - let (line_index, x) = layout.index_to_line_x(index_i32, false); - let target_line = match direction { - VisualLineDirection::Up if line_index == 0 => return 0, - VisualLineDirection::Up => line_index - 1, - VisualLineDirection::Down if line_index + 1 >= layout.line_count() => { - return text.len(); - } - VisualLineDirection::Down => line_index + 1, - }; - let Some(line) = layout.line_readonly(target_line) else { - return index; - }; - let hit = line.x_to_index(x); - hit_position_to_byte(text, hit.index(), hit.trailing()) + with_legacy_measurer(|measurer| { + measurer.caret_on_adjacent_visual_line( + text, + font_desc_str, + wrap_width, + byte_index, + direction, + ) }) } @@ -468,9 +276,8 @@ pub(crate) fn caret_geometry_text( wrap_width: Option, byte_index: usize, ) -> Option { - with_measurement_context(|ctx| { - let layout = configured_layout(ctx, text, font_desc_str, wrap_width); - caret_geometry_in(&layout, text, byte_index) + with_legacy_measurer(|measurer| { + measurer.caret_geometry_text(text, font_desc_str, wrap_width, byte_index) }) } @@ -507,12 +314,8 @@ pub(crate) fn text_preview_geometry( wrap_width: Option, byte_index: Option, ) -> Option { - with_measurement_context(|ctx| { - let layout = configured_layout(ctx, text, font_desc_str, wrap_width); - TextPreviewGeometry { - caret: byte_index.map(|byte_index| caret_geometry_in(&layout, text, byte_index)), - logical: logical_bounds_in(&layout), - } + with_legacy_measurer(|measurer| { + measurer.text_preview_geometry(text, font_desc_str, wrap_width, byte_index) }) } @@ -542,195 +345,4 @@ fn logical_bounds_in(layout: &pango::Layout) -> LogicalBounds { } #[cfg(test)] -mod tests { - use super::*; - - fn measurement(width: f64) -> TextMeasurement { - TextMeasurement { - ink_x: 0.0, - ink_y: 0.0, - ink_width: width, - ink_height: 10.0, - logical_x: 0.0, - logical_y: 0.0, - logical_width: width, - logical_height: 10.0, - baseline: 8.0, - } - } - - #[test] - fn hit_test_maps_x_extremes_to_buffer_ends() { - // Far-left click lands at the start; far-right at the end; the exact - // glyph widths do not matter, only the ordering and clamping. - assert_eq!( - hit_test_text("hello", "Sans 20", None, -100.0, 0.0), - Some(0) - ); - assert_eq!( - hit_test_text("hello", "Sans 20", None, 100_000.0, 0.0), - Some(5) - ); - // Empty text always resolves to caret 0. - assert_eq!(hit_test_text("", "Sans 20", None, 42.0, 0.0), Some(0)); - } - - #[test] - fn hit_test_result_is_always_a_char_boundary() { - // '你好' is two 3-byte chars; any x must land on 0, 3, or 6. - let text = "你好"; - for x in [-10.0, 0.0, 5.0, 12.0, 30.0, 1000.0] { - let offset = hit_test_text(text, "Sans 20", None, x, 0.0).unwrap(); - assert!( - text.is_char_boundary(offset), - "offset {offset} split a char" - ); - } - } - - #[test] - fn caret_geometry_advances_left_to_right_and_has_height() { - let start = caret_geometry_text("hello", "Sans 20", None, 0).unwrap(); - let end = caret_geometry_text("hello", "Sans 20", None, 5).unwrap(); - assert!(start.x >= 0.0); - assert!( - end.x > start.x, - "the end caret must sit to the right of the start caret" - ); - assert!(start.height > 0.0, "the caret must have a visible height"); - } - - #[test] - fn caret_geometry_works_on_empty_text() { - // An empty buffer still needs a visible caret at the origin. - let geom = caret_geometry_text("", "Sans 20", None, 0).unwrap(); - assert_eq!(geom.x, 0.0); - assert!(geom.height > 0.0); - } - - #[test] - fn caret_geometry_snaps_off_boundary_indices_down() { - // Byte 2 is inside the 3-byte '你'; it must resolve like byte 0, not panic. - let at_zero = caret_geometry_text("你a", "Sans 20", None, 0).unwrap(); - let off_boundary = caret_geometry_text("你a", "Sans 20", None, 2).unwrap(); - assert_eq!(at_zero, off_boundary); - } - - #[test] - fn test_cache_returns_same_measurement() { - let text = "Hello World"; - let font = "Sans 12"; - - let m1 = measure_text_cached(text, font, 12.0, None); - let m2 = measure_text_cached(text, font, 12.0, None); - - assert!(m1.is_some()); - assert!(m2.is_some()); - - let m1 = m1.unwrap(); - let m2 = m2.unwrap(); - - assert_eq!(m1.ink_width, m2.ink_width); - assert_eq!(m1.ink_height, m2.ink_height); - assert_eq!(m1.baseline, m2.baseline); - } - - #[test] - fn test_different_sizes_use_different_cache_keys() { - // Verify that measurements for different sizes are cached with different keys - // by checking that both requests succeed (cache doesn't confuse them) - let text = "Test"; - let font = "Sans"; - - let m1 = measure_text_cached(text, font, 12.0, None); - let m2 = measure_text_cached(text, font, 24.0, None); - - assert!(m1.is_some(), "12pt measurement should succeed"); - assert!(m2.is_some(), "24pt measurement should succeed"); - - // Request them again - should hit cache for both - let m1_cached = measure_text_cached(text, font, 12.0, None); - let m2_cached = measure_text_cached(text, font, 24.0, None); - - let m1 = m1.unwrap(); - let m1_cached = m1_cached.unwrap(); - - // Verify cache returns consistent results for same parameters - assert_eq!(m1.ink_width, m1_cached.ink_width); - assert_eq!(m1.ink_height, m1_cached.ink_height); - - let m2 = m2.unwrap(); - let m2_cached = m2_cached.unwrap(); - - assert_eq!(m2.ink_width, m2_cached.ink_width); - assert_eq!(m2.ink_height, m2_cached.ink_height); - } - - #[test] - fn test_cache_evicts_oldest_entry_at_capacity() { - let mut cache = TextMeasurementCache::new(2); - let key_a = TextCacheKey::new("A", "Sans", 12.0, None); - let key_b = TextCacheKey::new("B", "Sans", 12.0, None); - let key_c = TextCacheKey::new("C", "Sans", 12.0, None); - - cache.insert(key_a.clone(), measurement(10.0)); - cache.insert(key_b.clone(), measurement(20.0)); - cache.insert(key_c.clone(), measurement(30.0)); - - assert!(cache.get(&key_a).is_none()); - assert_eq!(cache.get(&key_b).unwrap().ink_width, 20.0); - assert_eq!(cache.get(&key_c).unwrap().ink_width, 30.0); - } - - #[test] - fn test_get_refreshes_lru_order_before_eviction() { - let mut cache = TextMeasurementCache::new(2); - let key_a = TextCacheKey::new("A", "Sans", 12.0, None); - let key_b = TextCacheKey::new("B", "Sans", 12.0, None); - let key_c = TextCacheKey::new("C", "Sans", 12.0, None); - - cache.insert(key_a.clone(), measurement(10.0)); - cache.insert(key_b.clone(), measurement(20.0)); - assert_eq!(cache.get(&key_a).unwrap().ink_width, 10.0); - cache.insert(key_c.clone(), measurement(30.0)); - - assert!(cache.get(&key_b).is_none()); - assert_eq!(cache.get(&key_a).unwrap().ink_width, 10.0); - assert_eq!(cache.get(&key_c).unwrap().ink_width, 30.0); - } - - #[test] - fn test_insert_existing_key_updates_cached_measurement() { - let mut cache = TextMeasurementCache::new(2); - let key = TextCacheKey::new("A", "Sans", 12.0, None); - - cache.insert(key.clone(), measurement(10.0)); - cache.insert(key.clone(), measurement(42.0)); - - assert_eq!(cache.get(&key).unwrap().ink_width, 42.0); - assert_eq!(cache.entries.len(), 1); - } - - #[test] - fn test_empty_text_returns_none() { - let result = measure_text_cached("", "Sans 12", 12.0, None); - assert!(result.is_none()); - } - - #[test] - fn test_wrap_width_affects_cache_key() { - let text = "A very long text that would wrap"; - let font = "Sans 12"; - - let m1 = measure_text_cached(text, font, 12.0, None); - let m2 = measure_text_cached(text, font, 12.0, Some(50)); - - assert!(m1.is_some()); - assert!(m2.is_some()); - - // With narrow wrap width, height should be larger (more lines) - let m1 = m1.unwrap(); - let m2 = m2.unwrap(); - assert!(m2.ink_height >= m1.ink_height); - } -} +mod tests; diff --git a/src/draw/shape/text_cache/cache.rs b/src/draw/shape/text_cache/cache.rs new file mode 100644 index 000000000..7634f8cb4 --- /dev/null +++ b/src/draw/shape/text_cache/cache.rs @@ -0,0 +1,76 @@ +use super::TextMeasurement; +use std::collections::HashMap; + +/// Cache key for text measurements. +#[derive(Clone, Debug, Hash, PartialEq, Eq)] +pub(super) struct TextCacheKey { + text: String, + font_desc_str: String, + /// Size in hundredths of points for stable hashing + size_hundredths: i32, + /// Wrap width in pixels, or -1 for no wrap + wrap_width: i32, +} + +impl TextCacheKey { + pub(super) fn new(text: &str, font_desc_str: &str, size: f64, wrap_width: Option) -> Self { + Self { + text: text.to_string(), + font_desc_str: font_desc_str.to_string(), + size_hundredths: (size * 100.0).round() as i32, + wrap_width: wrap_width.unwrap_or(-1), + } + } +} + +/// Numeric measurements retained by one text service. +/// Uses an LRU-style eviction when cache exceeds max size. +pub(super) struct TextMeasurementCache { + pub(super) entries: HashMap, + access_order: Vec, + max_entries: usize, +} + +impl TextMeasurementCache { + pub(super) fn new(max_entries: usize) -> Self { + Self { + entries: HashMap::with_capacity(max_entries), + access_order: Vec::with_capacity(max_entries), + max_entries, + } + } + + pub(super) fn get(&mut self, key: &TextCacheKey) -> Option { + if let Some(measurement) = self.entries.get(key) { + // Move to end of access order (most recently used) + if let Some(pos) = self.access_order.iter().position(|k| k == key) { + self.access_order.remove(pos); + self.access_order.push(key.clone()); + } + Some(measurement.clone()) + } else { + None + } + } + + pub(super) fn insert(&mut self, key: TextCacheKey, measurement: TextMeasurement) { + // If key already exists, update it and move to end of access order + if self.entries.contains_key(&key) { + self.entries.insert(key.clone(), measurement); + if let Some(pos) = self.access_order.iter().position(|k| k == &key) { + self.access_order.remove(pos); + } + self.access_order.push(key); + return; + } + + // Evict oldest entries if at capacity + while self.entries.len() >= self.max_entries && !self.access_order.is_empty() { + let oldest = self.access_order.remove(0); + self.entries.remove(&oldest); + } + + self.entries.insert(key.clone(), measurement); + self.access_order.push(key); + } +} diff --git a/src/draw/shape/text_cache/cursor.rs b/src/draw/shape/text_cache/cursor.rs new file mode 100644 index 000000000..53a7f3445 --- /dev/null +++ b/src/draw/shape/text_cache/cursor.rs @@ -0,0 +1,179 @@ +use super::*; + +impl TextMeasurer { + pub(crate) fn hit_test_text( + &self, + text: &str, + font_desc_str: &str, + wrap_width: Option, + local_x: f64, + local_y_from_baseline: f64, + ) -> Option { + if text.is_empty() { + return Some(0); + } + self.with_measurement_context(|ctx| { + let layout = configured_layout(ctx, text, font_desc_str, wrap_width); + + let scale = pango::SCALE as f64; + // Convert the baseline-relative y into the layout's top-left frame. + let local_y = local_y_from_baseline + layout.baseline() as f64 / scale; + let x_pu = (local_x * scale) + .round() + .clamp(i32::MIN as f64, i32::MAX as f64) as i32; + let y_pu = (local_y * scale) + .round() + .clamp(i32::MIN as f64, i32::MAX as f64) as i32; + let (_inside, index, trailing) = layout.xy_to_index(x_pu, y_pu); + + // Advance past `trailing` characters so a click on a glyph's right half + // lands the caret after it, keeping the result on a char boundary. + hit_position_to_byte(text, index, trailing) + }) + } + pub(crate) fn caret_on_adjacent_visual_position( + &self, + text: &str, + font_desc_str: &str, + wrap_width: Option, + byte_index: usize, + direction: VisualCaretDirection, + ) -> Option { + self.with_measurement_context(|ctx| { + let layout = configured_layout(ctx, text, font_desc_str, wrap_width); + + let index = snap_char_boundary(text, byte_index); + let old_index = i32::try_from(index).unwrap_or(i32::MAX); + let direction = match direction { + VisualCaretDirection::Left => -1, + VisualCaretDirection::Right => 1, + }; + let (new_index, trailing) = layout.move_cursor_visually(true, old_index, 0, direction); + + // Pango uses sentinels when movement would leave either visual edge of + // the layout. Keep the current logical position at those boundaries. + if new_index < 0 || new_index == i32::MAX { + return index; + } + hit_position_to_byte(text, new_index, trailing) + }) + } + pub(crate) fn caret_at_visual_selection_edge( + &self, + text: &str, + font_desc_str: &str, + wrap_width: Option, + start: usize, + end: usize, + direction: VisualCaretDirection, + ) -> Option { + self.with_measurement_context(|ctx| { + let layout = configured_layout(ctx, text, font_desc_str, wrap_width); + + let start = start.min(text.len()); + let end = end.min(text.len()); + let (start_line, start_x) = + layout.index_to_line_x(i32::try_from(start).unwrap_or(i32::MAX), false); + let (end_line, end_x) = + layout.index_to_line_x(i32::try_from(end).unwrap_or(i32::MAX), false); + if start_line != end_line { + return match direction { + VisualCaretDirection::Left => start, + VisualCaretDirection::Right => end, + }; + } + match direction { + VisualCaretDirection::Left if start_x <= end_x => start, + VisualCaretDirection::Left => end, + VisualCaretDirection::Right if start_x >= end_x => start, + VisualCaretDirection::Right => end, + } + }) + } + pub(crate) fn caret_on_visual_line_edge( + &self, + text: &str, + font_desc_str: &str, + wrap_width: Option, + byte_index: usize, + edge: VisualLineEdge, + ) -> Option { + self.with_measurement_context(|ctx| { + let layout = configured_layout(ctx, text, font_desc_str, wrap_width); + + let index = snap_char_boundary(text, byte_index); + let (line_index, _) = + layout.index_to_line_x(i32::try_from(index).unwrap_or(i32::MAX), false); + let Some(line) = layout.line_readonly(line_index) else { + return index; + }; + let start = usize::try_from(line.start_index()).unwrap_or(0); + let mut end = start + .saturating_add(usize::try_from(line.length()).unwrap_or(0)) + .min(text.len()); + if end > start && text.as_bytes().get(end - 1) == Some(&b'\n') { + end -= 1; + } + match edge { + VisualLineEdge::Start => start, + VisualLineEdge::End => end, + } + }) + } + pub(crate) fn caret_on_adjacent_visual_line( + &self, + text: &str, + font_desc_str: &str, + wrap_width: Option, + byte_index: usize, + direction: VisualLineDirection, + ) -> Option { + self.with_measurement_context(|ctx| { + let layout = configured_layout(ctx, text, font_desc_str, wrap_width); + + let index = snap_char_boundary(text, byte_index); + let index_i32 = i32::try_from(index).unwrap_or(i32::MAX); + let (line_index, x) = layout.index_to_line_x(index_i32, false); + let target_line = match direction { + VisualLineDirection::Up if line_index == 0 => return 0, + VisualLineDirection::Up => line_index - 1, + VisualLineDirection::Down if line_index + 1 >= layout.line_count() => { + return text.len(); + } + VisualLineDirection::Down => line_index + 1, + }; + let Some(line) = layout.line_readonly(target_line) else { + return index; + }; + let hit = line.x_to_index(x); + hit_position_to_byte(text, hit.index(), hit.trailing()) + }) + } + pub(crate) fn caret_geometry_text( + &self, + text: &str, + font_desc_str: &str, + wrap_width: Option, + byte_index: usize, + ) -> Option { + self.with_measurement_context(|ctx| { + let layout = configured_layout(ctx, text, font_desc_str, wrap_width); + caret_geometry_in(&layout, text, byte_index) + }) + } + pub(crate) fn text_preview_geometry( + &self, + text: &str, + font_desc_str: &str, + wrap_width: Option, + byte_index: Option, + ) -> Option { + self.with_measurement_context(|ctx| { + let layout = configured_layout(ctx, text, font_desc_str, wrap_width); + TextPreviewGeometry { + caret: byte_index.map(|byte_index| caret_geometry_in(&layout, text, byte_index)), + logical: logical_bounds_in(&layout), + } + }) + } +} diff --git a/src/draw/shape/text_cache/owner.rs b/src/draw/shape/text_cache/owner.rs new file mode 100644 index 000000000..384edc273 --- /dev/null +++ b/src/draw/shape/text_cache/owner.rs @@ -0,0 +1,87 @@ +use super::{TextCacheKey, TextMeasurement, TextMeasurementCache, configured_layout}; +use std::cell::RefCell; + +/// Canonical shape text measurements and cursor geometry for one owner. +/// Construction creates no Cairo/Pango resources. All geometry uses the same +/// measurement policy regardless of the eventual drawing destination. +pub(crate) struct TextMeasurer { + pub(super) cache: RefCell, + context: RefCell>, +} + +impl Default for TextMeasurer { + fn default() -> Self { + Self { + cache: RefCell::new(TextMeasurementCache::new(256)), + context: RefCell::new(None), + } + } +} + +impl TextMeasurer { + pub(super) fn with_measurement_context( + &self, + f: impl FnOnce(&cairo::Context) -> R, + ) -> Option { + let ctx = { + let mut context = self.context.borrow_mut(); + if context.is_none() { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).ok()?; + let ctx = cairo::Context::new(&surface).ok()?; + ctx.set_antialias(cairo::Antialias::Best); + *context = Some(ctx); + } + context.as_ref()?.clone() + }; + // Cairo retains its target surface. Release the initialization borrow + // before calling Pango or any nested measurement operation. + Some(f(&ctx)) + } + pub(crate) fn measure( + &self, + text: &str, + font_desc_str: &str, + size: f64, + wrap_width: Option, + ) -> Option { + if text.is_empty() { + return None; + } + + let key = TextCacheKey::new(text, font_desc_str, size, wrap_width); + + // Check cache first + let cached = self.cache.borrow_mut().get(&key); + if let Some(measurement) = cached { + return Some(measurement); + } + + // Measure using shared context + let measurement = self.with_measurement_context(|ctx| { + let layout = configured_layout(ctx, text, font_desc_str, wrap_width); + + let (ink_rect, logical_rect) = layout.extents(); + let scale = pango::SCALE as f64; + + TextMeasurement { + ink_x: ink_rect.x() as f64 / scale, + ink_y: ink_rect.y() as f64 / scale, + ink_width: ink_rect.width() as f64 / scale, + ink_height: ink_rect.height() as f64 / scale, + logical_x: logical_rect.x() as f64 / scale, + logical_y: logical_rect.y() as f64 / scale, + logical_width: logical_rect.width() as f64 / scale, + logical_height: logical_rect.height() as f64 / scale, + baseline: layout.baseline() as f64 / scale, + } + })?; + + // Cache the result + self.cache.borrow_mut().insert(key, measurement.clone()); + + Some(measurement) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/draw/shape/text_cache/owner/tests.rs b/src/draw/shape/text_cache/owner/tests.rs new file mode 100644 index 000000000..366341eca --- /dev/null +++ b/src/draw/shape/text_cache/owner/tests.rs @@ -0,0 +1,192 @@ +use super::*; +use crate::draw::shape::text_cache::{VisualCaretDirection, VisualLineDirection, VisualLineEdge}; + +fn metrics(value: &TextMeasurement) -> [f64; 9] { + [ + value.ink_x, + value.ink_y, + value.ink_width, + value.ink_height, + value.logical_x, + value.logical_y, + value.logical_width, + value.logical_height, + value.baseline, + ] +} + +#[test] +fn construction_and_empty_measurement_leave_context_lazy() { + let owner = TextMeasurer::default(); + assert!(owner.context.borrow().is_none()); + assert!(owner.cache.borrow().entries.is_empty()); + assert!(owner.measure("", "Sans 12", 12.0, None).is_none()); + assert_eq!(owner.hit_test_text("", "Sans 12", None, 0.0, 0.0), Some(0)); + assert!(owner.context.borrow().is_none()); + assert!( + owner + .caret_geometry_text("", "Sans 12", None, 0) + .unwrap() + .height + > 0.0 + ); + assert!(owner.context.borrow().is_some()); +} + +#[test] +fn independent_owners_retain_distinct_contexts_and_numeric_entries() { + let first = TextMeasurer::default(); + let second = TextMeasurer::default(); + let expected = first.measure("shared", "Sans 12", 12.0, None).unwrap(); + assert!(second.context.borrow().is_none()); + let actual = second.measure("shared", "Sans 12", 12.0, None).unwrap(); + assert_eq!(metrics(&expected), metrics(&actual)); + let first_ctx = first.context.borrow().as_ref().unwrap().clone(); + let second_ctx = second.context.borrow().as_ref().unwrap().clone(); + assert_ne!(first_ctx.to_raw_none(), second_ctx.to_raw_none()); + first.measure("first only", "Sans 12", 12.0, None).unwrap(); + assert_eq!(first.cache.borrow().entries.len(), 2); + assert_eq!(second.cache.borrow().entries.len(), 1); + drop(first); + assert_eq!( + metrics(&second.measure("shared", "Sans 12", 12.0, None).unwrap()), + metrics(&expected) + ); +} + +#[test] +fn hits_reuse_numeric_results_and_promote_within_the_default_budget() { + let owner = TextMeasurer::default(); + for index in 0..256 { + owner + .measure(&format!("entry {index}"), "Sans 12", 12.0, None) + .unwrap(); + } + let first_key = TextCacheKey::new("entry 0", "Sans 12", 12.0, None); + // A recognizable cached value proves the owner hit path avoids recomputing. + owner + .cache + .borrow_mut() + .entries + .get_mut(&first_key) + .unwrap() + .baseline = -123.0; + assert_eq!( + owner + .measure("entry 0", "Sans 12", 12.0, None) + .unwrap() + .baseline, + -123.0 + ); + owner.measure("new", "Sans 12", 12.0, None).unwrap(); + let cache = owner.cache.borrow(); + assert_eq!(cache.entries.len(), 256); + assert!(cache.entries.contains_key(&first_key)); + assert!( + !cache + .entries + .contains_key(&TextCacheKey::new("entry 1", "Sans 12", 12.0, None)) + ); +} + +#[test] +fn destination_settings_do_not_change_canonical_measurements() { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 8, 8).unwrap(); + let destination = cairo::Context::new(&surface).unwrap(); + destination.scale(2.0, 3.0); + destination.set_antialias(cairo::Antialias::None); + let mut options = cairo::FontOptions::new().unwrap(); + options.set_hint_metrics(cairo::HintMetrics::Off); + destination.set_font_options(&options); + let owner = TextMeasurer::default(); + let fresh = TextMeasurer::default(); + let actual = crate::draw::shape::text_cache::measure_text_with_context( + &destination, + "Hello 你好\nאבג", + "Sans 16", + 16.0, + Some(70), + ) + .unwrap(); + let expected = fresh + .measure("Hello 你好\nאבג", "Sans 16", 16.0, Some(70)) + .unwrap(); + assert_eq!(metrics(&actual), metrics(&expected)); + owner + .with_measurement_context(|ctx| { + assert_eq!(ctx.antialias(), cairo::Antialias::Best); + assert_eq!(ctx.matrix(), cairo::Matrix::identity()); + let target = cairo::ImageSurface::try_from(ctx.target()).unwrap(); + assert_eq!((target.width(), target.height()), (1, 1)); + }) + .unwrap(); +} + +#[test] +fn initialization_borrow_is_released_before_nested_measurement() { + let owner = TextMeasurer::default(); + owner + .with_measurement_context(|outer| { + let measured = owner.measure("nested miss", "Sans 12", 12.0, None).unwrap(); + assert!(measured.logical_width > 0.0); + owner + .with_measurement_context(|inner| { + assert_eq!(outer.to_raw_none(), inner.to_raw_none()) + }) + .unwrap(); + }) + .unwrap(); +} + +#[test] +fn explicit_cursor_geometry_preserves_wrap_bidi_and_utf8_boundaries() { + let owner = TextMeasurer::default(); + let text = "abc אבג 你好\nsecond line"; + let font = "Sans 18"; + for byte in 0..=text.len() { + let geometry = owner + .caret_geometry_text(text, font, Some(80), byte) + .unwrap(); + assert!(geometry.height > 0.0); + let preview = owner + .text_preview_geometry(text, font, Some(80), Some(byte)) + .unwrap(); + assert_eq!(preview.caret, Some(geometry)); + for direction in [VisualCaretDirection::Left, VisualCaretDirection::Right] { + let next = owner + .caret_on_adjacent_visual_position(text, font, Some(80), byte, direction) + .unwrap(); + assert!(text.is_char_boundary(next)); + } + for direction in [VisualLineDirection::Up, VisualLineDirection::Down] { + let next = owner + .caret_on_adjacent_visual_line(text, font, Some(80), byte, direction) + .unwrap(); + assert!(text.is_char_boundary(next)); + } + for edge in [VisualLineEdge::Start, VisualLineEdge::End] { + let next = owner + .caret_on_visual_line_edge(text, font, Some(80), byte, edge) + .unwrap(); + assert!(text.is_char_boundary(next)); + } + } + let rtl = "אבג"; + let start = owner.caret_geometry_text(rtl, font, None, 0).unwrap(); + let end = owner + .caret_geometry_text(rtl, font, None, rtl.len()) + .unwrap(); + assert!(start.x > end.x); + // Interior boundaries in an RTL run have the opposite visual order to + // their byte order. Avoid equating the terminal index's line position + // with Pango's strong cursor rectangle at the paragraph boundary. + let mixed = "abc אבג xyz"; + assert_eq!( + owner.caret_at_visual_selection_edge(mixed, font, None, 6, 8, VisualCaretDirection::Left), + Some(8) + ); + assert_eq!( + owner.caret_at_visual_selection_edge(mixed, font, None, 6, 8, VisualCaretDirection::Right), + Some(6) + ); +} diff --git a/src/draw/shape/text_cache/tests.rs b/src/draw/shape/text_cache/tests.rs new file mode 100644 index 000000000..70236799e --- /dev/null +++ b/src/draw/shape/text_cache/tests.rs @@ -0,0 +1,190 @@ +use super::*; + +fn measurement(width: f64) -> TextMeasurement { + TextMeasurement { + ink_x: 0.0, + ink_y: 0.0, + ink_width: width, + ink_height: 10.0, + logical_x: 0.0, + logical_y: 0.0, + logical_width: width, + logical_height: 10.0, + baseline: 8.0, + } +} + +#[test] +fn hit_test_maps_x_extremes_to_buffer_ends() { + // Far-left click lands at the start; far-right at the end; the exact + // glyph widths do not matter, only the ordering and clamping. + assert_eq!( + hit_test_text("hello", "Sans 20", None, -100.0, 0.0), + Some(0) + ); + assert_eq!( + hit_test_text("hello", "Sans 20", None, 100_000.0, 0.0), + Some(5) + ); + // Empty text always resolves to caret 0. + assert_eq!(hit_test_text("", "Sans 20", None, 42.0, 0.0), Some(0)); +} + +#[test] +fn hit_test_result_is_always_a_char_boundary() { + // '你好' is two 3-byte chars; any x must land on 0, 3, or 6. + let text = "你好"; + for x in [-10.0, 0.0, 5.0, 12.0, 30.0, 1000.0] { + let offset = hit_test_text(text, "Sans 20", None, x, 0.0).unwrap(); + assert!( + text.is_char_boundary(offset), + "offset {offset} split a char" + ); + } +} + +#[test] +fn caret_geometry_advances_left_to_right_and_has_height() { + let start = caret_geometry_text("hello", "Sans 20", None, 0).unwrap(); + let end = caret_geometry_text("hello", "Sans 20", None, 5).unwrap(); + assert!(start.x >= 0.0); + assert!( + end.x > start.x, + "the end caret must sit to the right of the start caret" + ); + assert!(start.height > 0.0, "the caret must have a visible height"); +} + +#[test] +fn caret_geometry_works_on_empty_text() { + // An empty buffer still needs a visible caret at the origin. + let geom = caret_geometry_text("", "Sans 20", None, 0).unwrap(); + assert_eq!(geom.x, 0.0); + assert!(geom.height > 0.0); +} + +#[test] +fn caret_geometry_snaps_off_boundary_indices_down() { + // Byte 2 is inside the 3-byte '你'; it must resolve like byte 0, not panic. + let at_zero = caret_geometry_text("你a", "Sans 20", None, 0).unwrap(); + let off_boundary = caret_geometry_text("你a", "Sans 20", None, 2).unwrap(); + assert_eq!(at_zero, off_boundary); +} + +#[test] +fn test_cache_returns_same_measurement() { + let text = "Hello World"; + let font = "Sans 12"; + + let m1 = measure_text_cached(text, font, 12.0, None); + let m2 = measure_text_cached(text, font, 12.0, None); + + assert!(m1.is_some()); + assert!(m2.is_some()); + + let m1 = m1.unwrap(); + let m2 = m2.unwrap(); + + assert_eq!(m1.ink_width, m2.ink_width); + assert_eq!(m1.ink_height, m2.ink_height); + assert_eq!(m1.baseline, m2.baseline); +} + +#[test] +fn test_different_sizes_use_different_cache_keys() { + // Verify that measurements for different sizes are cached with different keys + // by checking that both requests succeed (cache doesn't confuse them) + let text = "Test"; + let font = "Sans"; + + let m1 = measure_text_cached(text, font, 12.0, None); + let m2 = measure_text_cached(text, font, 24.0, None); + + assert!(m1.is_some(), "12pt measurement should succeed"); + assert!(m2.is_some(), "24pt measurement should succeed"); + + // Request them again - should hit cache for both + let m1_cached = measure_text_cached(text, font, 12.0, None); + let m2_cached = measure_text_cached(text, font, 24.0, None); + + let m1 = m1.unwrap(); + let m1_cached = m1_cached.unwrap(); + + // Verify cache returns consistent results for same parameters + assert_eq!(m1.ink_width, m1_cached.ink_width); + assert_eq!(m1.ink_height, m1_cached.ink_height); + + let m2 = m2.unwrap(); + let m2_cached = m2_cached.unwrap(); + + assert_eq!(m2.ink_width, m2_cached.ink_width); + assert_eq!(m2.ink_height, m2_cached.ink_height); +} + +#[test] +fn test_cache_evicts_oldest_entry_at_capacity() { + let mut cache = TextMeasurementCache::new(2); + let key_a = TextCacheKey::new("A", "Sans", 12.0, None); + let key_b = TextCacheKey::new("B", "Sans", 12.0, None); + let key_c = TextCacheKey::new("C", "Sans", 12.0, None); + + cache.insert(key_a.clone(), measurement(10.0)); + cache.insert(key_b.clone(), measurement(20.0)); + cache.insert(key_c.clone(), measurement(30.0)); + + assert!(cache.get(&key_a).is_none()); + assert_eq!(cache.get(&key_b).unwrap().ink_width, 20.0); + assert_eq!(cache.get(&key_c).unwrap().ink_width, 30.0); +} + +#[test] +fn test_get_refreshes_lru_order_before_eviction() { + let mut cache = TextMeasurementCache::new(2); + let key_a = TextCacheKey::new("A", "Sans", 12.0, None); + let key_b = TextCacheKey::new("B", "Sans", 12.0, None); + let key_c = TextCacheKey::new("C", "Sans", 12.0, None); + + cache.insert(key_a.clone(), measurement(10.0)); + cache.insert(key_b.clone(), measurement(20.0)); + assert_eq!(cache.get(&key_a).unwrap().ink_width, 10.0); + cache.insert(key_c.clone(), measurement(30.0)); + + assert!(cache.get(&key_b).is_none()); + assert_eq!(cache.get(&key_a).unwrap().ink_width, 10.0); + assert_eq!(cache.get(&key_c).unwrap().ink_width, 30.0); +} + +#[test] +fn test_insert_existing_key_updates_cached_measurement() { + let mut cache = TextMeasurementCache::new(2); + let key = TextCacheKey::new("A", "Sans", 12.0, None); + + cache.insert(key.clone(), measurement(10.0)); + cache.insert(key.clone(), measurement(42.0)); + + assert_eq!(cache.get(&key).unwrap().ink_width, 42.0); + assert_eq!(cache.entries.len(), 1); +} + +#[test] +fn test_empty_text_returns_none() { + let result = measure_text_cached("", "Sans 12", 12.0, None); + assert!(result.is_none()); +} + +#[test] +fn test_wrap_width_affects_cache_key() { + let text = "A very long text that would wrap"; + let font = "Sans 12"; + + let m1 = measure_text_cached(text, font, 12.0, None); + let m2 = measure_text_cached(text, font, 12.0, Some(50)); + + assert!(m1.is_some()); + assert!(m2.is_some()); + + // With narrow wrap width, height should be larger (more lines) + let m1 = m1.unwrap(); + let m2 = m2.unwrap(); + assert!(m2.ink_height >= m1.ink_height); +} From 588b621bc37142262ccb874c6d29f2e3a1d174ff Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:45:54 +0200 Subject: [PATCH 04/42] refactor(help): keep interaction geometry on each input owner --- src/backend/wayland/state/render/ui.rs | 46 +++---- src/input/state/core/help_overlay/state.rs | 30 ++++- src/input/state/core/help_overlay/tests.rs | 127 ++++++++++++------- src/input/state/core/utility/help_overlay.rs | 34 +++-- src/ui.rs | 2 +- src/ui/help_overlay/mod.rs | 2 +- src/ui/help_overlay/render/mod.rs | 1 + tests/ui.rs | 8 ++ 8 files changed, 168 insertions(+), 82 deletions(-) diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 6f3d48b86..b0f16f4ec 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -195,30 +195,30 @@ impl WaylandState { ) { if !capture_picker && self.input_state.help_overlay.is_visible() { let bindings = crate::ui::HelpOverlayBindings::from_input_state(&self.input_state); - let (theme, caches) = self.render.ui_parts_mut(); - let mut render = crate::ui::UiRenderCtx { - cairo: ctx, - theme, - caches, + let result = { + let (theme, caches) = self.render.ui_parts_mut(); + let mut render = crate::ui::UiRenderCtx { + cairo: ctx, + theme, + caches, + }; + crate::ui::render_help_overlay_result_with_context( + &mut render, + &self.config.ui.help_overlay_style, + width, + height, + self.frozen.enabled(), + self.input_state.help_overlay.page(), + &bindings, + self.input_state.help_overlay.query(), + self.config.ui.help_overlay_context_filter, + self.input_state.boards.board_count() > 1, + self.config.capture.enabled, + self.input_state.help_overlay.scroll(), + self.input_state.help_overlay.is_quick_mode(), + ) }; - let scroll_max = crate::ui::render_help_overlay_with_context( - &mut render, - &self.config.ui.help_overlay_style, - width, - height, - self.frozen.enabled(), - self.input_state.help_overlay.page(), - &bindings, - self.input_state.help_overlay.query(), - self.config.ui.help_overlay_context_filter, - self.input_state.boards.board_count() > 1, - self.config.capture.enabled, - self.input_state.help_overlay.scroll(), - self.input_state.help_overlay.is_quick_mode(), - ); - self.input_state - .help_overlay - .update_scroll_extent(scroll_max); + self.input_state.help_overlay.install_render_result(result); } if !capture_picker && self.input_state.is_board_picker_open() { self.input_state diff --git a/src/input/state/core/help_overlay/state.rs b/src/input/state/core/help_overlay/state.rs index 446c40ec7..8485a86fa 100644 --- a/src/input/state/core/help_overlay/state.rs +++ b/src/input/state/core/help_overlay/state.rs @@ -1,3 +1,4 @@ +use crate::help_overlay_interaction::{HelpHitMap, HelpOverlayRegion, HelpRenderResult}; use crate::input::state::{HelpOverlayClick, HelpOverlayPressSource, HelpOverlayReleaseOutcome}; /// Upper bound for page navigation. Rendering clamps this to the actual page count. @@ -6,6 +7,7 @@ pub(crate) const MAX_PAGES: usize = 10; /// Visibility, navigation, search, and pointer bookkeeping for the help overlay. #[derive(Debug, Default)] pub struct HelpOverlayState { + hit_map: Option, pub(in crate::input::state) visible: bool, pub(in crate::input::state) page: usize, pub(in crate::input::state) search: String, @@ -38,7 +40,19 @@ impl HelpOverlayState { self.quick_mode } + /// Install geometry and scroll bounds from the same completed help paint. + pub fn install_render_result(&mut self, result: HelpRenderResult) { + self.update_scroll_extent(result.scroll_max); + self.hit_map = Some(result.hit_map); + } + + /// Query this overlay's last rendered geometry in screen coordinates. + pub fn region_at(&self, x: f64, y: f64) -> Option { + self.hit_map.as_ref()?.region_at(x, y) + } + pub(crate) fn open(&mut self, quick_mode: bool) { + self.hit_map = None; self.visible = true; self.quick_mode = quick_mode; self.page = 0; @@ -51,6 +65,7 @@ impl HelpOverlayState { if !self.visible { return false; } + self.hit_map = None; self.visible = false; self.quick_mode = false; self.scroll = 0.0; @@ -260,7 +275,13 @@ mod tests { assert!(state.scroll_by(96.0)); assert_eq!(state.scroll, 100.0); assert!(!state.scroll_by(1.0)); - state.update_scroll_extent(24.0); + state.note_press(HelpOverlayPressSource::Touch, HelpOverlayClick::Inside); + state.install_render_result(HelpRenderResult { + scroll_max: 24.0, + hit_map: HelpHitMap::new((0.0, 0.0, 100.0, 100.0), None, []), + }); + assert_eq!(state.region_at(50.0, 50.0), Some(HelpOverlayRegion::Inside)); + assert_eq!(state.pending_presses.len(), 1); assert_eq!((state.scroll, state.scroll_max), (24.0, 24.0)); } @@ -322,9 +343,16 @@ mod tests { fn closing_retires_press_without_retargeting_a_reopened_overlay() { let mut state = HelpOverlayState::default(); state.open(false); + state.install_render_result(HelpRenderResult { + scroll_max: 40.0, + hit_map: HelpHitMap::new((0.0, 0.0, 100.0, 100.0), None, []), + }); + assert_eq!(state.region_at(50.0, 50.0), Some(HelpOverlayRegion::Inside)); state.note_press(HelpOverlayPressSource::Touch, HelpOverlayClick::Outside); assert!(state.close()); + assert_eq!(state.region_at(50.0, 50.0), None); state.open(false); + assert_eq!(state.region_at(50.0, 50.0), None); assert_eq!( state.resolve_release(HelpOverlayPressSource::Touch, HelpOverlayClick::Outside), Some(HelpOverlayReleaseOutcome::None) diff --git a/src/input/state/core/help_overlay/tests.rs b/src/input/state/core/help_overlay/tests.rs index bbb7ca526..1e23da957 100644 --- a/src/input/state/core/help_overlay/tests.rs +++ b/src/input/state/core/help_overlay/tests.rs @@ -55,7 +55,8 @@ fn help_overlay_cursor_hint_maps_real_layout_regions() { assert_eq!(state.help_overlay_cursor_hint_at(150, 215), None); state.toggle_help_overlay(); - crate::ui::install_help_hit_map_for_test( + install_hit_map( + &mut state, (100.0, 100.0, 200.0, 300.0), Some((110.0, 130.0, 180.0, 24.0)), &[(120.0, 200.0, 160.0, 30.0, crate::config::Action::ToggleHelp)], @@ -74,15 +75,14 @@ fn help_overlay_cursor_hint_maps_real_layout_regions() { Some(HelpOverlayCursorHint::Default) ); assert_eq!(state.help_overlay_cursor_hint_at(10, 10), None); - - crate::ui::clear_help_overlay_hit_map(); } #[test] fn help_overlay_click_runs_rows_and_dismisses_outside() { let mut state = make_state(); state.toggle_help_overlay(); - crate::ui::install_help_hit_map_for_test( + install_hit_map( + &mut state, (100.0, 100.0, 200.0, 300.0), Some((110.0, 130.0, 180.0, 24.0)), &[( @@ -110,27 +110,29 @@ fn help_overlay_click_runs_rows_and_dismisses_outside() { state.help_overlay_click_at(10, 10), HelpOverlayClick::Outside ); - - crate::ui::clear_help_overlay_hit_map(); } #[test] fn close_help_overlay_resets_state_and_clears_hit_map() { let mut state = make_state(); state.toggle_help_overlay(); - state.help_overlay.scroll = 42.0; - crate::ui::install_help_hit_map_for_test( + install_hit_map( + &mut state, (100.0, 100.0, 200.0, 300.0), None, &[(120.0, 200.0, 160.0, 30.0, crate::config::Action::ToggleHelp)], ); + state.help_overlay.update_scroll_extent(100.0); + assert!(state.help_overlay.scroll_by(42.0)); + assert_eq!(state.help_overlay.scroll, 42.0); + state.close_help_overlay(); assert!(!state.help_overlay.visible); assert_eq!(state.help_overlay.scroll, 0.0); // Closing dropped the stale hit map, so a later click resolves outside. - assert_eq!(crate::ui::help_overlay_region_at(150.0, 215.0), None); + assert_eq!(state.help_overlay.region_at(150.0, 215.0), None); assert_eq!( state.help_overlay_click_at(150, 215), HelpOverlayClick::Outside @@ -145,7 +147,8 @@ fn close_help_overlay_resets_state_and_clears_hit_map() { fn state_with_help_row(action: crate::config::Action) -> InputState { let mut state = make_state(); state.toggle_help_overlay(); - crate::ui::install_help_hit_map_for_test( + install_hit_map( + &mut state, (100.0, 100.0, 200.0, 300.0), Some((110.0, 130.0, 180.0, 24.0)), &[(120.0, 200.0, 160.0, 30.0, action)], @@ -167,8 +170,6 @@ fn help_release_runs_row_only_when_press_and_release_share_the_row() { ); // The recorded press was consumed. assert!(state.help_overlay.pending_presses.is_empty()); - - crate::ui::clear_help_overlay_hit_map(); } #[test] @@ -188,8 +189,6 @@ fn help_press_on_chrome_then_drag_onto_row_does_not_run() { state.resolve_help_overlay_release(HelpOverlayPressSource::Pointer(1), 150, 215), Some(HelpOverlayReleaseOutcome::None) ); - - crate::ui::clear_help_overlay_hit_map(); } #[test] @@ -208,8 +207,6 @@ fn help_press_outside_then_release_on_row_does_not_run() { state.resolve_help_overlay_release(HelpOverlayPressSource::Pointer(1), 150, 215), Some(HelpOverlayReleaseOutcome::None) ); - - crate::ui::clear_help_overlay_hit_map(); } #[test] @@ -221,8 +218,6 @@ fn help_press_and_release_outside_dismisses() { state.resolve_help_overlay_release(HelpOverlayPressSource::Pointer(1), 20, 20), Some(HelpOverlayReleaseOutcome::Dismiss) ); - - crate::ui::clear_help_overlay_hit_map(); } #[test] @@ -234,8 +229,6 @@ fn help_press_on_row_then_release_on_chrome_does_not_run() { state.resolve_help_overlay_release(HelpOverlayPressSource::Pointer(1), 150, 280), Some(HelpOverlayReleaseOutcome::None) ); - - crate::ui::clear_help_overlay_hit_map(); } #[test] @@ -248,8 +241,6 @@ fn help_release_without_a_recorded_press_is_inert() { state.resolve_help_overlay_release(HelpOverlayPressSource::Touch, 150, 215), None ); - - crate::ui::clear_help_overlay_hit_map(); } #[test] @@ -272,8 +263,6 @@ fn help_release_requires_the_modality_that_owned_the_press() { crate::config::Action::ClearCanvas )) ); - - crate::ui::clear_help_overlay_hit_map(); } #[test] @@ -299,8 +288,6 @@ fn help_pointer_ownership_is_tracked_per_button() { )), "middle ownership must not consume the left help click" ); - - crate::ui::clear_help_overlay_hit_map(); } #[test] @@ -321,8 +308,6 @@ fn closing_help_keeps_its_press_release_owned_without_running_the_stale_target() ); assert!(state.help_overlay.pending_presses.is_empty()); assert!(state.help_overlay.consume_only_presses.is_empty()); - - crate::ui::clear_help_overlay_hit_map(); } #[test] @@ -333,7 +318,8 @@ fn reopening_help_cannot_retarget_a_press_from_the_previous_layout() { state.close_help_overlay(); state.toggle_help_overlay(); - crate::ui::install_help_hit_map_for_test( + install_hit_map( + &mut state, (100.0, 100.0, 200.0, 300.0), None, &[( @@ -350,8 +336,6 @@ fn reopening_help_cannot_retarget_a_press_from_the_previous_layout() { Some(HelpOverlayReleaseOutcome::None), "an old press may only be consumed, never resolved against a reopened layout" ); - - crate::ui::clear_help_overlay_hit_map(); } #[test] @@ -361,7 +345,8 @@ fn a_new_help_press_supersedes_consume_only_ownership_for_its_source() { state.note_help_overlay_press(pointer, 150, 215); state.close_help_overlay(); state.toggle_help_overlay(); - crate::ui::install_help_hit_map_for_test( + install_hit_map( + &mut state, (100.0, 100.0, 200.0, 300.0), None, &[( @@ -381,15 +366,14 @@ fn a_new_help_press_supersedes_consume_only_ownership_for_its_source() { crate::config::Action::ClearCanvas )) ); - - crate::ui::clear_help_overlay_hit_map(); } #[test] fn opening_help_drops_stale_hit_map_geometry() { let mut state = make_state(); // Simulate geometry left over from a previous open. - crate::ui::install_help_hit_map_for_test( + install_hit_map( + &mut state, (100.0, 100.0, 200.0, 300.0), None, &[(120.0, 200.0, 160.0, 30.0, crate::config::Action::ToggleHelp)], @@ -400,17 +384,16 @@ fn opening_help_drops_stale_hit_map_geometry() { state.toggle_help_overlay(); assert!(state.help_overlay.visible); - assert_eq!(crate::ui::help_overlay_region_at(150.0, 215.0), None); + assert_eq!(state.help_overlay.region_at(150.0, 215.0), None); assert!(state.help_overlay.pending_presses.is_empty()); - - crate::ui::clear_help_overlay_hit_map(); } #[test] fn starting_the_tour_routes_help_close_through_the_canonical_closer() { let mut state = make_state(); state.toggle_help_overlay(); - crate::ui::install_help_hit_map_for_test( + install_hit_map( + &mut state, (100.0, 100.0, 200.0, 300.0), None, &[(120.0, 200.0, 160.0, 30.0, crate::config::Action::ToggleHelp)], @@ -421,16 +404,15 @@ fn starting_the_tour_routes_help_close_through_the_canonical_closer() { assert!(!state.help_overlay.visible); // Routing through close_help_overlay dropped the cached hit map, so a // click after help reopens can never act on this stale layout. - assert_eq!(crate::ui::help_overlay_region_at(150.0, 215.0), None); - - crate::ui::clear_help_overlay_hit_map(); + assert_eq!(state.help_overlay.region_at(150.0, 215.0), None); } #[test] fn opening_the_command_palette_routes_help_close_through_the_canonical_closer() { let mut state = make_state(); state.toggle_help_overlay(); - crate::ui::install_help_hit_map_for_test( + install_hit_map( + &mut state, (100.0, 100.0, 200.0, 300.0), None, &[(120.0, 200.0, 160.0, 30.0, crate::config::Action::ToggleHelp)], @@ -440,7 +422,62 @@ fn opening_the_command_palette_routes_help_close_through_the_canonical_closer() assert!(!state.help_overlay.visible); assert!(state.command_palette.open); - assert_eq!(crate::ui::help_overlay_region_at(150.0, 215.0), None); + assert_eq!(state.help_overlay.region_at(150.0, 215.0), None); +} + +fn install_hit_map( + state: &mut InputState, + bounds: (f64, f64, f64, f64), + search: Option<(f64, f64, f64, f64)>, + rows: &[(f64, f64, f64, f64, crate::config::Action)], +) { + state + .help_overlay + .install_render_result(crate::help_overlay_interaction::HelpRenderResult { + scroll_max: 0.0, + hit_map: crate::help_overlay_interaction::HelpHitMap::new( + bounds, + search, + rows.iter() + .map(|&(x, y, w, h, action)| ((x, y, w, h), action)), + ), + }); +} - crate::ui::clear_help_overlay_hit_map(); +#[test] +fn help_geometry_and_pending_sources_are_independent_between_owners() { + use crate::config::Action; + let mut first = state_with_help_row(Action::ClearCanvas); + let mut second = state_with_help_row(Action::ToggleHelp); + first.note_help_overlay_press(HelpOverlayPressSource::Touch, 150, 215); + second.note_help_overlay_press(HelpOverlayPressSource::Pointer(1), 150, 215); + assert_eq!( + first.help_overlay_click_at(150, 215), + HelpOverlayClick::Run(Action::ClearCanvas) + ); + assert_eq!( + second.help_overlay_click_at(150, 215), + HelpOverlayClick::Run(Action::ToggleHelp) + ); + first.close_help_overlay(); + first.toggle_help_overlay(); + assert_eq!(first.help_overlay.region_at(150.0, 215.0), None); + assert_eq!( + second.help_overlay_cursor_hint_at(150, 215), + Some(HelpOverlayCursorHint::Pointer) + ); + install_hit_map( + &mut first, + (100.0, 100.0, 200.0, 300.0), + None, + &[(120.0, 200.0, 160.0, 30.0, Action::ClearCanvas)], + ); + assert_eq!( + first.resolve_help_overlay_release(HelpOverlayPressSource::Touch, 150, 215), + Some(HelpOverlayReleaseOutcome::None) + ); + assert_eq!( + second.resolve_help_overlay_release(HelpOverlayPressSource::Pointer(1), 150, 215), + Some(HelpOverlayReleaseOutcome::Run(Action::ToggleHelp)) + ); } diff --git a/src/input/state/core/utility/help_overlay.rs b/src/input/state/core/utility/help_overlay.rs index 6432fa882..bae589da2 100644 --- a/src/input/state/core/utility/help_overlay.rs +++ b/src/input/state/core/utility/help_overlay.rs @@ -53,13 +53,18 @@ pub enum HelpOverlayReleaseOutcome { } impl InputState { + /// Install the geometry returned by the public help result renderer for + /// subsequent click and cursor queries on this input owner. + pub fn install_help_overlay_render_result( + &mut self, + result: crate::help_overlay_interaction::HelpRenderResult, + ) { + self.help_overlay.install_render_result(result); + } + fn open_help_overlay_internal(&mut self, quick_mode: bool, track_usage: bool) { self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::HelpOverlay); self.help_overlay.open(quick_mode); - // Defensively drop any geometry left from a previous open. The hit map - // is normally cleared on close, but re-opening should never expose the - // prior layout to a click before the first fresh render repopulates it. - crate::ui::clear_help_overlay_hit_map(); if track_usage { self.pending_onboarding_usage.used_help_overlay = true; } @@ -89,7 +94,6 @@ impl InputState { if !self.help_overlay.close() { return; } - crate::ui::clear_help_overlay_hit_map(); self.dirty_tracker.mark_full(); self.needs_redraw = true; } @@ -97,8 +101,10 @@ impl InputState { /// Resolve a left-click at `(x, y)` (screen space) against the real rendered /// help layout: a clickable row/footer action, inside chrome, or a dismiss. pub fn help_overlay_click_at(&self, x: i32, y: i32) -> HelpOverlayClick { - match crate::ui::help_overlay_region_at(x as f64, y as f64) { - Some(crate::ui::HelpOverlayRegion::Row(action)) => HelpOverlayClick::Run(action), + match self.help_overlay.region_at(x as f64, y as f64) { + Some(crate::help_overlay_interaction::HelpOverlayRegion::Row(action)) => { + HelpOverlayClick::Run(action) + } Some(_) => HelpOverlayClick::Inside, None => HelpOverlayClick::Outside, } @@ -154,10 +160,16 @@ impl InputState { return None; } - match crate::ui::help_overlay_region_at(x as f64, y as f64)? { - crate::ui::HelpOverlayRegion::Search => Some(HelpOverlayCursorHint::Text), - crate::ui::HelpOverlayRegion::Row(_) => Some(HelpOverlayCursorHint::Pointer), - crate::ui::HelpOverlayRegion::Inside => Some(HelpOverlayCursorHint::Default), + match self.help_overlay.region_at(x as f64, y as f64)? { + crate::help_overlay_interaction::HelpOverlayRegion::Search => { + Some(HelpOverlayCursorHint::Text) + } + crate::help_overlay_interaction::HelpOverlayRegion::Row(_) => { + Some(HelpOverlayCursorHint::Pointer) + } + crate::help_overlay_interaction::HelpOverlayRegion::Inside => { + Some(HelpOverlayCursorHint::Default) + } } } } diff --git a/src/ui.rs b/src/ui.rs index 1f3a9a8b9..a11741534 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -43,7 +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; +pub(crate) use help_overlay::render_help_overlay_result_with_context; #[allow(unused_imports)] pub use help_overlay::{ HelpHitMap, HelpOverlayRegion, HelpRenderResult, clear_help_overlay_hit_map, diff --git a/src/ui/help_overlay/mod.rs b/src/ui/help_overlay/mod.rs index 756f6d923..825669cee 100644 --- a/src/ui/help_overlay/mod.rs +++ b/src/ui/help_overlay/mod.rs @@ -17,4 +17,4 @@ pub use render::{ pub use sections::HelpOverlayBindings; pub(in crate::ui) use render::HelpLayoutCache; -pub(crate) use render::render_help_overlay_with_context; +pub(crate) use render::render_help_overlay_result_with_context; diff --git a/src/ui/help_overlay/render/mod.rs b/src/ui/help_overlay/render/mod.rs index 824efb197..04819483e 100644 --- a/src/ui/help_overlay/render/mod.rs +++ b/src/ui/help_overlay/render/mod.rs @@ -18,6 +18,7 @@ use crate::config::{Action, action_label}; use crate::label_format::NOT_BOUND_LABEL; use crate::ui_text::{UiTextStyle, draw_text_baseline}; pub(in crate::ui) use cache::HelpLayoutCache; +#[cfg(test)] pub(crate) use entry::render_help_overlay_with_context; pub use entry::{render_help_overlay, render_help_overlay_result}; use footer::{FooterPill, FooterPillLayout, draw_footer_pills}; diff --git a/tests/ui.rs b/tests/ui.rs index 01b37f5f9..148f0058d 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -331,6 +331,14 @@ fn help_result_owns_rendered_footer_hits_and_preserves_legacy_paint_pixels() { ); } + let mut interactive = make_input_state(); + interactive.install_help_overlay_render_result(result.clone()); + let (about_x, about_y) = about.unwrap(); + assert_eq!( + interactive.help_overlay_click_at(about_x, about_y), + wayscriber::input::state::HelpOverlayClick::Run(Action::OpenAbout), + ); + let (mut legacy_surface, ctx) = surface_with_context(1400, 1000); let scroll = wayscriber::ui::render_help_overlay( &ctx, &style, 1400, 1000, true, 0, &bindings, "", false, true, true, 0.0, false, From 375b9dd3a58236846ec74ed40b4a835e759adc1b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:46:04 +0200 Subject: [PATCH 05/42] refactor(text): share runtime text resources with status and zoom --- src/backend/wayland/state/render/runtime.rs | 6 + src/backend/wayland/state/render/ui.rs | 6 +- .../wayland/state/render/ui_effect_damage.rs | 30 +-- src/input/state/core/status_hud.rs | 32 ++- src/input/state/core/zoom_chip.rs | 29 ++- src/ui.rs | 4 + src/ui/primitives.rs | 40 +++- src/ui/status/bar.rs | 7 +- src/ui/status/bar/content.rs | 28 ++- src/ui/status/bar/measurement.rs | 12 +- src/ui/status/bar/render.rs | 29 ++- src/ui/status/bar/tests.rs | 1 + src/ui/status/bar/tests/layout_and_hits.rs | 3 +- src/ui/status/mod.rs | 6 + src/ui/status/tests.rs | 213 ++++++++++++++++++ src/ui/status/zoom_chip.rs | 50 +++- 16 files changed, 451 insertions(+), 45 deletions(-) create mode 100644 src/ui/status/tests.rs diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index 56d2fc0a4..09743128e 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -122,6 +122,7 @@ pub(in crate::backend::wayland) struct RenderRuntime { draw_caches: crate::draw::RenderCaches, theme: crate::ui::theme::Theme, ui_caches: crate::ui::UiRenderCaches, + ui_text: crate::ui_text::UiTextEngine, ui_damage: UiDamageHistory, profile_ui_baseline: Vec, } @@ -133,11 +134,16 @@ impl RenderRuntime { draw_caches: crate::draw::RenderCaches::default(), theme, ui_caches: crate::ui::UiRenderCaches::default(), + ui_text: crate::ui_text::UiTextEngine::default(), ui_damage: UiDamageHistory::default(), profile_ui_baseline: Vec::new(), } } + pub(in crate::backend::wayland) fn ui_text(&self) -> &crate::ui_text::UiTextEngine { + &self.ui_text + } + pub(in crate::backend::wayland::state) fn theme(&self) -> &crate::ui::theme::Theme { &self.theme } diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index b0f16f4ec..55ff7bf3a 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -156,7 +156,8 @@ impl WaylandState { ); } if self.input_state.ui_visibility.show_status_bar { - crate::ui::render_status_bar_with_theme( + crate::ui::render_status_bar_with_resources( + self.render.ui_text(), ctx, self.render.theme(), &self.input_state, @@ -166,7 +167,8 @@ impl WaylandState { ); } if !capture_picker && self.zoom_chip_visible() { - crate::ui::render_zoom_chip_with_theme( + crate::ui::render_zoom_chip_with_resources( + self.render.ui_text(), ctx, self.render.theme(), &self.input_state, diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index 234d88646..1800dbb97 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -134,13 +134,15 @@ impl WaylandState { let chrome_cursor_focused = chrome_cursor_can_rehit(self.has_cursor_focus(), self.cursor_blocked_by_toolbar()); let status_hud_rect = if flags.active(UiEffect::StatusHud) { - self.input_state.update_status_hud_layout_for_pointer( - self.config.ui.status_bar_position, - &self.config.ui.status_bar_style, - width, - height, - chrome_cursor_focused, - ); + self.input_state + .update_status_hud_layout_for_pointer_with_engine( + self.render.ui_text(), + self.config.ui.status_bar_position, + &self.config.ui.status_bar_style, + width, + height, + chrome_cursor_focused, + ); crate::ui::status_hud_geometry(&self.input_state, width, height) .and_then(|bounds| effect_rect(bounds, width, height)) } else { @@ -162,12 +164,14 @@ impl WaylandState { // all read the same cache for the frame; the appear → move → disappear // union keeps stale pixels cleaned up when the percentage changes. let zoom_chip_rect = if flags.active(UiEffect::ZoomChip) { - self.input_state.update_zoom_chip_layout_for_pointer( - &self.config.ui.status_bar_style, - width, - height, - chrome_cursor_focused, - ); + self.input_state + .update_zoom_chip_layout_for_pointer_with_engine( + self.render.ui_text(), + &self.config.ui.status_bar_style, + width, + height, + chrome_cursor_focused, + ); crate::ui::zoom_chip_geometry(&self.input_state, width, height) .and_then(|bounds| effect_rect(bounds, width, height)) } else { diff --git a/src/input/state/core/status_hud.rs b/src/input/state/core/status_hud.rs index 95a3ede78..aee9bcb9a 100644 --- a/src/input/state/core/status_hud.rs +++ b/src/input/state/core/status_hud.rs @@ -11,7 +11,7 @@ use state::StatusHudRebuildInputs; pub use state::StatusHudState; use crate::config::{Action, StatusBarItem, StatusBarStyle, StatusPosition}; -use crate::ui::{StatusHudLayout, StatusHudSegmentKind, compute_status_hud_layout}; +use crate::ui::{StatusHudLayout, StatusHudSegmentKind}; use super::base::InputState; use super::board_picker::BoardPickerFocus; @@ -125,9 +125,37 @@ impl InputState { screen_width: u32, screen_height: u32, chrome_cursor_focused: bool, + ) { + crate::ui_text::with_legacy_engine(|engine| { + self.update_status_hud_layout_for_pointer_with_engine( + engine, + position, + style, + screen_width, + screen_height, + chrome_cursor_focused, + ) + }); + } + + pub(crate) fn update_status_hud_layout_for_pointer_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + position: StatusPosition, + style: &StatusBarStyle, + screen_width: u32, + screen_height: u32, + chrome_cursor_focused: bool, ) { let layout = if self.ui_visibility.show_status_bar { - compute_status_hud_layout(self, position, style, screen_width, screen_height) + crate::ui::compute_status_hud_layout_with_engine( + engine, + self, + position, + style, + screen_width, + screen_height, + ) } else { None }; diff --git a/src/input/state/core/zoom_chip.rs b/src/input/state/core/zoom_chip.rs index 673f76bb8..6df9c7444 100644 --- a/src/input/state/core/zoom_chip.rs +++ b/src/input/state/core/zoom_chip.rs @@ -20,7 +20,7 @@ mod state; pub use state::ZoomChipState; use crate::config::{Action, StatusBarStyle}; -use crate::ui::{ZoomChipButtonKind, ZoomChipLayout, ZoomChipPress, compute_zoom_chip_layout}; +use crate::ui::{ZoomChipButtonKind, ZoomChipLayout, ZoomChipPress}; use super::base::InputState; @@ -61,9 +61,34 @@ impl InputState { screen_width: u32, screen_height: u32, chrome_cursor_focused: bool, + ) { + crate::ui_text::with_legacy_engine(|engine| { + self.update_zoom_chip_layout_for_pointer_with_engine( + engine, + style, + screen_width, + screen_height, + chrome_cursor_focused, + ) + }); + } + + pub(crate) fn update_zoom_chip_layout_for_pointer_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + style: &StatusBarStyle, + screen_width: u32, + screen_height: u32, + chrome_cursor_focused: bool, ) { let layout = if self.zoom_chip_enabled() { - compute_zoom_chip_layout(self, style, screen_width, screen_height) + crate::ui::compute_zoom_chip_layout_with_engine( + engine, + self, + style, + screen_width, + screen_height, + ) } else { None }; diff --git a/src/ui.rs b/src/ui.rs index a11741534..99714341f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -81,6 +81,10 @@ pub use status::{ render_zoom_badge, render_zoom_chip, render_zoom_chip_with_theme, status_hud_geometry, zoom_chip_geometry, }; +pub(crate) use status::{ + compute_status_hud_layout_with_engine, compute_zoom_chip_layout_with_engine, + render_status_bar_with_resources, render_zoom_chip_with_resources, +}; pub use toasts::{ blocked_feedback_rects, preset_toast_geometry, render_blocked_feedback, render_preset_toast, render_ui_toast, ui_toast_geometry, diff --git a/src/ui/primitives.rs b/src/ui/primitives.rs index b292521ad..a4b94336a 100644 --- a/src/ui/primitives.rs +++ b/src/ui/primitives.rs @@ -408,7 +408,7 @@ pub(crate) enum BadgeAlign { } /// Badge box `(width, height, text_inset)` from measured label/hint extents. -/// Shared by [`draw_badge`] and [`measure_badge`] so layout and rendering can +/// Shared by [`draw_badge`] and [`measure_badge_with_engine`] so layout and rendering can /// never disagree about badge geometry. fn badge_box( label_extents: &crate::ui_text::UiTextExtents, @@ -431,12 +431,13 @@ fn badge_box( /// Measure the `(width, height)` [`draw_badge`] would occupy, without a /// rendering context (used for HUD badge stacking and damage geometry). -pub(crate) fn measure_badge( +pub(crate) fn measure_badge_with_engine( + engine: &UiTextEngine, label: &str, label_font_size: f64, hint: Option<(&str, f64)>, ) -> Option<(f64, f64)> { - let label_extents = crate::ui_text::measure_text( + let label_extents = engine.measure( UiTextStyle { family: "Sans", slant: cairo::FontSlant::Normal, @@ -447,7 +448,7 @@ pub(crate) fn measure_badge( None, )?; let hint_extents = match hint { - Some((text, font_size)) => Some(crate::ui_text::measure_text( + Some((text, font_size)) => Some(engine.measure( UiTextStyle { family: "Sans", slant: cairo::FontSlant::Normal, @@ -476,9 +477,36 @@ pub(crate) fn draw_badge( label_font_size: f64, hint: Option<(&str, f64)>, tint: [f64; 4], +) -> f64 { + with_legacy_engine(|engine| { + draw_badge_with_engine( + engine, + ctx, + anchor_x, + top_y, + align, + label, + label_font_size, + hint, + tint, + ) + }) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn draw_badge_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + anchor_x: f64, + top_y: f64, + align: BadgeAlign, + label: &str, + label_font_size: f64, + hint: Option<(&str, f64)>, + tint: [f64; 4], ) -> f64 { let padding = BADGE_PADDING; - let label_layout = text_layout( + let label_layout = engine.layout( ctx, UiTextStyle { family: "Sans", @@ -492,7 +520,7 @@ pub(crate) fn draw_badge( let label_extents = label_layout.ink_extents(); let hint_layout = hint.map(|(text, font_size)| { - let layout = text_layout( + let layout = engine.layout( ctx, UiTextStyle { family: "Sans", diff --git a/src/ui/status/bar.rs b/src/ui/status/bar.rs index 0690a86b2..3d0a7027b 100644 --- a/src/ui/status/bar.rs +++ b/src/ui/status/bar.rs @@ -1,7 +1,8 @@ use std::f64::consts::PI; use super::super::primitives::{ - BADGE_STACK_GAP, BadgeAlign, draw_badge, draw_pill, draw_rounded_rect, measure_badge, + BADGE_STACK_GAP, BadgeAlign, draw_badge_with_engine, draw_pill, draw_rounded_rect, + measure_badge_with_engine, }; use super::super::theme::{self, overlay}; use super::badges::{ @@ -13,7 +14,7 @@ use crate::config::{Action, StatusPosition, action_display_label}; use crate::input::{BoardBackground, DrawingState, InputState, TextInputMode, Tool}; use crate::label_format::{format_binding_labels, join_binding_labels}; use crate::ui::toolbar::bindings::action_for_tool; -use crate::ui_text::{UiTextExtents, UiTextStyle, measure_text, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextExtents, UiTextStyle, with_legacy_engine}; mod content; mod helpers; @@ -21,6 +22,8 @@ mod measurement; mod render; pub use content::compute_status_hud_layout; +pub(crate) use content::compute_status_hud_layout_with_engine; +pub(crate) use render::render_status_bar_with_resources; pub use render::{render_status_bar, render_status_bar_with_theme}; #[cfg(test)] diff --git a/src/ui/status/bar/content.rs b/src/ui/status/bar/content.rs index bafc62b67..bb38649fa 100644 --- a/src/ui/status/bar/content.rs +++ b/src/ui/status/bar/content.rs @@ -67,10 +67,30 @@ pub fn compute_status_hud_layout( style: &crate::config::StatusBarStyle, screen_width: u32, screen_height: u32, +) -> Option { + with_legacy_engine(|engine| { + compute_status_hud_layout_with_engine( + engine, + input_state, + position, + style, + screen_width, + screen_height, + ) + }) +} + +pub(crate) fn compute_status_hud_layout_with_engine( + engine: &UiTextEngine, + input_state: &InputState, + position: StatusPosition, + style: &crate::config::StatusBarStyle, + screen_width: u32, + screen_height: u32, ) -> Option { let text_style = status_text_style(style.font_size); let dot_diameter = style.dot_radius * 2.0; - let sep_extents = measure_text(text_style, SEGMENT_SEPARATOR, None)?; + let sep_extents = engine.measure(text_style, SEGMENT_SEPARATOR, None)?; let sep_advance = sep_extents.x_advance(); let mut pieces = build_cluster_pieces(input_state); @@ -80,7 +100,7 @@ pub fn compute_status_hud_layout( } for piece in &mut pieces { if let Some(text) = &piece.text { - piece.extents = Some(measure_text(text_style, text, None)?); + piece.extents = Some(engine.measure(text_style, text, None)?); } } // Degradation ladder while the width budget binds: shed optional display @@ -92,6 +112,7 @@ pub fn compute_status_hud_layout( let cluster_width = cluster_width(&pieces, sep_advance, dot_diameter); let line_metrics = cluster_line_metrics(&pieces, sep_extents); let measurement = measure_status_bar( + engine, style, prefix_text.as_deref().unwrap_or(""), cluster_width, @@ -113,7 +134,7 @@ pub fn compute_status_hud_layout( { let label = board_segment_label(input_state, limit); if piece.text.as_deref() != Some(label.as_str()) { - piece.extents = Some(measure_text(text_style, &label, None)?); + piece.extents = Some(engine.measure(text_style, &label, None)?); piece.text = Some(label); } } @@ -222,6 +243,7 @@ pub fn compute_status_hud_layout( widen_narrow_segments(&mut segments, pill_x, measurement.pill_width); let badges = layout_mode_badges( + engine, input_state, position, pill_x, diff --git a/src/ui/status/bar/measurement.rs b/src/ui/status/bar/measurement.rs index 653bb342c..981bf9378 100644 --- a/src/ui/status/bar/measurement.rs +++ b/src/ui/status/bar/measurement.rs @@ -47,6 +47,7 @@ pub(super) struct StatusBarMeasurement { /// budget. When the floor binds, `overflow` asks the caller to shed optional /// cluster pieces (the cluster cannot re-wrap the way the M0 suffix could). pub(super) fn measure_status_bar( + engine: &UiTextEngine, style: &crate::config::StatusBarStyle, prefix_text: &str, cluster_width: f64, @@ -56,7 +57,9 @@ pub(super) fn measure_status_bar( ) -> Option { let max_width = screen_width as f64 * STATUS_BAR_MAX_WIDTH_FRACTION - style.padding * 2.0; let text_style = status_text_style(style.font_size); - let sep_advance = measure_text(text_style, SEGMENT_SEPARATOR, None)?.x_advance(); + let sep_advance = engine + .measure(text_style, SEGMENT_SEPARATOR, None)? + .x_advance(); let has_prefix = !prefix_text.is_empty(); let separator_advance = if has_prefix && cluster_width > 0.0 { @@ -72,7 +75,7 @@ pub(super) fn measure_status_bar( // The floor binds when the cluster leaves less room than the // prefix is guaranteed; the caller sheds optional pieces then. let overflow = prefix_budget > available; - let extents = measure_text(text_style, prefix_text, Some(prefix_budget))?; + let extents = engine.measure(text_style, prefix_text, Some(prefix_budget))?; let width = extents.width().min(prefix_budget); ( prefix_budget, @@ -111,6 +114,7 @@ struct StatusHudBadgeSpec { /// Mode badges (FROZEN/ZOOM/PAN/EDITING) stacked directly above the HUD, or /// below it for top positions, aligned to the pill's near screen edge. pub(super) fn layout_mode_badges( + engine: &UiTextEngine, input_state: &InputState, position: StatusPosition, pill_x: f64, @@ -177,7 +181,9 @@ pub(super) fn layout_mode_badges( let mut badges = Vec::new(); let mut offset = BADGE_STACK_GAP; for spec in specs { - let Some((width, height)) = measure_badge(&spec.label, spec.font_size, spec.hint) else { + let Some((width, height)) = + measure_badge_with_engine(engine, &spec.label, spec.font_size, spec.hint) + else { continue; }; let x = if align_left { diff --git a/src/ui/status/bar/render.rs b/src/ui/status/bar/render.rs index ca2a3d43b..b778cbbbe 100644 --- a/src/ui/status/bar/render.rs +++ b/src/ui/status/bar/render.rs @@ -36,6 +36,26 @@ pub fn render_status_bar_with_theme( style: &crate::config::StatusBarStyle, screen_width: u32, screen_height: u32, +) { + render_status_bar_with_resources( + &UiTextEngine::default(), + ctx, + theme, + input_state, + style, + screen_width, + screen_height, + ); +} + +pub(crate) fn render_status_bar_with_resources( + engine: &UiTextEngine, + 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; @@ -98,7 +118,7 @@ pub fn render_status_bar_with_theme( if let Some(prefix) = &layout.prefix { // Center the (possibly wrapped) prefix block within the pill so a // second line never spills past the background. - let pango = text_layout(ctx, text_style, &prefix.text, Some(prefix.wrap_budget)); + let pango = engine.layout(ctx, text_style, &prefix.text, Some(prefix.wrap_budget)); let baseline = layout.pill_y + (layout.pill_height - prefix.height) / 2.0 - prefix.y_bearing; ctx.set_source_rgba(r, g, b, a); @@ -111,12 +131,12 @@ pub fn render_status_bar_with_theme( match run { StatusHudRun::Text { text, x, accent } => { ctx.set_source_rgba(r, g, b, a); - text_layout(ctx, text_style, text, None).show_at_baseline( + engine.layout(ctx, text_style, text, None).show_at_baseline( ctx, *x, layout.line_baseline, ); - if *accent && let Some(extents) = measure_text(text_style, text, None) { + if *accent && let Some(extents) = engine.measure(text_style, text, None) { // Underline the actionable hint run so it reads as // clickable against the informational runs. Follows the // palette text color, so it holds up on any board @@ -144,7 +164,8 @@ pub fn render_status_bar_with_theme( let _ = ctx.restore(); for badge in &layout.badges { - draw_badge( + draw_badge_with_engine( + engine, ctx, badge.x, badge.y, diff --git a/src/ui/status/bar/tests.rs b/src/ui/status/bar/tests.rs index 3890ff0f6..38394d8eb 100644 --- a/src/ui/status/bar/tests.rs +++ b/src/ui/status/bar/tests.rs @@ -27,6 +27,7 @@ fn measure( screen_width: u32, ) -> StatusBarMeasurement { measure_status_bar( + &UiTextEngine::default(), style, prefix, cluster_width, diff --git a/src/ui/status/bar/tests/layout_and_hits.rs b/src/ui/status/bar/tests/layout_and_hits.rs index 036daa38f..39f8cd0d8 100644 --- a/src/ui/status/bar/tests/layout_and_hits.rs +++ b/src/ui/status/bar/tests/layout_and_hits.rs @@ -45,7 +45,8 @@ fn pill_height_covers_min_interactive_hit_target() { ..StatusBarStyle::default() }; - let measurement = measure_status_bar(&style, "", 100.0, 9.0, 4.0, 1920).unwrap(); + let measurement = + measure_status_bar(&UiTextEngine::default(), &style, "", 100.0, 9.0, 4.0, 1920).unwrap(); assert!(measurement.pill_height >= MIN_INTERACTIVE_HEIGHT); } diff --git a/src/ui/status/mod.rs b/src/ui/status/mod.rs index f5cd86b08..5d051cb2f 100644 --- a/src/ui/status/mod.rs +++ b/src/ui/status/mod.rs @@ -12,3 +12,9 @@ pub use zoom_chip::{ ZoomChipButtonKind, ZoomChipLayout, ZoomChipPress, compute_zoom_chip_layout, render_zoom_chip, render_zoom_chip_with_theme, zoom_chip_geometry, }; + +pub(crate) use bar::{compute_status_hud_layout_with_engine, render_status_bar_with_resources}; +pub(crate) use zoom_chip::{compute_zoom_chip_layout_with_engine, render_zoom_chip_with_resources}; + +#[cfg(test)] +mod tests; diff --git a/src/ui/status/tests.rs b/src/ui/status/tests.rs new file mode 100644 index 000000000..8ebd77d50 --- /dev/null +++ b/src/ui/status/tests.rs @@ -0,0 +1,213 @@ +use super::*; +use crate::config::{StatusBarStyle, StatusPosition}; +use crate::input::InputState; +use crate::ui::theme::Theme; +use crate::ui_text::UiTextEngine; + +fn state() -> InputState { + crate::input::state::test_support::make_test_input_state() +} + +fn update(engine: &UiTextEngine, state: &mut InputState, style: &StatusBarStyle, focused: bool) { + state.update_status_hud_layout_for_pointer_with_engine( + engine, + StatusPosition::BottomLeft, + style, + 1280, + 720, + focused, + ); + state.update_zoom_chip_layout_for_pointer_with_engine(engine, style, 1280, 720, focused); +} + +fn paint( + engine: &UiTextEngine, + state: &InputState, + style: &StatusBarStyle, + theme: &Theme, + density: i32, + standalone: bool, +) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 1280 * density, 720 * density).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + if standalone { + render_status_bar_with_theme(&ctx, theme, state, style, 1280, 720); + render_zoom_chip_with_theme(&ctx, theme, state, style, 1280, 720); + } else { + render_status_bar_with_resources(engine, &ctx, theme, state, style, 1280, 720); + render_zoom_chip_with_resources(engine, &ctx, theme, state, style, 1280, 720); + } + } + surface.flush(); + surface.data().unwrap().to_vec() +} + +#[test] +fn explicit_status_and_zoom_frames_match_standalone_after_target_rebinding() { + let engine = UiTextEngine::default(); + let mut input = state(); + for (font_size, zoom, density) in [(10.0, 1.0, 1), (18.0, 2.0, 2), (10.0, 1.0, 1)] { + let style = StatusBarStyle { + font_size, + ..StatusBarStyle::default() + }; + input.set_zoom_status(zoom != 1.0, false, zoom, (0.0, 0.0)); + update(&engine, &mut input, &style, true); + let hud = input.status_hud_layout().unwrap(); + let chip = input.zoom_chip_layout().unwrap(); + // Compare the complete numeric layout, including runs and hit targets. + assert_eq!( + format!("{hud:?}"), + format!( + "{:?}", + compute_status_hud_layout(&input, StatusPosition::BottomLeft, &style, 1280, 720) + .unwrap() + ) + ); + assert_eq!( + format!("{chip:?}"), + format!( + "{:?}", + compute_zoom_chip_layout(&input, &style, 1280, 720).unwrap() + ) + ); + for (theme_name, theme) in [("dark", Theme::dark()), ("light", Theme::light())] { + let pixels = paint(&engine, &input, &style, &theme, density, false); + assert!( + pixels + .as_chunks::<4>() + .0 + .iter() + .filter(|pixel| pixel.iter().any(|byte| *byte != 0)) + .count() + > 1000 + ); + assert!( + pixels + == paint( + &UiTextEngine::default(), + &input, + &style, + &theme, + density, + false + ), + "fresh-owner paint differs: theme={theme_name}, density={density}, font={font_size}, zoom={zoom}" + ); + assert!( + pixels == paint(&engine, &input, &style, &theme, density, true), + "standalone paint differs: theme={theme_name}, density={density}, font={font_size}, zoom={zoom}" + ); + } + // Painting on a scaled target must not contaminate the next headless layout. + let before = ( + format!("{:?}", input.status_hud_layout()), + format!("{:?}", input.zoom_chip_layout()), + ); + update(&engine, &mut input, &style, true); + assert_eq!( + before, + ( + format!("{:?}", input.status_hud_layout()), + format!("{:?}", input.zoom_chip_layout()) + ) + ); + } +} + +#[test] +fn explicit_frame_layout_rehits_stationary_pointer_and_clears_unfocused_hover() { + let engine = UiTextEngine::default(); + let style = StatusBarStyle::default(); + let mut input = state(); + update(&engine, &mut input, &style, true); + let help = input + .status_hud_layout() + .unwrap() + .segments + .iter() + .find(|s| s.kind == StatusHudSegmentKind::Help) + .unwrap(); + let (x, y) = ( + (help.x + help.width / 2.0).round() as i32, + (help.y + help.height / 2.0).round() as i32, + ); + input.on_mouse_motion(x, y); + assert_eq!(input.status_hud.hover(), Some(StatusHudSegmentKind::Help)); + update(&engine, &mut input, &style, false); + assert_eq!(input.status_hud.hover(), None); + update(&engine, &mut input, &style, true); + assert_eq!(input.status_hud.hover(), Some(StatusHudSegmentKind::Help)); + + input.set_zoom_status(true, false, 2.0, (0.0, 0.0)); + update(&engine, &mut input, &style, true); + let fit = input + .zoom_chip_layout() + .unwrap() + .buttons + .iter() + .find(|b| b.kind == ZoomChipButtonKind::Fit) + .unwrap(); + let (x, y) = ( + (fit.x + fit.width / 2.0).round() as i32, + (fit.y + fit.height / 2.0).round() as i32, + ); + input.on_mouse_motion_with_canvas(x, y, x, y); + assert_eq!(input.zoom_chip.hover(), Some(ZoomChipButtonKind::Fit)); + input.set_zoom_status(false, false, 1.0, (0.0, 0.0)); + update(&engine, &mut input, &style, true); + let expected = input + .zoom_chip_layout() + .unwrap() + .button_at(f64::from(x), f64::from(y)); + assert_ne!(expected, Some(ZoomChipButtonKind::Fit)); + assert_eq!(input.zoom_chip.hover(), expected); + update(&engine, &mut input, &style, false); + assert_eq!(input.zoom_chip.hover(), None); + + input.ui_visibility.show_status_bar = false; + input.ui_visibility.show_zoom_chip = false; + update(&engine, &mut input, &style, true); + assert!(input.status_hud_layout().is_none()); + assert!(input.zoom_chip_layout().is_none()); +} + +#[test] +fn explicit_status_prefix_and_stacked_badge_paint_match_standalone() { + let engine = UiTextEngine::default(); + let mut input = state(); + input.ui_visibility.show_zoom_chip = false; + input.ui_visibility.show_active_output_badge = true; + input.set_active_output_label(Some("DP-3 Dell UltraSharp U2723QE 3840x2160@60".into())); + input.set_zoom_status(true, false, 2.0, (0.0, 0.0)); + let style = StatusBarStyle { + font_size: 28.0, + ..StatusBarStyle::default() + }; + update(&engine, &mut input, &style, true); + let layout = input.status_hud_layout().unwrap(); + assert!( + layout + .prefix + .as_ref() + .is_some_and(|prefix| prefix.height > 0.0) + ); + assert!( + layout + .badges + .iter() + .any(|badge| badge.label.contains("ZOOM")) + ); + assert!(input.zoom_chip_layout().is_none()); + let theme = Theme::dark(); + for density in [1, 2, 1] { + assert!( + paint(&engine, &input, &style, &theme, density, false) + == paint(&engine, &input, &style, &theme, density, true), + "prefix/badge paint differs: theme=dark, density={density}" + ); + } +} diff --git a/src/ui/status/zoom_chip.rs b/src/ui/status/zoom_chip.rs index 393d3229d..f6b1e0891 100644 --- a/src/ui/status/zoom_chip.rs +++ b/src/ui/status/zoom_chip.rs @@ -20,7 +20,7 @@ use super::super::primitives::{draw_pill, draw_rounded_rect}; use super::super::theme::{self, overlay}; use crate::config::StatusBarStyle; use crate::input::{BoardBackground, InputState}; -use crate::ui_text::{UiTextExtents, UiTextStyle, measure_text, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextExtents, UiTextStyle, with_legacy_engine}; // ============================================================================ // UI Layout Constants (not configurable) — mirror the status bar pill so the @@ -257,6 +257,24 @@ pub fn compute_zoom_chip_layout( style: &StatusBarStyle, screen_width: u32, screen_height: u32, +) -> Option { + with_legacy_engine(|engine| { + compute_zoom_chip_layout_with_engine( + engine, + input_state, + style, + screen_width, + screen_height, + ) + }) +} + +pub(crate) fn compute_zoom_chip_layout_with_engine( + engine: &UiTextEngine, + input_state: &InputState, + style: &StatusBarStyle, + screen_width: u32, + screen_height: u32, ) -> Option { let text_style = chip_text_style(style.font_size); @@ -285,7 +303,7 @@ pub fn compute_zoom_chip_layout( let mut digit_cap: Option = None; let mut pieces: Vec = Vec::with_capacity(specs.len()); for (text, kind) in specs { - let extents = measure_text(text_style, &text, None)?; + let extents = engine.measure(text_style, &text, None)?; if kind.is_none() { digit_cap = Some(-extents.y_bearing()); } @@ -548,6 +566,26 @@ pub fn render_zoom_chip_with_theme( style: &StatusBarStyle, screen_width: u32, screen_height: u32, +) { + render_zoom_chip_with_resources( + &UiTextEngine::default(), + ctx, + theme, + input_state, + style, + screen_width, + screen_height, + ); +} + +pub(crate) fn render_zoom_chip_with_resources( + engine: &UiTextEngine, + 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; @@ -628,11 +666,9 @@ pub fn render_zoom_chip_with_theme( } else { ctx.set_source_rgba(r, g, b, a); } - text_layout(ctx, text_style, &run.text, None).show_at_baseline( - ctx, - run.x, - layout.line_baseline, - ); + engine + .layout(ctx, text_style, &run.text, None) + .show_at_baseline(ctx, run.x, layout.line_baseline); } } } From 46338a1aeda520b810ff3cbafebcc666ee5f5007 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:46:09 +0200 Subject: [PATCH 06/42] refactor(draw): pass text measurement through shape bounds --- src/draw/dirty.rs | 8 +- src/draw/frame/types.rs | 11 +- src/draw/mod.rs | 3 +- src/draw/shape/arrow_label.rs | 32 ++- src/draw/shape/bounds.rs | 9 +- src/draw/shape/mod.rs | 2 + src/draw/shape/step_marker.rs | 33 ++- src/draw/shape/text.rs | 63 ++++- src/draw/shape/text_cache.rs | 6 +- src/draw/shape/text_cache/owner.rs | 4 +- src/draw/shape/text_cache/owner/tests.rs | 2 + .../shape/text_cache/owner/tests/bounds.rs | 259 ++++++++++++++++++ src/draw/shape/types.rs | 40 ++- src/input/tool/drawing.rs | 7 +- 14 files changed, 443 insertions(+), 36 deletions(-) create mode 100644 src/draw/shape/text_cache/owner/tests/bounds.rs diff --git a/src/draw/dirty.rs b/src/draw/dirty.rs index d6222472d..193d8962b 100644 --- a/src/draw/dirty.rs +++ b/src/draw/dirty.rs @@ -3,6 +3,7 @@ //! Collects axis-aligned rectangles that need repainting between frames. use super::Shape; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::util::Rect; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -74,7 +75,12 @@ impl DirtyTracker { /// Adds the bounding box for the given shape, or full damage if none is available. pub fn mark_shape(&mut self, shape: &Shape) { - match shape.bounding_box() { + with_legacy_measurer(|measurer| self.mark_shape_with(shape, measurer)); + } + + /// Adds bounds measured with the supplied owner, or full damage if unavailable. + pub fn mark_shape_with(&mut self, shape: &Shape, measurer: &TextMeasurer) { + match shape.bounding_box_with(measurer) { Some(rect) => self.mark_rect(rect), None => self.mark_full(), } diff --git a/src/draw/frame/types.rs b/src/draw/frame/types.rs index 05528d194..c21390331 100644 --- a/src/draw/frame/types.rs +++ b/src/draw/frame/types.rs @@ -1,4 +1,5 @@ use crate::draw::shape::Shape; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::util::Rect; use serde::{Deserialize, Serialize}; use std::cell::Cell; @@ -59,15 +60,21 @@ impl DrawnShape { /// [`Self::invalidate_bounds`]) trips an assertion, so the test suite /// catches invalidation bugs while release builds get the O(1) fast path. pub fn bounding_box(&self) -> Option { + with_legacy_measurer(|measurer| self.bounding_box_with(measurer)) + } + + /// Returns memoized bounds using the supplied owner for text measurements + /// and debug validation of the cached geometry. + pub fn bounding_box_with(&self, measurer: &TextMeasurer) -> Option { if let CachedBounds::Known(bounds) = self.cached_bounds.get() { debug_assert_eq!( bounds, - self.shape.bounding_box(), + self.shape.bounding_box_with(measurer), "stale DrawnShape bounds cache: a mutation site is missing invalidate_bounds()" ); return bounds; } - let bounds = self.shape.bounding_box(); + let bounds = self.shape.bounding_box_with(measurer); self.cached_bounds.set(CachedBounds::Known(bounds)); bounds } diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 5f4756435..87ee62a10 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -46,11 +46,12 @@ pub use render::{ render_text_over_with_halo, render_text_with_halo, selection_handle_rects, spotlight_regions_for_frame, sticky_note_foreground, text_outline_color, }; +pub(crate) use shape::with_legacy_measurer; #[allow(unused_imports)] pub use shape::{ ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, MAX_PEN_SMOOTHING, PolygonKind, REGULAR_POLYGON_DEFAULT_SIDES, REGULAR_POLYGON_MAX_SIDES, - REGULAR_POLYGON_MIN_SIDES, Shape, StepMarkerLabel, clamp_regular_sides, + REGULAR_POLYGON_MIN_SIDES, Shape, StepMarkerLabel, TextMeasurer, clamp_regular_sides, }; pub use spotlight::{ DEFAULT_SPOTLIGHT_MAGNIFICATION, MAX_SPOTLIGHT_MAGNIFICATION, MIN_SPOTLIGHT_MAGNIFICATION, diff --git a/src/draw/shape/arrow_label.rs b/src/draw/shape/arrow_label.rs index 6eef463ec..bbf0b444c 100644 --- a/src/draw/shape/arrow_label.rs +++ b/src/draw/shape/arrow_label.rs @@ -2,6 +2,7 @@ use crate::draw::{ArrowStyle, FontDescriptor}; use crate::util::Rect; use super::text::{text_bounds_from_metrics, text_layout_metrics}; +use super::text_cache::{TextMeasurer, with_legacy_measurer}; pub(crate) const ARROW_LABEL_BACKGROUND: bool = true; @@ -63,6 +64,35 @@ pub(crate) fn arrow_label_layout( label_text: &str, label_size: f64, font_descriptor: &FontDescriptor, +) -> Option { + with_legacy_measurer(|measurer| { + arrow_label_layout_with( + measurer, + tip_x, + tip_y, + tail_x, + tail_y, + thick, + bend, + label_text, + label_size, + font_descriptor, + ) + }) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn arrow_label_layout_with( + measurer: &TextMeasurer, + tip_x: i32, + tip_y: i32, + tail_x: i32, + tail_y: i32, + thick: f64, + bend: f64, + label_text: &str, + label_size: f64, + font_descriptor: &FontDescriptor, ) -> Option { if label_text.is_empty() { return None; @@ -109,7 +139,7 @@ pub(crate) fn arrow_label_layout( let anchor_x = base_x + nx * offset; let anchor_y = base_y + ny * offset; - let metrics = text_layout_metrics(label_text, label_size, font_descriptor, None)?; + let metrics = text_layout_metrics(measurer, label_text, label_size, font_descriptor, None)?; let center_offset_x = metrics.ink_x + metrics.ink_width / 2.0; let center_offset_y = metrics.ink_y + metrics.ink_height / 2.0; diff --git a/src/draw/shape/bounds.rs b/src/draw/shape/bounds.rs index 8f9a76bc7..52a89eca0 100644 --- a/src/draw/shape/bounds.rs +++ b/src/draw/shape/bounds.rs @@ -1,6 +1,7 @@ use crate::util::{self, Rect}; -use super::arrow_label::{arrow_label_ends, arrow_label_layout}; +use super::arrow_label::{arrow_label_ends, arrow_label_layout_with}; +use super::text_cache::TextMeasurer; use super::types::{ArrowLabel, ArrowStyle}; const MIN_COORDINATE: i64 = i32::MIN as i64; @@ -122,7 +123,8 @@ pub(crate) fn bounding_box_for_ellipse( /// behind the shaft. The arc is unioned from the same sampler the renderer /// walks, so the box cannot be tighter than what was drawn. #[allow(clippy::too_many_arguments)] -pub(crate) fn bounding_box_for_arrow( +pub(crate) fn bounding_box_for_arrow_with( + measurer: &TextMeasurer, x1: i32, y1: i32, x2: i32, @@ -180,7 +182,8 @@ pub(crate) fn bounding_box_for_arrow( if let Some(label) = label { let label_text = label.value.to_string(); - if let Some(layout) = arrow_label_layout( + if let Some(layout) = arrow_label_layout_with( + measurer, label_tip_x, label_tip_y, label_tail_x, diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs index a34806128..fe4740184 100644 --- a/src/draw/shape/mod.rs +++ b/src/draw/shape/mod.rs @@ -15,6 +15,8 @@ pub use polygon::{ REGULAR_POLYGON_MIN_SIDES, clamp_regular_sides, }; pub use smoothing::{MAX_PEN_SMOOTHING, clamp_pen_smoothing, smooth_path, smooth_pressure_path}; +pub use text_cache::TextMeasurer; +pub(crate) use text_cache::with_legacy_measurer; pub use types::{ ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, Shape, StepMarkerLabel, diff --git a/src/draw/shape/step_marker.rs b/src/draw/shape/step_marker.rs index 9f297c842..e4699e30d 100644 --- a/src/draw/shape/step_marker.rs +++ b/src/draw/shape/step_marker.rs @@ -2,16 +2,26 @@ use crate::draw::FontDescriptor; use crate::util::Rect; use super::bounds::ensure_positive_rect_f64; -use super::text_cache::measure_text_cached; +use super::text_cache::{TextMeasurer, with_legacy_measurer}; const STEP_MARKER_PADDING_RATIO: f64 = 0.45; const STEP_MARKER_PADDING_MIN: f64 = 6.0; const STEP_MARKER_MIN_RADIUS: f64 = 10.0; pub(crate) fn step_marker_radius(value: u32, size: f64, font_descriptor: &FontDescriptor) -> f64 { + with_legacy_measurer(|measurer| step_marker_radius_with(measurer, value, size, font_descriptor)) +} + +pub(crate) fn step_marker_radius_with( + measurer: &TextMeasurer, + value: u32, + size: f64, + font_descriptor: &FontDescriptor, +) -> f64 { let text = value.to_string(); let font_desc_str = font_descriptor.to_pango_string(size); - let max_dim = measure_text_cached(&text, &font_desc_str, size, None) + let max_dim = measurer + .measure(&text, &font_desc_str, size, None) .map(|m| m.ink_width.max(m.ink_height)) .unwrap_or(size * 0.6); let padding = (size * STEP_MARKER_PADDING_RATIO).max(STEP_MARKER_PADDING_MIN); @@ -22,14 +32,15 @@ pub(crate) fn step_marker_outline_thickness(size: f64) -> f64 { (size * 0.12).max(1.5) } -pub(crate) fn step_marker_bounds( +pub(crate) fn step_marker_bounds_with( + measurer: &TextMeasurer, x: i32, y: i32, value: u32, size: f64, font_descriptor: &FontDescriptor, ) -> Option { - let radius = step_marker_radius(value, size, font_descriptor); + let radius = step_marker_radius_with(measurer, value, size, font_descriptor); let outline = step_marker_outline_thickness(size); let total = radius + (outline / 2.0); ensure_positive_rect_f64( @@ -65,7 +76,8 @@ mod tests { #[test] fn step_marker_bounds_are_centered_around_marker_position() { let font = FontDescriptor::default(); - let bounds = step_marker_bounds(50, 75, 3, 18.0, &font).expect("step marker bounds"); + let bounds = step_marker_bounds_with(&TextMeasurer::default(), 50, 75, 3, 18.0, &font) + .expect("step marker bounds"); assert!(bounds.contains(50, 75)); assert!(bounds.width > 0); @@ -77,8 +89,15 @@ mod tests { let font = FontDescriptor::default(); for coordinate in [i32::MIN, i32::MAX] { - let bounds = step_marker_bounds(coordinate, coordinate, 3, 18.0, &font) - .expect("edge marker should retain visible bounds"); + let bounds = step_marker_bounds_with( + &TextMeasurer::default(), + coordinate, + coordinate, + 3, + 18.0, + &font, + ) + .expect("edge marker should retain visible bounds"); assert!(bounds.contains(coordinate, coordinate)); } } diff --git a/src/draw/shape/text.rs b/src/draw/shape/text.rs index c0d4fd52b..962c6afe9 100644 --- a/src/draw/shape/text.rs +++ b/src/draw/shape/text.rs @@ -2,9 +2,10 @@ use crate::draw::font::FontDescriptor; use crate::util::Rect; use super::bounds::ensure_positive_rect_f64; -use super::text_cache::{TextContentExtents, TextMeasurement, measure_text_cached}; +use super::text_cache::{TextContentExtents, TextMeasurement, TextMeasurer, with_legacy_measurer}; pub(super) fn text_layout_metrics( + measurer: &TextMeasurer, text: &str, size: f64, font_descriptor: &FontDescriptor, @@ -16,7 +17,7 @@ pub(super) fn text_layout_metrics( // Use cached text measurement instead of creating a new surface each time let font_desc_str = font_descriptor.to_pango_string(size); - let measurement = measure_text_cached(text, &font_desc_str, size, wrap_width)?; + let measurement = measurer.measure(text, &font_desc_str, size, wrap_width)?; Some(measurement) } @@ -82,7 +83,32 @@ pub(crate) fn bounding_box_for_text( background_enabled: bool, wrap_width: Option, ) -> Option { - let metrics = text_layout_metrics(text, size, font_descriptor, wrap_width)?; + with_legacy_measurer(|measurer| { + bounding_box_for_text_with( + measurer, + x, + y, + text, + size, + font_descriptor, + background_enabled, + wrap_width, + ) + }) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn bounding_box_for_text_with( + measurer: &TextMeasurer, + x: i32, + y: i32, + text: &str, + size: f64, + font_descriptor: &FontDescriptor, + background_enabled: bool, + wrap_width: Option, +) -> Option { + let metrics = text_layout_metrics(measurer, text, size, font_descriptor, wrap_width)?; text_bounds_from_metrics( x as f64, y as f64, @@ -206,7 +232,8 @@ pub(crate) fn sticky_note_text_layout( } } -pub(crate) fn bounding_box_for_sticky_note( +pub(crate) fn bounding_box_for_sticky_note_with( + measurer: &TextMeasurer, x: i32, y: i32, text: &str, @@ -217,7 +244,7 @@ pub(crate) fn bounding_box_for_sticky_note( if text.is_empty() { return None; } - bounding_box_for_sticky_note_layout(x, y, text, size, font_descriptor, wrap_width) + bounding_box_for_sticky_note_layout(measurer, x, y, text, size, font_descriptor, wrap_width) } pub(crate) fn bounding_box_for_sticky_note_preview( @@ -227,8 +254,31 @@ pub(crate) fn bounding_box_for_sticky_note_preview( size: f64, font_descriptor: &FontDescriptor, wrap_width: Option, +) -> Option { + with_legacy_measurer(|measurer| { + bounding_box_for_sticky_note_preview_with( + measurer, + x, + y, + text, + size, + font_descriptor, + wrap_width, + ) + }) +} + +pub(crate) fn bounding_box_for_sticky_note_preview_with( + measurer: &TextMeasurer, + x: i32, + y: i32, + text: &str, + size: f64, + font_descriptor: &FontDescriptor, + wrap_width: Option, ) -> Option { bounding_box_for_sticky_note_layout( + measurer, x, y, sticky_note_layout_text(text), @@ -239,6 +289,7 @@ pub(crate) fn bounding_box_for_sticky_note_preview( } fn bounding_box_for_sticky_note_layout( + measurer: &TextMeasurer, x: i32, y: i32, text: &str, @@ -248,7 +299,7 @@ fn bounding_box_for_sticky_note_layout( ) -> Option { // Use cached text measurement instead of creating a new surface each time let font_desc_str = font_descriptor.to_pango_string(size); - let measurement = measure_text_cached(text, &font_desc_str, size, wrap_width)?; + let measurement = measurer.measure(text, &font_desc_str, size, wrap_width)?; let base_x = x as f64; let base_y = y as f64 - measurement.baseline; diff --git a/src/draw/shape/text_cache.rs b/src/draw/shape/text_cache.rs index 83992ce0c..fa54684c9 100644 --- a/src/draw/shape/text_cache.rs +++ b/src/draw/shape/text_cache.rs @@ -3,7 +3,7 @@ mod cursor; mod owner; use cache::{TextCacheKey, TextMeasurementCache}; -use owner::TextMeasurer; +pub use owner::TextMeasurer; /// Cached text measurement results from Pango layout. #[derive(Clone, Debug)] @@ -52,7 +52,7 @@ thread_local! { static LEGACY_TEXT_MEASURER: TextMeasurer = TextMeasurer::default(); } -fn with_legacy_measurer(f: impl FnOnce(&TextMeasurer) -> R) -> R { +pub(crate) fn with_legacy_measurer(f: impl FnOnce(&TextMeasurer) -> R) -> R { LEGACY_TEXT_MEASURER.with(f) } @@ -114,7 +114,7 @@ pub(crate) fn measure_text_with_context( size: f64, wrap_width: Option, ) -> Option { - with_legacy_measurer(|measurer| measurer.measure(text, font_desc_str, size, wrap_width)) + measure_text_cached(text, font_desc_str, size, wrap_width) } /// Hit-test a point against a rendered text run, returning the caret byte diff --git a/src/draw/shape/text_cache/owner.rs b/src/draw/shape/text_cache/owner.rs index 384edc273..adbca38e5 100644 --- a/src/draw/shape/text_cache/owner.rs +++ b/src/draw/shape/text_cache/owner.rs @@ -4,8 +4,8 @@ use std::cell::RefCell; /// Canonical shape text measurements and cursor geometry for one owner. /// Construction creates no Cairo/Pango resources. All geometry uses the same /// measurement policy regardless of the eventual drawing destination. -pub(crate) struct TextMeasurer { - pub(super) cache: RefCell, +pub struct TextMeasurer { + cache: RefCell, context: RefCell>, } diff --git a/src/draw/shape/text_cache/owner/tests.rs b/src/draw/shape/text_cache/owner/tests.rs index 366341eca..79623cb6f 100644 --- a/src/draw/shape/text_cache/owner/tests.rs +++ b/src/draw/shape/text_cache/owner/tests.rs @@ -1,3 +1,5 @@ +mod bounds; + use super::*; use crate::draw::shape::text_cache::{VisualCaretDirection, VisualLineDirection, VisualLineEdge}; diff --git a/src/draw/shape/text_cache/owner/tests/bounds.rs b/src/draw/shape/text_cache/owner/tests/bounds.rs new file mode 100644 index 000000000..f238cfae4 --- /dev/null +++ b/src/draw/shape/text_cache/owner/tests/bounds.rs @@ -0,0 +1,259 @@ +use super::*; +use crate::draw::{ + ArrowLabel, ArrowStyle, Color, DrawnShape, FontDescriptor, Shape, StepMarkerLabel, +}; + +fn text_shapes() -> [Shape; 4] { + let font_descriptor = FontDescriptor::default(); + let color = Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }; + [ + Shape::Text { + x: 40, + y: 60, + text: "abc אבג 你好 wrapping words".into(), + color, + size: 18.0, + font_descriptor: font_descriptor.clone(), + background_enabled: true, + wrap_width: Some(100), + }, + Shape::StickyNote { + x: 40, + y: 60, + text: "a note with wrapping words".into(), + background: color, + size: 18.0, + font_descriptor: font_descriptor.clone(), + wrap_width: Some(100), + }, + Shape::Arrow { + x1: 20, + y1: 40, + x2: 90, + y2: 60, + color, + thick: 3.0, + arrow_length: 12.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Curved, + bend: 0.3, + label: Some(ArrowLabel { + value: 8888, + size: 24.0, + font_descriptor: font_descriptor.clone(), + }), + }, + Shape::StepMarker { + x: 50, + y: 60, + color, + label: StepMarkerLabel { + value: 8888, + size: 24.0, + font_descriptor, + }, + }, + ] +} + +#[test] +fn decorated_bounds_use_supplied_owner_across_memo_clone_and_serde() { + for shape in text_shapes() { + let owner = TextMeasurer::default(); + let independent = TextMeasurer::default(); + let drawn = DrawnShape::with_metadata(7, shape, 123, false); + let cold_clone = drawn.clone(); + let encoded = serde_json::to_string(&drawn).unwrap(); + let restored: DrawnShape = serde_json::from_str(&encoded).unwrap(); + assert!(owner.context.borrow().is_none()); + assert!(owner.cache.borrow().entries.is_empty()); + let expected = drawn.bounding_box_with(&owner).expect("decorated bounds"); + assert!(owner.context.borrow().is_some()); + assert_eq!( + owner.cache.borrow().entries.len(), + 1, + "each shape measures its text with the supplied owner" + ); + assert!(independent.context.borrow().is_none()); + assert_eq!(drawn.bounding_box_with(&owner), Some(expected)); + assert_eq!(cold_clone.bounding_box_with(&independent), Some(expected)); + assert_eq!(restored.bounding_box_with(&independent), Some(expected)); + assert_eq!( + drawn.clone().bounding_box_with(&independent), + Some(expected) + ); + assert_eq!(drawn.bounding_box(), Some(expected)); + assert_eq!(drawn.shape.bounding_box(), Some(expected)); + assert_eq!( + serde_json::to_string(&drawn).unwrap(), + encoded, + "measurement resources must not change persisted values" + ); + } +} + +#[test] +fn text_mutations_invalidate_memo_and_select_new_measurement_keys() { + for shape in text_shapes().into_iter().take(2) { + let owner = TextMeasurer::default(); + let mut drawn = DrawnShape::with_metadata(1, shape, 0, false); + let before = drawn.bounding_box_with(&owner).unwrap(); + match &mut drawn.shape { + Shape::Text { wrap_width, .. } | Shape::StickyNote { wrap_width, .. } => { + *wrap_width = Some(45) + } + _ => unreachable!(), + } + drawn.invalidate_bounds(); + let wrapped = drawn.bounding_box_with(&owner).unwrap(); + assert!(wrapped.height > before.height); + assert_eq!(owner.cache.borrow().entries.len(), 2); + match &mut drawn.shape { + Shape::Text { + size, + font_descriptor, + .. + } + | Shape::StickyNote { + size, + font_descriptor, + .. + } => { + *size = 32.0; + font_descriptor.weight = "bold".into(); + font_descriptor.style = "italic".into(); + } + _ => unreachable!(), + } + drawn.invalidate_bounds(); + let styled = drawn.bounding_box_with(&owner).unwrap(); + assert_ne!(styled, wrapped); + assert_eq!(owner.cache.borrow().entries.len(), 3); + assert_eq!( + Some(styled), + drawn.shape.bounding_box_with(&TextMeasurer::default()) + ); + } +} + +#[test] +fn numeric_and_empty_bounds_do_not_initialize_text_resources() { + let owner = TextMeasurer::default(); + let mut shapes = text_shapes(); + for shape in &mut shapes[..2] { + match shape { + Shape::Text { text, .. } | Shape::StickyNote { text, .. } => text.clear(), + _ => unreachable!(), + } + let drawn = DrawnShape::with_metadata(1, shape.clone(), 0, false); + assert_eq!(drawn.bounding_box_with(&owner), None); + assert_eq!(drawn.bounding_box_with(&owner), None); + assert_eq!(drawn.clone().bounding_box_with(&owner), None); + } + let numeric = Shape::BlurRect { + x: 10, + y: 20, + w: 30, + h: 40, + strength: 5.0, + style: Default::default(), + }; + assert!(numeric.bounding_box_with(&owner).is_some()); + assert!(owner.context.borrow().is_none()); + assert!(owner.cache.borrow().entries.is_empty()); +} + +#[test] +fn dirty_and_provisional_bounds_consume_the_supplied_owner() { + use crate::draw::DirtyTracker; + use crate::input::tool::ProvisionalToolStroke; + + let shape = text_shapes().into_iter().next().unwrap(); + let expected = shape.bounding_box().unwrap(); + let dirty_owner = TextMeasurer::default(); + let mut dirty = DirtyTracker::default(); + dirty.mark_shape_with(&shape, &dirty_owner); + assert_eq!(dirty_owner.cache.borrow().entries.len(), 1); + assert_eq!(dirty.take_regions(800, 600), vec![expected]); + + let preview_owner = TextMeasurer::default(); + let preview = ProvisionalToolStroke::Shape(shape); + assert_eq!(preview.bounds_with(&preview_owner), Some(expected)); + assert_eq!(preview_owner.cache.borrow().entries.len(), 1); + assert_eq!(preview.bounds(), Some(expected)); + + dirty.mark_shape_with( + &Shape::Freehand { + points: vec![], + color: Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + thick: 2.0, + }, + &dirty_owner, + ); + let full = dirty.take_regions(800, 600); + assert_eq!(full.len(), 1); + assert_eq!( + (full[0].x, full[0].y, full[0].width, full[0].height), + (0, 0, 800, 600) + ); +} + +#[cfg(debug_assertions)] +#[test] +#[should_panic(expected = "stale DrawnShape bounds cache")] +fn known_empty_bounds_still_validate_after_untracked_text_mutation() { + let owner = TextMeasurer::default(); + let mut shape = text_shapes().into_iter().next().unwrap(); + if let Shape::Text { text, .. } = &mut shape { + text.clear(); + } + let mut drawn = DrawnShape::with_metadata(1, shape, 0, false); + assert_eq!(drawn.bounding_box_with(&owner), None); + if let Shape::Text { text, .. } = &mut drawn.shape { + *text = "new drawable text".into(); + } + drawn.bounding_box_with(&owner); +} + +#[test] +fn changed_number_labels_refresh_cached_shape_bounds() { + for mut shape in text_shapes().into_iter().skip(2) { + let owner = TextMeasurer::default(); + let mut drawn = DrawnShape::with_metadata(1, shape.clone(), 0, false); + let before = drawn.bounding_box_with(&owner).unwrap(); + match &mut shape { + Shape::Arrow { + label: Some(label), .. + } => { + label.value = 888888; + label.size = 48.0; + label.font_descriptor.weight = "bold".into(); + } + Shape::StepMarker { label, .. } => { + label.value = 888888; + label.size = 48.0; + label.font_descriptor.weight = "bold".into(); + } + _ => unreachable!(), + } + drawn.set_shape(shape); + let after = drawn.bounding_box_with(&owner).unwrap(); + assert_ne!(before, after); + assert_eq!(owner.cache.borrow().entries.len(), 2); + assert_eq!( + Some(after), + drawn.shape.bounding_box_with(&TextMeasurer::default()) + ); + } +} diff --git a/src/draw/shape/types.rs b/src/draw/shape/types.rs index fae665b64..fcfbb1a8e 100644 --- a/src/draw/shape/types.rs +++ b/src/draw/shape/types.rs @@ -1,11 +1,12 @@ use super::bounds::{ - bounding_box_for_arrow, bounding_box_for_blur, bounding_box_for_ellipse, + bounding_box_for_arrow_with, bounding_box_for_blur, bounding_box_for_ellipse, bounding_box_for_eraser, bounding_box_for_line, bounding_box_for_points, bounding_box_for_pressure_points, bounding_box_for_rect, ensure_positive_rect_i64, }; use super::polygon::{PolygonKind, bounding_box_for_polygon}; -use super::step_marker::step_marker_bounds; -use super::text::{bounding_box_for_sticky_note, bounding_box_for_text}; +use super::step_marker::step_marker_bounds_with; +use super::text::{bounding_box_for_sticky_note_with, bounding_box_for_text_with}; +use super::text_cache::{TextMeasurer, with_legacy_measurer}; use crate::draw::color::Color; use crate::draw::font::FontDescriptor; use crate::util::Rect; @@ -434,6 +435,12 @@ impl Shape { /// Returns `None` when the shape has no drawable area or its full bounds cannot be /// represented safely by [`Rect`]. pub fn bounding_box(&self) -> Option { + with_legacy_measurer(|measurer| self.bounding_box_with(measurer)) + } + + /// Computes bounds using the caller's canonical text measurement owner. + /// Numeric-only shapes do not initialize text rendering resources. + pub fn bounding_box_with(&self, measurer: &TextMeasurer) -> Option { match self { Shape::Freehand { points, thick, .. } => bounding_box_for_points(points, *thick), Shape::FreehandPressure { points, .. } => bounding_box_for_pressure_points(points), @@ -473,7 +480,8 @@ impl Shape { bend, label, color: _, - } => bounding_box_for_arrow( + } => bounding_box_for_arrow_with( + measurer, *x1, *y1, *x2, @@ -496,7 +504,8 @@ impl Shape { background_enabled, wrap_width, .. - } => bounding_box_for_text( + } => bounding_box_for_text_with( + measurer, *x, *y, text, @@ -505,9 +514,14 @@ impl Shape { *background_enabled, *wrap_width, ), - Shape::StepMarker { x, y, label, .. } => { - step_marker_bounds(*x, *y, label.value, label.size, &label.font_descriptor) - } + Shape::StepMarker { x, y, label, .. } => step_marker_bounds_with( + measurer, + *x, + *y, + label.value, + label.size, + &label.font_descriptor, + ), Shape::StickyNote { x, y, @@ -516,7 +530,15 @@ impl Shape { font_descriptor, wrap_width, .. - } => bounding_box_for_sticky_note(*x, *y, text, *size, font_descriptor, *wrap_width), + } => bounding_box_for_sticky_note_with( + measurer, + *x, + *y, + text, + *size, + font_descriptor, + *wrap_width, + ), Shape::MarkerStroke { points, thick, .. } => { let inflated = (*thick * 1.35).max(*thick + 1.0); bounding_box_for_points(points, inflated) diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index 9453dbbc9..6b66d6e38 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -4,6 +4,7 @@ use crate::draw::shape::{ }; use crate::draw::{ ArrowLabel, ArrowStyle, BlurRectParams, BlurStyle, Color, EraserBrush, EraserKind, Shape, + TextMeasurer, with_legacy_measurer, }; use crate::input::tool::{ EraserMode, Tool, ToolDrawingBehavior, ToolPathKind, ToolPressureBehavior, @@ -409,6 +410,10 @@ impl Tool { impl<'a> ProvisionalToolStroke<'a> { pub(crate) fn bounds(&self) -> Option { + with_legacy_measurer(|measurer| self.bounds_with(measurer)) + } + + pub(crate) fn bounds_with(&self, measurer: &TextMeasurer) -> Option { match self { Self::BorrowedFreehand { points, size, .. } => bounding_box_for_points(points, *size), Self::BorrowedPressureFreehand { @@ -425,7 +430,7 @@ impl<'a> ProvisionalToolStroke<'a> { } Self::EraserPreview { points, size } => bounding_box_for_eraser(points, *size), Self::Shape(shape) => { - let bounds = shape.bounding_box(); + let bounds = shape.bounding_box_with(measurer); if matches!(shape, Shape::Polygon { .. }) { bounds.and_then(|rect| rect.inflated(PROVISIONAL_POLYGON_DAMAGE_PADDING)) } else { From 04282c9e571bd9d2f9d5e5d382e7aec0d5b4ab85 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:58:45 +0200 Subject: [PATCH 07/42] refactor(help): remove ambient hit-map storage --- src/ui.rs | 6 +- src/ui/help_overlay/mod.rs | 6 +- src/ui/help_overlay/render/entry.rs | 58 +++--------- src/ui/help_overlay/render/hit.rs | 107 ---------------------- src/ui/help_overlay/render/mod.rs | 6 -- src/ui/help_overlay/render/tests/cache.rs | 5 +- tests/ui.rs | 55 ++++++----- 7 files changed, 51 insertions(+), 192 deletions(-) delete mode 100644 src/ui/help_overlay/render/hit.rs diff --git a/src/ui.rs b/src/ui.rs index 99714341f..f79162e53 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -41,13 +41,11 @@ pub(crate) use eyedropper_loupe::{compute_eyedropper_loupe_layout, render_eyedro pub use font_picker::render_font_picker; #[allow(unused_imports)] 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_result_with_context; #[allow(unused_imports)] pub use help_overlay::{ - HelpHitMap, HelpOverlayRegion, HelpRenderResult, clear_help_overlay_hit_map, - help_overlay_region_at, render_help_overlay, render_help_overlay_result, + HelpHitMap, HelpOverlayRegion, HelpRenderResult, render_help_overlay, + render_help_overlay_result, }; pub use input_hud::{input_hud_geometry, render_input_hud}; pub(crate) use measure_badge::{ diff --git a/src/ui/help_overlay/mod.rs b/src/ui/help_overlay/mod.rs index 825669cee..09794e3bd 100644 --- a/src/ui/help_overlay/mod.rs +++ b/src/ui/help_overlay/mod.rs @@ -8,11 +8,9 @@ mod search; mod sections; mod types; -#[cfg(test)] -pub use render::install_help_hit_map_for_test; pub use render::{ - HelpHitMap, HelpOverlayRegion, HelpRenderResult, clear_help_overlay_hit_map, - help_overlay_region_at, render_help_overlay, render_help_overlay_result, + HelpHitMap, HelpOverlayRegion, HelpRenderResult, render_help_overlay, + render_help_overlay_result, }; pub use sections::HelpOverlayBindings; diff --git a/src/ui/help_overlay/render/entry.rs b/src/ui/help_overlay/render/entry.rs index 0e5b25461..cd072e19b 100644 --- a/src/ui/help_overlay/render/entry.rs +++ b/src/ui/help_overlay/render/entry.rs @@ -1,7 +1,8 @@ -use super::{HelpOverlayBindings, HelpRenderResult, hit, render_help_overlay_result_with_context}; +use super::{HelpOverlayBindings, HelpRenderResult, render_help_overlay_result_with_context}; /// Render help overlay showing all keybindings with call-local paint resources. -/// The overlay runtime uses the explicit-context entry point to retain its layout. +/// Returns the maximum scroll offset and discards interaction geometry. +/// Interactive callers should use [`render_help_overlay_result`] instead. #[allow(clippy::too_many_arguments)] pub fn render_help_overlay( ctx: &cairo::Context, @@ -18,14 +19,8 @@ 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, - }, + render_help_overlay_result( + ctx, style, screen_width, screen_height, @@ -39,9 +34,15 @@ pub fn render_help_overlay( scroll_offset, quick_mode, ) + .scroll_max } -/// Paint once and return owned scroll and hit geometry without changing the legacy map. +/// Paint once and return owned scroll and hit geometry. +/// +/// Query `result.hit_map.region_at(x, y)` directly, or pass the result to +/// [`crate::input::InputState::install_help_overlay_render_result`] before using +/// that input owner's click and cursor queries. Each caller retains its own map; +/// painting another overlay cannot replace it. #[allow(clippy::too_many_arguments)] pub fn render_help_overlay_result( ctx: &cairo::Context, @@ -80,38 +81,3 @@ pub fn render_help_overlay_result( 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 result = render_help_overlay_result_with_context( - render, - style, - screen_width, - screen_height, - frozen_enabled, - page_index, - bindings, - search_query, - context_filter, - board_enabled, - capture_enabled, - scroll_offset, - quick_mode, - ); - hit::store_help_hit_map(result.hit_map); - result.scroll_max -} diff --git a/src/ui/help_overlay/render/hit.rs b/src/ui/help_overlay/render/hit.rs deleted file mode 100644 index 46d21a14f..000000000 --- a/src/ui/help_overlay/render/hit.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Temporary owner-free interaction adapter for existing help callers. -//! New result-returning paint entry points keep their hit maps with the caller. - -use crate::help_overlay_interaction::{HelpHitMap, HelpOverlayRegion}; -use std::cell::RefCell; - -thread_local! { - static HIT_MAP: RefCell> = const { RefCell::new(None) }; -} - -pub(super) fn store_help_hit_map(map: HelpHitMap) { - HIT_MAP.with(|cell| *cell.borrow_mut() = Some(map)); -} - -/// Region under a point in the last overlay painted through the legacy renderer. -/// Result-returning renderers instead provide an independent owned map. -pub fn help_overlay_region_at(x: f64, y: f64) -> Option { - HIT_MAP.with(|cell| cell.borrow().as_ref()?.region_at(x, y)) -} - -/// Clear the legacy renderer's last-painted interaction geometry. -pub fn clear_help_overlay_hit_map() { - HIT_MAP.with(|cell| *cell.borrow_mut() = None); -} - -/// Install geometry for tests of the legacy pointer plumbing. -#[cfg(test)] -pub fn install_help_hit_map_for_test( - box_rect: (f64, f64, f64, f64), - search_rect: Option<(f64, f64, f64, f64)>, - rows: &[(f64, f64, f64, f64, crate::config::Action)], -) { - store_help_hit_map(HelpHitMap::new( - box_rect, - search_rect, - rows.iter() - .map(|&(x, y, w, h, action)| ((x, y, w, h), action)), - )); -} - -#[cfg(test)] -mod tests { - use super::*; - - use super::super::super::types::HelpRowHit; - use crate::config::Action; - - fn store_help_hit_map( - box_rect: (f64, f64, f64, f64), - search_rect: Option<(f64, f64, f64, f64)>, - rows: &[HelpRowHit], - ) { - super::store_help_hit_map(HelpHitMap::new( - box_rect, - search_rect, - rows.iter() - .map(|hit| ((hit.x, hit.y, hit.w, hit.h), hit.action)), - )); - } - - fn row_hit(x: f64, y: f64, w: f64, h: f64, action: Action) -> HelpRowHit { - HelpRowHit { x, y, w, h, action } - } - - #[test] - fn region_reports_none_outside_the_box() { - store_help_hit_map((100.0, 100.0, 200.0, 200.0), None, &[]); - assert_eq!(help_overlay_region_at(50.0, 50.0), None); - assert_eq!(help_overlay_region_at(320.0, 150.0), None); - assert_eq!( - help_overlay_region_at(150.0, 150.0), - Some(HelpOverlayRegion::Inside) - ); - clear_help_overlay_hit_map(); - } - - #[test] - fn rows_win_over_search_and_chrome() { - let rows = [row_hit(120.0, 180.0, 160.0, 30.0, Action::ToggleHelp)]; - store_help_hit_map( - (100.0, 100.0, 200.0, 200.0), - Some((110.0, 130.0, 180.0, 24.0)), - &rows, - ); - - assert_eq!( - help_overlay_region_at(150.0, 190.0), - Some(HelpOverlayRegion::Row(Action::ToggleHelp)) - ); - assert_eq!( - help_overlay_region_at(150.0, 140.0), - Some(HelpOverlayRegion::Search) - ); - assert_eq!( - help_overlay_region_at(150.0, 250.0), - Some(HelpOverlayRegion::Inside) - ); - clear_help_overlay_hit_map(); - } - - #[test] - fn cleared_map_answers_none() { - store_help_hit_map((0.0, 0.0, 100.0, 100.0), None, &[]); - clear_help_overlay_hit_map(); - assert_eq!(help_overlay_region_at(10.0, 10.0), None); - } -} diff --git a/src/ui/help_overlay/render/mod.rs b/src/ui/help_overlay/render/mod.rs index 04819483e..f33b4149c 100644 --- a/src/ui/help_overlay/render/mod.rs +++ b/src/ui/help_overlay/render/mod.rs @@ -8,7 +8,6 @@ mod entry; mod footer; mod frame; mod header; -mod hit; mod metrics; mod palette; mod state; @@ -18,17 +17,12 @@ use crate::config::{Action, action_label}; use crate::label_format::NOT_BOUND_LABEL; use crate::ui_text::{UiTextStyle, draw_text_baseline}; pub(in crate::ui) use cache::HelpLayoutCache; -#[cfg(test)] -pub(crate) use entry::render_help_overlay_with_context; pub use entry::{render_help_overlay, render_help_overlay_result}; use footer::{FooterPill, FooterPillLayout, draw_footer_pills}; use frame::draw_overlay_frame; use header::{HeaderContent, HeaderHint, draw_hints, draw_version_pill}; pub use crate::help_overlay_interaction::{HelpHitMap, HelpOverlayRegion, HelpRenderResult}; -#[cfg(test)] -pub use hit::install_help_hit_map_for_test; -pub use hit::{clear_help_overlay_hit_map, help_overlay_region_at}; const BULLET: &str = "\u{2022}"; diff --git a/src/ui/help_overlay/render/tests/cache.rs b/src/ui/help_overlay/render/tests/cache.rs index 65f57258a..795ee1544 100644 --- a/src/ui/help_overlay/render/tests/cache.rs +++ b/src/ui/help_overlay/render/tests/cache.rs @@ -169,7 +169,7 @@ fn paint(caches: &mut crate::ui::UiRenderCaches, inputs: &Inputs, scroll: f64) - theme: &theme, caches, }; - extent = super::super::render_help_overlay_with_context( + extent = super::super::render_help_overlay_result_with_context( &mut render, &inputs.style, inputs.width, @@ -183,7 +183,8 @@ fn paint(caches: &mut crate::ui::UiRenderCaches, inputs: &Inputs, scroll: f64) - inputs.capture, scroll, inputs.quick, - ); + ) + .scroll_max; } surface.flush(); (surface.data().unwrap().to_vec(), extent) diff --git a/tests/ui.rs b/tests/ui.rs index 148f0058d..db5c51aef 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -266,8 +266,7 @@ fn help_overlay_footer_offers_clickable_replay_and_about() { let (_surface, ctx) = surface_with_context(1400, 1000); let input = make_input_state(); let bindings = wayscriber::ui::HelpOverlayBindings::from_input_state(&input); - wayscriber::ui::clear_help_overlay_hit_map(); - wayscriber::ui::render_help_overlay( + let result = wayscriber::ui::render_help_overlay_result( &ctx, &style, 1400, 1000, true, 0, &bindings, "", false, true, true, 0.0, false, ); drop(ctx); @@ -276,7 +275,7 @@ fn help_overlay_footer_offers_clickable_replay_and_about() { for y in 0..1000 { for x in 0..1400 { if let Some(HelpOverlayRegion::Row(action)) = - wayscriber::ui::help_overlay_region_at(x as f64, y as f64) + result.hit_map.region_at(x as f64, y as f64) && !found.contains(&action) { found.push(action); @@ -292,18 +291,16 @@ fn help_overlay_footer_offers_clickable_replay_and_about() { found.contains(&Action::OpenAbout), "about is clickable from the help overlay: {found:?}" ); - wayscriber::ui::clear_help_overlay_hit_map(); } #[test] -fn help_result_owns_rendered_footer_hits_and_preserves_legacy_paint_pixels() { +fn help_result_owns_rendered_footer_hits_and_matches_drawing_only_pixels() { use wayscriber::ui::HelpOverlayRegion; let style = HelpOverlayStyle::default(); let input = make_input_state(); let bindings = wayscriber::ui::HelpOverlayBindings::from_input_state(&input); let (mut owned_surface, ctx) = surface_with_context(1400, 1000); - wayscriber::ui::clear_help_overlay_hit_map(); let result = wayscriber::ui::render_help_overlay_result( &ctx, &style, 1400, 1000, true, 0, &bindings, "", false, true, true, 0.0, false, ); @@ -320,16 +317,8 @@ fn help_result_owns_rendered_footer_hits_and_preserves_legacy_paint_pixels() { } } } - for point in [ - replay.expect("rendered Replay tour target"), - about.expect("rendered About target"), - ] { - assert_eq!( - wayscriber::ui::help_overlay_region_at(point.0 as f64, point.1 as f64), - None, - "owned result must not install the legacy singleton" - ); - } + replay.expect("rendered Replay tour target"); + about.expect("rendered About target"); let mut interactive = make_input_state(); interactive.install_help_overlay_render_result(result.clone()); @@ -339,25 +328,45 @@ fn help_result_owns_rendered_footer_hits_and_preserves_legacy_paint_pixels() { wayscriber::input::state::HelpOverlayClick::Run(Action::OpenAbout), ); - let (mut legacy_surface, ctx) = surface_with_context(1400, 1000); + let (mut drawing_surface, ctx) = surface_with_context(1400, 1000); let scroll = wayscriber::ui::render_help_overlay( &ctx, &style, 1400, 1000, true, 0, &bindings, "", false, true, true, 0.0, false, ); drop(ctx); assert_eq!(result.scroll_max, scroll); owned_surface.flush(); - legacy_surface.flush(); + drawing_surface.flush(); let owned = owned_surface.data().unwrap(); - let legacy = legacy_surface.data().unwrap(); + let drawing = drawing_surface.data().unwrap(); assert!( - owned[..] == legacy[..], - "result and legacy paths must paint identical pixels" + owned[..] == drawing[..], + "result and drawing-only paths must paint identical pixels" + ); + let (_surface, ctx) = surface_with_context(320, 240); + wayscriber::ui::render_help_overlay( + &ctx, + &style, + 320, + 240, + false, + 0, + &bindings, + "no matching action", + false, + false, + false, + 0.0, + true, + ); + assert_eq!( + interactive.help_overlay_click_at(about_x, about_y), + wayscriber::input::state::HelpOverlayClick::Run(Action::OpenAbout), + "painting another overlay must not replace the installed owner's map", ); - wayscriber::ui::clear_help_overlay_hit_map(); let (x, y) = about.unwrap(); assert_eq!( result.hit_map.region_at(x as f64, y as f64), Some(HelpOverlayRegion::Row(Action::OpenAbout)), - "clearing the legacy map must not erase the owned result" + "another paint must not replace the owned result" ); } From c818283e91282fa4ae9e67683a1efa85d3776518 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:58:58 +0200 Subject: [PATCH 08/42] refactor(text): pass resources through synchronous UI layout changes --- src/input/state/core/status_hud.rs | 21 ++- src/input/state/core/tool_controls/toolbar.rs | 46 ++++- src/input/state/core/toolbar/apply/layout.rs | 124 ++++++++++++- src/input/state/core/tour.rs | 12 +- src/input/state/core/utility/focus_mode.rs | 6 +- src/input/state/core/utility/light_mode.rs | 42 ++++- .../state/core/utility/presenter_mode.rs | 18 +- src/input/state/tests/status_hud.rs | 2 + .../tests/status_hud/engine_mutations.rs | 165 ++++++++++++++++++ 9 files changed, 411 insertions(+), 25 deletions(-) create mode 100644 src/input/state/tests/status_hud/engine_mutations.rs diff --git a/src/input/state/core/status_hud.rs b/src/input/state/core/status_hud.rs index aee9bcb9a..9dc171285 100644 --- a/src/input/state/core/status_hud.rs +++ b/src/input/state/core/status_hud.rs @@ -51,6 +51,17 @@ impl InputState { &mut self, item: StatusBarItem, visible: bool, + ) -> bool { + crate::ui_text::with_legacy_engine(|engine| { + self.set_status_bar_item_visible_with_engine(engine, item, visible) + }) + } + + pub(crate) fn set_status_bar_item_visible_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + item: StatusBarItem, + visible: bool, ) -> bool { if self.status_bar_item_visible(item) == visible { return false; @@ -70,7 +81,7 @@ impl InputState { StatusBarItem::Help => self.ui_visibility.show_status_help = visible, StatusBarItem::About => self.ui_visibility.show_status_about = visible, } - self.refresh_status_hud_layout(); + self.refresh_status_hud_layout_with_engine(engine); self.needs_redraw = true; true } @@ -81,13 +92,17 @@ impl InputState { /// narrow outputs — between the mutation and the next frame, and hover is /// re-derived so a vanished segment cannot stay lit. Damage stays with /// the render effect pass, which re-measures with that frame's inputs. - pub(crate) fn refresh_status_hud_layout(&mut self) { + pub(crate) fn refresh_status_hud_layout_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + ) { let Some(inputs) = self.status_hud.rebuild_inputs() else { self.status_hud.layout = None; self.status_hud.hover = None; return; }; - self.update_status_hud_layout_for_pointer( + self.update_status_hud_layout_for_pointer_with_engine( + engine, inputs.position, &inputs.style, inputs.screen_width, diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs index 4e68f2e24..d01958239 100644 --- a/src/input/state/core/tool_controls/toolbar.rs +++ b/src/input/state/core/tool_controls/toolbar.rs @@ -12,10 +12,20 @@ pub(crate) const CLEAR_UNDO_TOAST_MS: u64 = 2000; impl InputState { /// Sets toolbar visibility without changing its persisted pin. pub fn set_toolbar_visible(&mut self, visible: bool) -> bool { + crate::ui_text::with_legacy_engine(|engine| { + self.set_toolbar_visible_with_engine(engine, visible) + }) + } + + pub(crate) fn set_toolbar_visible_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + visible: bool, + ) -> bool { if !self.toolbar.set_visible(visible) { return false; } - self.refresh_status_hud_layout(); + self.refresh_status_hud_layout_with_engine(engine); self.needs_redraw = true; true } @@ -23,12 +33,21 @@ impl InputState { /// Re-derive live visibility from the persisted pin without surfacing a /// toolbar hidden by a transient chrome owner. pub(crate) fn derive_toolbar_visibility_from_pins(&mut self) { + crate::ui_text::with_legacy_engine(|engine| { + self.derive_toolbar_visibility_from_pins_with_engine(engine) + }) + } + + pub(crate) fn derive_toolbar_visibility_from_pins_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + ) { let visible = self.toolbar.top_pinned(); if self.modes.retarget_visibility_from_pin(visible) { return; } self.toolbar.derive_visibility_from_pins(); - self.refresh_status_hud_layout(); + self.refresh_status_hud_layout_with_engine(engine); } pub(crate) fn warn_if_all_chrome_hidden(&mut self) { @@ -221,15 +240,34 @@ impl InputState { } pub(crate) fn set_top_display_mode(&mut self, mode: TopDisplayMode) { + crate::ui_text::with_legacy_engine(|engine| { + self.set_top_display_mode_with_engine(engine, mode) + }) + } + + pub(crate) fn set_top_display_mode_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + mode: TopDisplayMode, + ) { self.toolbar.set_top_display_mode(mode); - self.refresh_status_hud_layout(); + self.refresh_status_hud_layout_with_engine(engine); self.needs_redraw = true; } pub fn cycle_top_toolbar_display(&mut self) -> TopDisplayMode { + crate::ui_text::with_legacy_engine(|engine| { + self.cycle_top_toolbar_display_with_engine(engine) + }) + } + + pub(crate) fn cycle_top_toolbar_display_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + ) -> TopDisplayMode { let current = self.top_display_state(); let next = self.toolbar.cycle_top_display_mode(current); - self.refresh_status_hud_layout(); + self.refresh_status_hud_layout_with_engine(engine); self.needs_redraw = true; next } diff --git a/src/input/state/core/toolbar/apply/layout.rs b/src/input/state/core/toolbar/apply/layout.rs index c20dd24b6..096fc4790 100644 --- a/src/input/state/core/toolbar/apply/layout.rs +++ b/src/input/state/core/toolbar/apply/layout.rs @@ -65,6 +65,16 @@ impl InputState { pub(super) fn apply_toolbar_set_top_display_mode( &mut self, mode: crate::config::TopDisplayMode, + ) -> bool { + crate::ui_text::with_legacy_engine(|engine| { + self.apply_toolbar_set_top_display_mode_with_engine(engine, mode) + }) + } + + pub(super) fn apply_toolbar_set_top_display_mode_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + mode: crate::config::TopDisplayMode, ) -> bool { // Same presenter gate as Action::CycleToolbarDisplay: while presenter // mode owns toolbar visibility (e.g. the micro chip mapping), a chip @@ -77,7 +87,7 @@ impl InputState { if self.top_display_state() == mode { return false; } - self.set_top_display_mode(mode); + self.set_top_display_mode_with_engine(engine, mode); true } @@ -205,6 +215,16 @@ impl InputState { } pub(super) fn apply_toolbar_toggle_status_bar(&mut self, show: bool) -> bool { + crate::ui_text::with_legacy_engine(|engine| { + self.apply_toolbar_toggle_status_bar_with_engine(engine, show) + }) + } + + pub(super) fn apply_toolbar_toggle_status_bar_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + show: bool, + ) -> bool { if self.presenter_mode_active() && self.presenter_mode_config().hide_status_bar { return false; } @@ -213,7 +233,7 @@ impl InputState { self.break_focus_mode(); if self.ui_visibility.show_status_bar != show { self.ui_visibility.show_status_bar = show; - self.refresh_status_hud_layout(); + self.refresh_status_hud_layout_with_engine(engine); self.needs_redraw = true; true } else { @@ -236,15 +256,54 @@ impl InputState { item: crate::config::StatusBarItem, visible: bool, ) -> bool { - self.set_status_bar_item_visible(item, visible) + crate::ui_text::with_legacy_engine(|engine| { + self.apply_toolbar_set_status_bar_item_visible_with_engine(engine, item, visible) + }) + } + + pub(super) fn apply_toolbar_set_status_bar_item_visible_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + item: crate::config::StatusBarItem, + visible: bool, + ) -> bool { + self.set_status_bar_item_visible_with_engine(engine, item, visible) } pub(super) fn apply_toolbar_toggle_status_board_badge(&mut self, show: bool) -> bool { - self.set_status_bar_item_visible(crate::config::StatusBarItem::Board, show) + crate::ui_text::with_legacy_engine(|engine| { + self.apply_toolbar_toggle_status_board_badge_with_engine(engine, show) + }) + } + + pub(super) fn apply_toolbar_toggle_status_board_badge_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + show: bool, + ) -> bool { + self.set_status_bar_item_visible_with_engine( + engine, + crate::config::StatusBarItem::Board, + show, + ) } pub(super) fn apply_toolbar_toggle_status_page_badge(&mut self, show: bool) -> bool { - self.set_status_bar_item_visible(crate::config::StatusBarItem::Page, show) + crate::ui_text::with_legacy_engine(|engine| { + self.apply_toolbar_toggle_status_page_badge_with_engine(engine, show) + }) + } + + pub(super) fn apply_toolbar_toggle_status_page_badge_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + show: bool, + ) -> bool { + self.set_status_bar_item_visible_with_engine( + engine, + crate::config::StatusBarItem::Page, + show, + ) } pub(super) fn apply_toolbar_toggle_floating_badge_always(&mut self, show: bool) -> bool { @@ -746,4 +805,59 @@ mod tests { .contains(&ToolbarSectionFlag::Presets.item_id()) ); } + + #[test] + fn explicit_status_layout_events_preserve_synchronous_geometry_and_results() { + use crate::config::{StatusBarItem, StatusBarStyle, StatusPosition, TopDisplayMode}; + use crate::ui_text::UiTextEngine; + let engine = UiTextEngine::default(); + for event in [ + ToolbarEvent::SetTopDisplayMode(TopDisplayMode::Micro), + ToolbarEvent::ToggleStatusBar(false), + ToolbarEvent::SetStatusBarItemVisible(StatusBarItem::Help, false), + ToolbarEvent::ToggleStatusBoardBadge(false), + ToolbarEvent::ToggleStatusPageBadge(false), + ] { + let mut explicit = make_test_input_state(); + let mut legacy = make_test_input_state(); + for input in [&mut explicit, &mut legacy] { + input.update_status_hud_layout_for_pointer_with_engine( + &engine, + StatusPosition::BottomLeft, + &StatusBarStyle::default(), + 1280, + 720, + true, + ); + } + let changed = match event.clone() { + ToolbarEvent::SetTopDisplayMode(mode) => { + explicit.apply_toolbar_set_top_display_mode_with_engine(&engine, mode) + } + ToolbarEvent::ToggleStatusBar(show) => { + explicit.apply_toolbar_toggle_status_bar_with_engine(&engine, show) + } + ToolbarEvent::SetStatusBarItemVisible(item, visible) => explicit + .apply_toolbar_set_status_bar_item_visible_with_engine(&engine, item, visible), + ToolbarEvent::ToggleStatusBoardBadge(show) => { + explicit.apply_toolbar_toggle_status_board_badge_with_engine(&engine, show) + } + ToolbarEvent::ToggleStatusPageBadge(show) => { + explicit.apply_toolbar_toggle_status_page_badge_with_engine(&engine, show) + } + _ => unreachable!(), + }; + assert_eq!(changed, legacy.apply_toolbar_event(event)); + assert_eq!( + explicit.ui_visibility.show_status_bar, + legacy.ui_visibility.show_status_bar + ); + assert_eq!(explicit.top_display_state(), legacy.top_display_state()); + assert_eq!( + format!("{:?}", explicit.status_hud_layout()), + format!("{:?}", legacy.status_hud_layout()) + ); + assert_eq!(explicit.status_hud.hover(), legacy.status_hud.hover()); + } + } } diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs index 9e4982614..922792057 100644 --- a/src/input/state/core/tour.rs +++ b/src/input/state/core/tour.rs @@ -296,11 +296,15 @@ impl InputState { /// Start the guided tour. pub fn start_tour(&mut self) { + crate::ui_text::with_legacy_engine(|engine| self.start_tour_with_engine(engine)) + } + + pub(crate) fn start_tour_with_engine(&mut self, engine: &crate::ui_text::UiTextEngine) { if self.focus_mode_active() { // The tour restores pinned chrome when it ends, so it must begin // from Focus Mode's real baseline rather than nesting underneath // that transient snapshot owner. - self.toggle_focus_mode(); + self.toggle_focus_mode_with_engine(engine); } self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::Tour); self.tour.start(); @@ -313,7 +317,11 @@ impl InputState { /// starts the overlay regardless of the persisted `tour_shown` flag — and /// so a future replay-specific behavior has a single call site to hang on. pub fn start_tour_replay(&mut self) { - self.start_tour(); + crate::ui_text::with_legacy_engine(|engine| self.start_tour_replay_with_engine(engine)) + } + + pub(crate) fn start_tour_replay_with_engine(&mut self, engine: &crate::ui_text::UiTextEngine) { + self.start_tour_with_engine(engine); } /// End the tour (skip or complete). diff --git a/src/input/state/core/utility/focus_mode.rs b/src/input/state/core/utility/focus_mode.rs index 07abc3535..65557788f 100644 --- a/src/input/state/core/utility/focus_mode.rs +++ b/src/input/state/core/utility/focus_mode.rs @@ -95,6 +95,10 @@ impl InputState { /// - nothing visible and no snapshot → show everything (rescue arm, so /// the action always has a visible effect). pub(crate) fn toggle_focus_mode(&mut self) { + crate::ui_text::with_legacy_engine(|engine| self.toggle_focus_mode_with_engine(engine)) + } + + pub(crate) fn toggle_focus_mode_with_engine(&mut self, engine: &crate::ui_text::UiTextEngine) { if self.light_mode_active() { self.exit_light_mode(); } @@ -119,7 +123,7 @@ impl InputState { || self.fallback_mode_badge_may_be_active(); if !anything_to_hide { self.clear_all_chrome_recovery_toast(); - self.set_toolbar_visible(true); + self.set_toolbar_visible_with_engine(engine, true); self.ui_visibility.show_status_bar = true; self.ui_visibility.show_floating_badge = true; self.ui_visibility.show_zoom_chip = true; diff --git a/src/input/state/core/utility/light_mode.rs b/src/input/state/core/utility/light_mode.rs index 8301f2792..12322b04e 100644 --- a/src/input/state/core/utility/light_mode.rs +++ b/src/input/state/core/utility/light_mode.rs @@ -53,6 +53,13 @@ impl InputState { } pub(crate) fn toggle_light_mode(&mut self) -> bool { + crate::ui_text::with_legacy_engine(|engine| self.toggle_light_mode_with_engine(engine)) + } + + pub(crate) fn toggle_light_mode_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + ) -> bool { if self.light_mode_active() { self.exit_light_mode(); } else { @@ -65,21 +72,40 @@ impl InputState { self.needs_redraw = true; return false; } - self.enter_light_mode(false); + self.enter_light_mode_with_engine(engine, false); } self.light_mode_active() } pub fn toggle_light_mode_drawing(&mut self) -> bool { + crate::ui_text::with_legacy_engine(|engine| { + self.toggle_light_mode_drawing_with_engine(engine) + }) + } + + pub(crate) fn toggle_light_mode_drawing_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + ) -> bool { let drawing = if self.light_mode_active() { !self.light_mode_drawing_active() } else { true }; - self.set_light_mode_drawing(drawing) + self.set_light_mode_drawing_with_engine(engine, drawing) } pub fn set_light_mode_drawing(&mut self, drawing: bool) -> bool { + crate::ui_text::with_legacy_engine(|engine| { + self.set_light_mode_drawing_with_engine(engine, drawing) + }) + } + + pub(crate) fn set_light_mode_drawing_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + drawing: bool, + ) -> bool { if !self.light_mode_active() { if drawing { if !self.light_mode_supported() { @@ -91,7 +117,7 @@ impl InputState { self.needs_redraw = true; return false; } - self.enter_light_mode(true); + self.enter_light_mode_with_engine(engine, true); } return self.light_mode_drawing_active(); } @@ -139,12 +165,16 @@ impl InputState { self.needs_redraw = true; } - fn enter_light_mode(&mut self, drawing: bool) { + fn enter_light_mode_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + drawing: bool, + ) { if self.focus_mode_active() { - self.toggle_focus_mode(); + self.toggle_focus_mode_with_engine(engine); } if self.presenter_mode_active() { - self.toggle_presenter_mode(); + self.toggle_presenter_mode_with_engine(engine); } self.cancel_active_interaction(); diff --git a/src/input/state/core/utility/presenter_mode.rs b/src/input/state/core/utility/presenter_mode.rs index 051e1f32a..d3c365e50 100644 --- a/src/input/state/core/utility/presenter_mode.rs +++ b/src/input/state/core/utility/presenter_mode.rs @@ -65,10 +65,17 @@ impl InputState { } pub(crate) fn toggle_presenter_mode(&mut self) -> bool { + crate::ui_text::with_legacy_engine(|engine| self.toggle_presenter_mode_with_engine(engine)) + } + + pub(crate) fn toggle_presenter_mode_with_engine( + &mut self, + engine: &crate::ui_text::UiTextEngine, + ) -> bool { if self.presenter_mode_active() { self.stop_presenter_mode() } else { - self.start_presenter_mode() + self.start_presenter_mode(engine) } } @@ -108,7 +115,7 @@ impl InputState { self.presenter_mode_active() } - fn start_presenter_mode(&mut self) -> bool { + fn start_presenter_mode(&mut self, engine: &crate::ui_text::UiTextEngine) -> bool { let config = self.presenter_mode_config().clone(); if self.light_mode_active() { self.exit_light_mode(); @@ -118,7 +125,7 @@ impl InputState { // own chrome baseline. This keeps the two transient owners from // nesting and lets micro-toolbar presenter policy operate on the // real pre-Focus visibility. - self.toggle_focus_mode(); + self.toggle_focus_mode_with_engine(engine); } if config.close_help_overlay && self.help_overlay.visible { @@ -148,7 +155,10 @@ impl InputState { } crate::config::PresenterToolbarMode::Micro => { // The top strip stays up as the micro chip. - self.set_top_display_mode(crate::config::TopDisplayMode::Micro); + self.set_top_display_mode_with_engine( + engine, + crate::config::TopDisplayMode::Micro, + ); } } } diff --git a/src/input/state/tests/status_hud.rs b/src/input/state/tests/status_hud.rs index 89c48a3f9..c031897b2 100644 --- a/src/input/state/tests/status_hud.rs +++ b/src/input/state/tests/status_hud.rs @@ -589,3 +589,5 @@ fn tablet_path_help_chip_dispatches_action_on_release() { "help overlay should toggle on release" ); } + +mod engine_mutations; diff --git a/src/input/state/tests/status_hud/engine_mutations.rs b/src/input/state/tests/status_hud/engine_mutations.rs new file mode 100644 index 000000000..81f7bcb2d --- /dev/null +++ b/src/input/state/tests/status_hud/engine_mutations.rs @@ -0,0 +1,165 @@ +use super::*; +use crate::ui_text::UiTextEngine; + +fn seeded(engine: &UiTextEngine) -> InputState { + let mut input = create_test_input_state(); + input.compositor_capabilities.layer_shell = true; + input.presenter_mode_config_mut_for_test().hide_toolbars = true; + input.presenter_mode_config_mut_for_test().toolbar_mode = + crate::config::PresenterToolbarMode::Micro; + input.update_status_hud_layout_for_pointer_with_engine( + engine, + StatusPosition::BottomLeft, + &StatusBarStyle::default(), + 1280, + 720, + true, + ); + input +} + +#[test] +fn explicit_mutations_rebuild_immediately_and_preserve_no_op_results() { + let engine = UiTextEngine::default(); + let mut input = seeded(&engine); + let (x, y) = segment_center(&input, StatusHudSegmentKind::Help); + input.on_mouse_motion(x, y); + assert_eq!(input.status_hud.hover, Some(StatusHudSegmentKind::Help)); + let before = format!("{:?}", input.status_hud_layout()); + input.needs_redraw = false; + assert!(input.set_status_bar_item_visible_with_engine(&engine, StatusBarItem::Help, false)); + assert_eq!(input.status_hud.hover, None); + assert!( + !input + .status_hud_layout() + .unwrap() + .segments + .iter() + .any(|s| s.kind == StatusHudSegmentKind::Help) + ); + assert_ne!(format!("{:?}", input.status_hud_layout()), before); + assert!(input.needs_redraw); + input.needs_redraw = false; + assert!(!input.set_status_bar_item_visible_with_engine(&engine, StatusBarItem::Help, false)); + assert!(!input.needs_redraw); + + assert!(input.set_toolbar_visible_with_engine(&engine, false)); + assert!( + input + .status_hud_layout() + .unwrap() + .segments + .iter() + .any(|s| s.kind == StatusHudSegmentKind::Toolbar) + ); + input.needs_redraw = false; + assert!(!input.set_toolbar_visible_with_engine(&engine, false)); + assert!(!input.needs_redraw); + + let mut unpainted = create_test_input_state(); + assert!(unpainted.set_status_bar_item_visible_with_engine(&engine, StatusBarItem::Help, false)); + assert!( + unpainted.status_hud_layout().is_none(), + "mutations do not invent frame dimensions" + ); +} + +fn assert_same_chrome(actual: &InputState, expected: &InputState) { + assert_eq!(actual.toolbar_visible(), expected.toolbar_visible()); + assert_eq!(actual.top_display_state(), expected.top_display_state()); + assert_eq!( + actual.ui_visibility.show_status_bar, + expected.ui_visibility.show_status_bar + ); + assert_eq!(actual.focus_mode_active(), expected.focus_mode_active()); + assert_eq!( + actual.presenter_mode_active(), + expected.presenter_mode_active() + ); + assert_eq!(actual.light_mode_active(), expected.light_mode_active()); + assert_eq!( + actual.light_mode_drawing_active(), + expected.light_mode_drawing_active() + ); + assert_eq!(actual.tour.is_active(), expected.tour.is_active()); + assert_eq!(actual.status_hud.hover, expected.status_hud.hover); + assert_eq!( + format!("{:?}", actual.status_hud_layout()), + format!("{:?}", expected.status_hud_layout()) + ); +} + +#[test] +fn explicit_mode_cycles_match_legacy_without_an_intervening_frame() { + let engine = UiTextEngine::default(); + let mut explicit = seeded(&engine); + let mut legacy = seeded(&UiTextEngine::default()); + // Enter/leave Focus, then make Light's drawing-entry path leave Focus. + explicit.toggle_focus_mode_with_engine(&engine); + legacy.toggle_focus_mode(); + assert_same_chrome(&explicit, &legacy); + assert!(explicit.focus_mode_active()); + assert!(explicit.set_light_mode_drawing_with_engine(&engine, true)); + assert!(legacy.set_light_mode_drawing(true)); + assert_same_chrome(&explicit, &legacy); + assert!(explicit.light_mode_active()); + assert!(!explicit.focus_mode_active()); + assert_eq!( + explicit.toggle_light_mode_drawing_with_engine(&engine), + legacy.toggle_light_mode_drawing() + ); + assert_same_chrome(&explicit, &legacy); + assert_eq!( + explicit.toggle_light_mode_with_engine(&engine), + legacy.toggle_light_mode() + ); + assert_same_chrome(&explicit, &legacy); + assert!(!explicit.light_mode_active()); + // Presenter and Light replace each other's numeric visibility snapshots. + assert_eq!( + explicit.toggle_presenter_mode_with_engine(&engine), + legacy.toggle_presenter_mode() + ); + assert_same_chrome(&explicit, &legacy); + assert!(explicit.presenter_mode_active()); + assert_eq!( + explicit.toggle_light_mode_with_engine(&engine), + legacy.toggle_light_mode() + ); + assert_same_chrome(&explicit, &legacy); + assert!(!explicit.presenter_mode_active()); + explicit.toggle_focus_mode_with_engine(&engine); + legacy.toggle_focus_mode(); + assert_same_chrome(&explicit, &legacy); + explicit.start_tour_replay_with_engine(&engine); + legacy.start_tour_replay(); + assert_same_chrome(&explicit, &legacy); + assert!(explicit.tour.is_active()); + assert!(!explicit.focus_mode_active()); +} + +#[test] +fn explicit_focus_rescue_and_display_cycle_refresh_saved_geometry() { + let engine = UiTextEngine::default(); + let mut input = seeded(&engine); + input.set_toolbar_visible_with_engine(&engine, false); + input.ui_visibility.show_status_bar = false; + input.ui_visibility.show_floating_badge = false; + input.ui_visibility.show_zoom_chip = false; + input.refresh_status_hud_layout_with_engine(&engine); + assert!(!input.focus_mode_active()); + input.toggle_focus_mode_with_engine(&engine); + assert!(input.toolbar_visible(), "Focus rescues fully hidden chrome"); + assert!(input.ui_visibility.show_status_bar); + assert!(!input.focus_mode_active()); + // Rescue restores status visibility after its toolbar refresh. Do not add a new refresh. + assert!(input.status_hud_layout().is_none()); + input.set_top_display_mode_with_engine(&engine, crate::config::TopDisplayMode::Full); + assert!(input.status_hud_layout().is_some()); + assert_eq!( + input.cycle_top_toolbar_display_with_engine(&engine), + crate::config::TopDisplayMode::Micro + ); + input.derive_toolbar_visibility_from_pins_with_engine(&engine); + assert!(input.status_hud_layout().is_some()); +} From caeacbd2492a8e6b562f0545f7ecc55e784d56a3 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:59:04 +0200 Subject: [PATCH 09/42] refactor(draw): reuse text measurement for hit testing and PDF bounds --- src/backend/wayland/state/pdf_export.rs | 11 +- src/backend/wayland/state/pdf_export/tests.rs | 101 +++- src/backend/wayland/state/render/runtime.rs | 6 + src/draw/shape/mod.rs | 8 +- src/input/hit_test/mod.rs | 59 +- src/input/state/core/index.rs | 517 ++---------------- src/input/state/core/index/grid.rs | 8 +- src/input/state/core/index/grid/tests.rs | 51 +- .../state/core/index/measurement_tests.rs | 187 +++++++ src/input/state/core/index/owner.rs | 480 ++++++++++++++++ 10 files changed, 916 insertions(+), 512 deletions(-) create mode 100644 src/input/state/core/index/measurement_tests.rs create mode 100644 src/input/state/core/index/owner.rs diff --git a/src/backend/wayland/state/pdf_export.rs b/src/backend/wayland/state/pdf_export.rs index 2cb87c442..91ae7d067 100644 --- a/src/backend/wayland/state/pdf_export.rs +++ b/src/backend/wayland/state/pdf_export.rs @@ -4,7 +4,7 @@ use crate::canvas_export::{ resolve_pdf_page_layout, }; use crate::config::{Action, PdfFitMode}; -use crate::draw::Frame; +use crate::draw::{Frame, TextMeasurer}; use crate::input::BoardBackground; use crate::input::boards::BoardState; @@ -33,6 +33,7 @@ impl WaylandState { ) -> Result { let scope = pdf_export_scope_for_action(action); build_board_pdf_export_snapshot(BoardPdfExportBuildContext { + measurer: self.render.text_measurer(), logical_width: self.surface.width(), logical_height: self.surface.height(), boards: self.input_state.boards.board_states(), @@ -68,6 +69,7 @@ enum PdfExportScope { } struct BoardPdfExportBuildContext<'a> { + measurer: &'a TextMeasurer, logical_width: u32, logical_height: u32, boards: &'a [BoardState], @@ -84,6 +86,7 @@ fn build_board_pdf_export_snapshot( context: BoardPdfExportBuildContext<'_>, ) -> Result { let BoardPdfExportBuildContext { + measurer, logical_width, logical_height, boards, @@ -120,7 +123,7 @@ fn build_board_pdf_export_snapshot( (0, 0) }; let content_bounds = if config.fit == PdfFitMode::FitContentToPage { - frame_content_bounds(frame) + frame_content_bounds(frame, measurer) } else { None }; @@ -247,7 +250,7 @@ fn backdrop_from_background( } } -fn frame_content_bounds(frame: &Frame) -> Option { +fn frame_content_bounds(frame: &Frame, measurer: &TextMeasurer) -> Option { let mut min_x = i32::MAX; let mut min_y = i32::MAX; let mut max_x = i32::MIN; @@ -255,7 +258,7 @@ fn frame_content_bounds(frame: &Frame) -> Option { let mut found = false; for drawn in &frame.shapes { - let Some(bounds) = drawn.bounding_box() else { + let Some(bounds) = drawn.bounding_box_with(measurer) else { continue; }; min_x = min_x.min(bounds.x); diff --git a/src/backend/wayland/state/pdf_export/tests.rs b/src/backend/wayland/state/pdf_export/tests.rs index 7435b0f36..44f83c3a6 100644 --- a/src/backend/wayland/state/pdf_export/tests.rs +++ b/src/backend/wayland/state/pdf_export/tests.rs @@ -20,10 +20,12 @@ fn board(id: &str, name: &str, background: BoardBackground, pages: Vec) - } fn snapshot_context<'a>( + measurer: &'a TextMeasurer, boards: &'a [BoardState], config: &'a PdfExportConfig, ) -> BoardPdfExportBuildContext<'a> { BoardPdfExportBuildContext { + measurer, logical_width: 800, logical_height: 600, boards, @@ -51,8 +53,12 @@ fn active_board_pdf_snapshot_preserves_page_order_and_metadata() { )]; let config = PdfExportConfig::default(); - let snapshot = - build_board_pdf_export_snapshot(snapshot_context(&boards, &config)).expect("snapshot"); + let snapshot = build_board_pdf_export_snapshot(snapshot_context( + &TextMeasurer::default(), + &boards, + &config, + )) + .expect("snapshot"); assert_eq!(snapshot.pages.len(), 2); assert_eq!(snapshot.pages[0].page.frame.page_name(), Some("first")); @@ -77,7 +83,7 @@ fn all_boards_pdf_snapshot_uses_app_board_order() { let snapshot = build_board_pdf_export_snapshot(BoardPdfExportBuildContext { active_board_index: 1, scope: PdfExportScope::AllBoards, - ..snapshot_context(&boards, &config) + ..snapshot_context(&TextMeasurer::default(), &boards, &config) }) .expect("snapshot"); @@ -105,8 +111,12 @@ fn pdf_snapshot_uses_per_page_view_offset_for_solid_pannable_boards() { )]; let config = PdfExportConfig::default(); - let snapshot = - build_board_pdf_export_snapshot(snapshot_context(&boards, &config)).expect("snapshot"); + let snapshot = build_board_pdf_export_snapshot(snapshot_context( + &TextMeasurer::default(), + &boards, + &config, + )) + .expect("snapshot"); assert_eq!(snapshot.pages[0].page.origin_x, 100); assert_eq!(snapshot.pages[0].page.origin_y, -50); @@ -126,8 +136,12 @@ fn pdf_snapshot_forces_origin_for_transparent_boards() { )]; let config = PdfExportConfig::default(); - let snapshot = - build_board_pdf_export_snapshot(snapshot_context(&boards, &config)).expect("snapshot"); + let snapshot = build_board_pdf_export_snapshot(snapshot_context( + &TextMeasurer::default(), + &boards, + &config, + )) + .expect("snapshot"); assert_eq!(snapshot.pages[0].page.origin_x, 0); assert_eq!(snapshot.pages[0].page.origin_y, 0); @@ -156,8 +170,12 @@ fn fit_content_snapshot_uses_content_bounds() { ..PdfExportConfig::default() }; - let snapshot = - build_board_pdf_export_snapshot(snapshot_context(&boards, &config)).expect("snapshot"); + let snapshot = build_board_pdf_export_snapshot(snapshot_context( + &TextMeasurer::default(), + &boards, + &config, + )) + .expect("snapshot"); assert!(snapshot.pages[0].layout.source_rect.x <= 20.0); assert!(snapshot.pages[0].layout.source_rect.y <= 30.0); @@ -184,7 +202,7 @@ fn transparent_pdf_pages_use_desktop_backdrop_when_supplied() { let config = PdfExportConfig::default(); let snapshot = build_board_pdf_export_snapshot(BoardPdfExportBuildContext { desktop_backdrop: Some(backdrop), - ..snapshot_context(&boards, &config) + ..snapshot_context(&TextMeasurer::default(), &boards, &config) }) .expect("snapshot"); @@ -214,7 +232,7 @@ fn solid_pdf_pages_keep_solid_backdrop_when_desktop_backdrop_supplied() { let config = PdfExportConfig::default(); let snapshot = build_board_pdf_export_snapshot(BoardPdfExportBuildContext { desktop_backdrop: Some(backdrop), - ..snapshot_context(&boards, &config) + ..snapshot_context(&TextMeasurer::default(), &boards, &config) }) .expect("snapshot"); @@ -257,3 +275,64 @@ fn pdf_export_scope_detects_transparent_boards() { PdfExportScope::AllBoards )); } + +#[test] +fn text_content_preflight_preserves_bounds_and_resource_free_snapshots() { + use crate::draw::FontDescriptor; + + let measurer = TextMeasurer::default(); + let mut first = Frame::new(); + first.add_shape(Shape::Text { + x: 100, + y: 120, + text: "Wrapped العربية text for export".into(), + color: RED, + size: 24.0, + font_descriptor: FontDescriptor::default(), + background_enabled: true, + wrap_width: Some(100), + }); + let mut second = Frame::new(); + second.add_shape(Shape::StickyNote { + x: 300, + y: 200, + text: "A second page with a note".into(), + background: WHITE, + size: 24.0, + font_descriptor: FontDescriptor::default(), + wrap_width: Some(100), + }); + let boards = vec![board( + "white", + "Whiteboard", + BoardBackground::Solid(WHITE), + vec![first, second], + )]; + let config = PdfExportConfig { + fit: PdfFitMode::FitContentToPage, + content_source_padding: 0.0, + ..PdfExportConfig::default() + }; + // Preflight runs without creating any destination surface or painting. + let initial = + build_board_pdf_export_snapshot(snapshot_context(&measurer, &boards, &config)).unwrap(); + let repeated = + build_board_pdf_export_snapshot(snapshot_context(&measurer, &boards, &config)).unwrap(); + let independent = TextMeasurer::default(); + for (index, frame) in boards[0].pages.pages().iter().enumerate() { + let bounds = frame.shapes[0].bounding_box_with(&independent).unwrap(); + let expected = CanvasExportRect::new( + bounds.x as f64, + bounds.y as f64, + bounds.width as f64, + bounds.height as f64, + ) + .unwrap(); + assert_eq!(initial.pages[index].layout.source_rect, expected); + assert_eq!(repeated.pages[index].layout.source_rect, expected); + assert!(expected.width > 0.0 && expected.height > 0.0); + } + fn assert_send(_: &T) {} + assert_send(&initial); + assert_send(&repeated); +} diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index 09743128e..2c3eab9f7 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -123,6 +123,7 @@ pub(in crate::backend::wayland) struct RenderRuntime { theme: crate::ui::theme::Theme, ui_caches: crate::ui::UiRenderCaches, ui_text: crate::ui_text::UiTextEngine, + text_measurer: crate::draw::TextMeasurer, ui_damage: UiDamageHistory, profile_ui_baseline: Vec, } @@ -135,11 +136,16 @@ impl RenderRuntime { theme, ui_caches: crate::ui::UiRenderCaches::default(), ui_text: crate::ui_text::UiTextEngine::default(), + text_measurer: crate::draw::TextMeasurer::default(), ui_damage: UiDamageHistory::default(), profile_ui_baseline: Vec::new(), } } + pub(in crate::backend::wayland) fn text_measurer(&self) -> &crate::draw::TextMeasurer { + &self.text_measurer + } + pub(in crate::backend::wayland) fn ui_text(&self) -> &crate::ui_text::UiTextEngine { &self.ui_text } diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs index fe4740184..6b93227a5 100644 --- a/src/draw/shape/mod.rs +++ b/src/draw/shape/mod.rs @@ -22,10 +22,14 @@ pub use types::{ StepMarkerLabel, }; -pub(crate) use arrow_label::{ARROW_LABEL_BACKGROUND, arrow_label_ends, arrow_label_layout}; +pub(crate) use arrow_label::{ + ARROW_LABEL_BACKGROUND, arrow_label_ends, arrow_label_layout, arrow_label_layout_with, +}; pub(crate) use bounds::{bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points}; pub(crate) use polygon::{PolygonTemplate, generated_points, has_minimum_distinct_points}; -pub(crate) use step_marker::{step_marker_outline_thickness, step_marker_radius}; +pub(crate) use step_marker::{ + step_marker_outline_thickness, step_marker_radius, step_marker_radius_with, +}; pub(crate) use text::{ bounding_box_for_sticky_note_preview, bounding_box_for_text, sticky_note_layout, sticky_note_layout_text, sticky_note_text_layout, diff --git a/src/input/hit_test/mod.rs b/src/input/hit_test/mod.rs index 2435315d9..a2d94f964 100644 --- a/src/input/hit_test/mod.rs +++ b/src/input/hit_test/mod.rs @@ -7,9 +7,10 @@ mod shapes; mod tests; use crate::draw::shape::{ - arrow_label_ends, arrow_label_layout, step_marker_outline_thickness, step_marker_radius, + arrow_label_ends, arrow_label_layout_with, step_marker_outline_thickness, + step_marker_radius_with, }; -use crate::draw::{DrawnShape, Shape}; +use crate::draw::{DrawnShape, Shape, TextMeasurer, with_legacy_measurer}; use crate::util::Rect; const MAX_HIT_TEST_TOLERANCE: f64 = i32::MAX as f64; @@ -43,14 +44,24 @@ impl HitTestTolerance { pub(crate) use shapes::ellipse_fill_hit; pub fn compute_hit_bounds(shape: &DrawnShape, tolerance: f64) -> Option { - compute_hit_bounds_with_tolerance(shape, HitTestTolerance::new(tolerance)?) + with_legacy_measurer(|measurer| compute_hit_bounds_with(measurer, shape, tolerance)) +} + +/// Computes tolerance-inflated bounds with the supplied text measurement owner. +pub fn compute_hit_bounds_with( + measurer: &TextMeasurer, + shape: &DrawnShape, + tolerance: f64, +) -> Option { + compute_hit_bounds_with_tolerance(measurer, shape, HitTestTolerance::new(tolerance)?) } pub(crate) fn compute_hit_bounds_with_tolerance( + measurer: &TextMeasurer, shape: &DrawnShape, tolerance: HitTestTolerance, ) -> Option { - let base = shape.bounding_box()?; + let base = shape.bounding_box_with(measurer)?; if matches!(shape.shape, Shape::EraserStroke { .. }) { return None; } @@ -63,13 +74,24 @@ pub(crate) fn compute_hit_bounds_with_tolerance( /// Returns `true` if the point intersects the provided shape within tolerance. pub fn hit_test(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool { + with_legacy_measurer(|measurer| hit_test_with(measurer, shape, point, tolerance)) +} + +/// Tests stroke geometry with the supplied text measurement owner. +pub fn hit_test_with( + measurer: &TextMeasurer, + shape: &DrawnShape, + point: (i32, i32), + tolerance: f64, +) -> bool { let Some(tolerance) = HitTestTolerance::new(tolerance) else { return false; }; - hit_test_with_tolerance(shape, point, tolerance) + hit_test_with_tolerance(measurer, shape, point, tolerance) } pub(crate) fn hit_test_with_tolerance( + measurer: &TextMeasurer, shape: &DrawnShape, point: (i32, i32), tolerance: HitTestTolerance, @@ -160,7 +182,8 @@ pub(crate) fn hit_test_with_tolerance( }; if !hit && let Some(label) = label { let label_text = label.value.to_string(); - if let Some(layout) = arrow_label_layout( + if let Some(layout) = arrow_label_layout_with( + measurer, label_tip_x, label_tip_y, label_tail_x, @@ -180,7 +203,7 @@ pub(crate) fn hit_test_with_tolerance( } Shape::BlurRect { .. } => { let inflate = tolerance.ceil() as i32; - if let Some(bounds) = shape.bounding_box() { + if let Some(bounds) = shape.bounding_box_with(measurer) { bounds .inflated(inflate) .unwrap_or(bounds) @@ -190,7 +213,7 @@ pub(crate) fn hit_test_with_tolerance( } } Shape::Text { .. } | Shape::StickyNote { .. } | Shape::Image { .. } => { - if let Some(bounds) = shape.bounding_box() { + if let Some(bounds) = shape.bounding_box_with(measurer) { let inflate = tolerance.ceil() as i32; bounds .inflated(inflate) @@ -205,7 +228,8 @@ pub(crate) fn hit_test_with_tolerance( shapes::freehand_hit(points, point, effective_thick, tolerance) } Shape::StepMarker { x, y, label, .. } => { - let radius = step_marker_radius(label.value, label.size, &label.font_descriptor); + let radius = + step_marker_radius_with(measurer, label.value, label.size, &label.font_descriptor); let outline = step_marker_outline_thickness(label.size); shapes::circle_hit(*x, *y, radius + outline / 2.0, point, tolerance) } @@ -218,18 +242,31 @@ pub(crate) fn hit_test_with_tolerance( /// Stroke erasing intentionally keeps using `hit_test`, while direct point /// targeting includes filled interiors for closed fill-capable shapes. pub fn hit_test_for_point_targeting(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool { + with_legacy_measurer(|measurer| { + hit_test_for_point_targeting_with(measurer, shape, point, tolerance) + }) +} + +/// Tests selection targets, including filled interiors, with explicit measurements. +pub fn hit_test_for_point_targeting_with( + measurer: &TextMeasurer, + shape: &DrawnShape, + point: (i32, i32), + tolerance: f64, +) -> bool { let Some(tolerance) = HitTestTolerance::new(tolerance) else { return false; }; - hit_test_for_point_targeting_with_tolerance(shape, point, tolerance) + hit_test_for_point_targeting_with_tolerance(measurer, shape, point, tolerance) } pub(crate) fn hit_test_for_point_targeting_with_tolerance( + measurer: &TextMeasurer, shape: &DrawnShape, point: (i32, i32), tolerance: HitTestTolerance, ) -> bool { - if hit_test_with_tolerance(shape, point, tolerance) { + if hit_test_with_tolerance(measurer, shape, point, tolerance) { return true; } diff --git a/src/input/state/core/index.rs b/src/input/state/core/index.rs index 3d06727b6..2d7e676a2 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -1,380 +1,14 @@ -//! Canvas hit-testing caches, spatial indexing policy, and frame shape limits. +//! Active-frame coordination for canvas hit testing and spatial indexing. mod grid; +mod owner; use super::base::InputState; -use crate::draw::{Frame, ShapeId}; -use crate::input::boards::BoardIdentityGeneration; -use crate::input::hit_test::{self, HitTestTolerance}; -use std::collections::{HashMap, HashSet}; - -pub(super) use self::grid::SpatialGrid; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ActiveFrameOrderGuard { - board_identity_generation: BoardIdentityGeneration, - board_index: usize, - page_generation: u64, - page_index: usize, - shape_count: usize, - shape_order_generation: u64, -} - -impl ActiveFrameOrderGuard { - fn same_frame(self, other: Self) -> bool { - self.board_identity_generation == other.board_identity_generation - && self.board_index == other.board_index - && self.page_generation == other.page_generation - && self.page_index == other.page_index - } -} - -#[derive(Debug, Clone)] -pub(super) struct SpatialIndexCache { - grid: SpatialGrid, - shape_indices: Option>, - guard: ActiveFrameOrderGuard, -} - +use crate::draw::{ShapeId, TextMeasurer, with_legacy_measurer}; +use owner::ActiveFrameOrderGuard; +pub(in crate::input::state) use owner::CanvasIndex; #[cfg(test)] -std::thread_local! { - static SPATIAL_SHAPE_INDEX_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -#[derive(Debug, Clone)] -pub(in crate::input::state) struct CanvasIndex { - hit_test_cache: HashMap, - content_generation: u64, - tolerance: f64, - linear_threshold: usize, - spatial_index: Option, - max_shapes_per_frame: usize, -} - -impl Default for CanvasIndex { - fn default() -> Self { - Self { - hit_test_cache: HashMap::new(), - content_generation: 0, - tolerance: 6.0, - linear_threshold: 400, - spatial_index: None, - max_shapes_per_frame: 0, - } - } -} - -impl CanvasIndex { - pub(in crate::input::state) fn from_config( - hit_test_tolerance: f64, - max_shapes_per_frame: usize, - ) -> Self { - let mut index = Self { - max_shapes_per_frame, - ..Self::default() - }; - index.tolerance = Self::normalized_tolerance(hit_test_tolerance); - index - } - - fn generation(&self) -> u64 { - self.content_generation - } - - fn invalidate(&mut self) { - self.content_generation = self.content_generation.wrapping_add(1); - self.hit_test_cache.clear(); - self.spatial_index = None; - } - - pub(in crate::input::state) fn restore_from_rollback(&mut self, mut snapshot: Self) { - snapshot.content_generation = snapshot.content_generation.max(self.content_generation); - *self = snapshot; - } - - fn invalidate_shape( - &mut self, - id: ShapeId, - new_bounds: Option>, - guard: ActiveFrameOrderGuard, - ) { - self.content_generation = self.content_generation.wrapping_add(1); - self.hit_test_cache.remove(&id); - - if self - .spatial_index - .as_ref() - .is_some_and(|index| !index.guard.same_frame(guard)) - { - self.spatial_index = None; - return; - } - - // `Frame::shapes` is public compatibility storage. If a caller - // replaced or inserted an id directly, we cannot know which old grid - // entry to remove, so rebuild the complete spatial cache. - if self - .spatial_index - .as_ref() - .and_then(|index| index.shape_indices.as_ref()) - .is_some_and(|shape_indices| !shape_indices.contains_key(&id)) - { - self.spatial_index = None; - return; - } - - if let Some(index) = &mut self.spatial_index { - index.grid.remove_shape(id); - if let Some(bounds) = new_bounds { - index.grid.add_shape(id, bounds); - } - - if index.guard.shape_count != guard.shape_count - || index.guard.shape_order_generation != guard.shape_order_generation - { - index.shape_indices = None; - } - index.guard = guard; - } - } - - fn ensure_for_frame(&mut self, frame: &Frame, guard: ActiveFrameOrderGuard) { - let len = frame.shapes.len(); - if len <= self.linear_threshold { - self.spatial_index = None; - return; - } - - let needs_rebuild = match &self.spatial_index { - None => true, - Some(index) => { - let drift = (index.grid.shape_count() as i64 - len as i64).unsigned_abs() as usize; - index.guard != guard || drift > len / 5 + 1 - } - }; - - if needs_rebuild { - self.spatial_index = SpatialGrid::build(frame).map(|grid| SpatialIndexCache { - grid, - shape_indices: None, - guard, - }); - } - - if self - .spatial_index - .as_ref() - .is_some_and(|index| index.shape_indices.is_none()) - { - let shape_indices = Self::build_spatial_shape_indices(frame); - if let Some(index) = &mut self.spatial_index { - index.shape_indices = Some(shape_indices); - } - } - } - - fn hit_test_all_for_points( - &self, - frame: &Frame, - guard: ActiveFrameOrderGuard, - points: &[(i32, i32)], - tolerance: f64, - ) -> Vec { - let Some(tolerance) = HitTestTolerance::new(tolerance) else { - return Vec::new(); - }; - if points.is_empty() || frame.shapes.is_empty() { - return Vec::new(); - } - - let len = frame.shapes.len(); - let candidate_indices: Vec = if let Some((index, shape_indices)) = self - .spatial_index - .as_ref() - .filter(|index| index.guard == guard) - .and_then(|index| index.shape_indices.as_ref().map(|indices| (index, indices))) - { - let mut unique = HashSet::new(); - for &(x, y) in points { - for id in index.grid.query_with_tolerance((x, y), tolerance) { - unique.insert(id); - } - } - let mut indices = Vec::with_capacity(unique.len()); - let stale = unique.into_iter().any(|id| { - let Some(shape_index) = shape_indices.get(&id).copied() else { - return true; - }; - if frame - .shapes - .get(shape_index) - .is_none_or(|shape| shape.id != id) - { - return true; - } - indices.push(shape_index); - false - }); - if stale { (0..len).collect() } else { indices } - } else { - (0..len).collect() - }; - - let mut hits = Vec::new(); - for index in candidate_indices { - let Some(drawn) = frame.shapes.get(index) else { - continue; - }; - let bounds = hit_test::compute_hit_bounds_with_tolerance(drawn, tolerance); - let hit = points.iter().any(|&(x, y)| { - bounds.as_ref().is_none_or(|rect| rect.contains(x, y)) - && hit_test::hit_test_with_tolerance(drawn, (x, y), tolerance) - }); - if hit { - hits.push(drawn.id); - } - } - hits - } - - fn hit_test_at( - &mut self, - frame: &Frame, - guard: ActiveFrameOrderGuard, - x: i32, - y: i32, - ) -> Option { - let tolerance = - HitTestTolerance::new(self.tolerance).unwrap_or(HitTestTolerance::ONE_PIXEL); - let len = frame.shapes.len(); - - if len > self.linear_threshold { - self.ensure_for_frame(frame, guard); - if let Some(index) = &self.spatial_index { - let candidates = index.grid.query_with_tolerance((x, y), tolerance); - let index_map = index - .shape_indices - .as_ref() - .expect("spatial shape indices are built with the grid"); - let mut stale = false; - let mut sorted_candidates: Vec<_> = candidates - .into_iter() - .filter_map(|id| { - let Some(shape_index) = index_map.get(&id).copied() else { - stale = true; - return None; - }; - if frame - .shapes - .get(shape_index) - .is_none_or(|shape| shape.id != id) - { - stale = true; - return None; - } - Some(shape_index) - }) - .collect(); - sorted_candidates.sort_unstable_by_key(|&index| std::cmp::Reverse(index)); - - if !stale - && let Some(id) = - self.hit_test_indices(frame, sorted_candidates, x, y, tolerance) - { - return Some(id); - } - } - } else { - self.spatial_index = None; - } - - self.hit_test_indices(frame, (0..len).rev(), x, y, tolerance) - } - - fn tolerance(&self) -> f64 { - self.tolerance - } - - fn set_tolerance(&mut self, tolerance: f64) { - self.tolerance = Self::normalized_tolerance(tolerance); - self.invalidate(); - } - - fn set_linear_threshold(&mut self, threshold: usize) { - self.linear_threshold = threshold.max(1); - } - - fn max_shapes_per_frame(&self) -> usize { - self.max_shapes_per_frame - } - - #[cfg(test)] - fn set_max_shapes_per_frame(&mut self, limit: usize) { - self.max_shapes_per_frame = limit; - } - - #[cfg(test)] - fn has_spatial_index(&self) -> bool { - self.spatial_index.is_some() - } - - fn normalized_tolerance(tolerance: f64) -> f64 { - HitTestTolerance::new(tolerance) - .unwrap_or(HitTestTolerance::ONE_PIXEL) - .at_least(HitTestTolerance::ONE_PIXEL) - .value() - } - - fn build_spatial_shape_indices(frame: &Frame) -> HashMap { - #[cfg(test)] - SPATIAL_SHAPE_INDEX_BUILDS.with(|count| count.set(count.get().saturating_add(1))); - frame - .shapes - .iter() - .enumerate() - .map(|(index, shape)| (shape.id, index)) - .collect() - } - - fn hit_test_single( - &mut self, - frame: &Frame, - index: usize, - x: i32, - y: i32, - tolerance: HitTestTolerance, - ) -> Option { - let drawn = frame.shapes.get(index)?; - let cached = self.hit_test_cache.get(&drawn.id).copied(); - let bounds = - cached.or_else(|| hit_test::compute_hit_bounds_with_tolerance(drawn, tolerance)); - let hit = bounds.as_ref().is_none_or(|rect| rect.contains(x, y)) - && hit_test::hit_test_for_point_targeting_with_tolerance(drawn, (x, y), tolerance); - if let Some(bounds) = bounds { - self.hit_test_cache.entry(drawn.id).or_insert(bounds); - } - hit.then_some(drawn.id) - } - - fn hit_test_indices( - &mut self, - frame: &Frame, - indices: I, - x: i32, - y: i32, - tolerance: HitTestTolerance, - ) -> Option - where - I: IntoIterator, - { - for index in indices { - if let Some(shape_id) = self.hit_test_single(frame, index, x, y, tolerance) { - return Some(shape_id); - } - } - None - } -} +use owner::SPATIAL_SHAPE_INDEX_BUILDS; impl InputState { #[cfg(test)] @@ -393,8 +27,19 @@ impl InputState { points: &[(i32, i32)], tolerance: f64, ) -> Vec { - self.ensure_spatial_index_for_active_frame(); - self.hit_test_all_for_points_cached(points, tolerance) + with_legacy_measurer(|measurer| { + self.hit_test_all_for_points_with(measurer, points, tolerance) + }) + } + + pub(crate) fn hit_test_all_for_points_with( + &mut self, + measurer: &TextMeasurer, + points: &[(i32, i32)], + tolerance: f64, + ) -> Vec { + self.ensure_spatial_index_for_active_frame_with(measurer); + self.hit_test_all_for_points_cached_with(measurer, points, tolerance) } /// Returns all shapes intersecting any of the provided points using cached spatial data. @@ -402,9 +47,21 @@ impl InputState { &self, points: &[(i32, i32)], tolerance: f64, + ) -> Vec { + with_legacy_measurer(|measurer| { + self.hit_test_all_for_points_cached_with(measurer, points, tolerance) + }) + } + + pub(crate) fn hit_test_all_for_points_cached_with( + &self, + measurer: &TextMeasurer, + points: &[(i32, i32)], + tolerance: f64, ) -> Vec { let guard = self.active_frame_order_guard(); self.canvas_index.hit_test_all_for_points( + measurer, self.boards.active_frame(), guard, points, @@ -427,11 +84,16 @@ impl InputState { /// Instead of invalidating the entire spatial index, this method updates /// only the affected cells, providing O(1) amortized updates instead of O(n). pub fn invalidate_hit_cache_for(&mut self, id: ShapeId) { + with_legacy_measurer(|measurer| self.invalidate_hit_cache_for_with(measurer, id)) + } + + /// Refreshes one shape in the index using the supplied text measurements. + pub fn invalidate_hit_cache_for_with(&mut self, measurer: &TextMeasurer, id: ShapeId) { let new_bounds = self .boards .active_frame() .shape(id) - .map(|drawn| drawn.bounding_box()); + .map(|drawn| drawn.bounding_box_with(measurer)); let guard = self.active_frame_order_guard(); self.canvas_index.invalidate_shape(id, new_bounds, guard); } @@ -468,9 +130,13 @@ impl InputState { } pub(crate) fn ensure_spatial_index_for_active_frame(&mut self) { + with_legacy_measurer(|measurer| self.ensure_spatial_index_for_active_frame_with(measurer)) + } + + pub(crate) fn ensure_spatial_index_for_active_frame_with(&mut self, measurer: &TextMeasurer) { let guard = self.active_frame_order_guard(); self.canvas_index - .ensure_for_frame(self.boards.active_frame(), guard); + .ensure_for_frame(measurer, self.boards.active_frame(), guard); } fn active_frame_order_guard(&self) -> ActiveFrameOrderGuard { @@ -486,100 +152,15 @@ impl InputState { /// Performs hit-testing against the active frame and returns the top-most shape id. pub fn hit_test_at(&mut self, x: i32, y: i32) -> Option { + with_legacy_measurer(|measurer| self.hit_test_at_with(measurer, x, y)) + } + + /// Finds the topmost shape using the supplied canonical text measurements. + pub fn hit_test_at_with(&mut self, measurer: &TextMeasurer, x: i32, y: i32) -> Option { let guard = self.active_frame_order_guard(); self.canvas_index - .hit_test_at(self.boards.active_frame(), guard, x, y) + .hit_test_at(measurer, self.boards.active_frame(), guard, x, y) } } - #[cfg(test)] -mod canvas_index_owner_tests { - use super::*; - use crate::draw::{Color, Shape}; - use crate::util::Rect; - - fn frame_with_rectangles(count: usize) -> Frame { - let mut frame = Frame::new(); - for offset in 0..count as i32 { - frame.add_shape(Shape::Rect { - x: offset * 20, - y: 0, - w: 10, - h: 10, - color: Color::new(0.0, 0.0, 0.0, 1.0), - thick: 2.0, - fill: false, - }); - } - frame - } - - fn guard(identity: u64, frame: &Frame) -> ActiveFrameOrderGuard { - ActiveFrameOrderGuard { - board_identity_generation: BoardIdentityGeneration(identity), - board_index: 0, - page_generation: 1, - page_index: 0, - shape_count: frame.shapes.len(), - shape_order_generation: frame.shape_order_generation(), - } - } - - #[test] - fn invalidating_a_shape_with_a_stale_frame_guard_drops_the_index() { - let frame = frame_with_rectangles(2); - let mut index = CanvasIndex::default(); - index.set_linear_threshold(1); - index.ensure_for_frame(&frame, guard(1, &frame)); - assert!(index.has_spatial_index()); - - index.invalidate_shape( - frame.shapes[0].id, - Some(Some(Rect::new(0, 0, 10, 10).unwrap())), - guard(2, &frame), - ); - - assert!(!index.has_spatial_index()); - } - - #[test] - fn tolerance_and_linear_threshold_are_floored_at_one() { - let mut index = CanvasIndex::from_config(f64::NAN, 20); - assert_eq!(index.tolerance(), 1.0); - index.set_tolerance(-4.0); - assert_eq!(index.tolerance(), 1.0); - - index.set_linear_threshold(0); - let frame = frame_with_rectangles(1); - index.ensure_for_frame(&frame, guard(1, &frame)); - assert!(!index.has_spatial_index()); - } - - #[test] - fn ensuring_a_frame_below_the_threshold_clears_an_existing_index() { - let frame = frame_with_rectangles(2); - let mut index = CanvasIndex::default(); - index.set_linear_threshold(1); - index.ensure_for_frame(&frame, guard(1, &frame)); - assert!(index.has_spatial_index()); - - index.set_linear_threshold(2); - index.ensure_for_frame(&frame, guard(1, &frame)); - assert!(!index.has_spatial_index()); - } - - #[test] - fn rollback_restore_keeps_the_newer_content_generation() { - let mut snapshot = CanvasIndex::from_config(6.0, 20); - snapshot.invalidate(); - let mut current = snapshot.clone(); - current.invalidate(); - current.set_linear_threshold(1); - let newer_generation = current.generation(); - - current.restore_from_rollback(snapshot); - - assert_eq!(current.generation(), newer_generation); - assert_eq!(current.linear_threshold, 400); - } -} +mod measurement_tests; diff --git a/src/input/state/core/index/grid.rs b/src/input/state/core/index/grid.rs index f41fb281b..b0fe4d603 100644 --- a/src/input/state/core/index/grid.rs +++ b/src/input/state/core/index/grid.rs @@ -1,4 +1,4 @@ -use crate::draw::{Frame, ShapeId}; +use crate::draw::{Frame, ShapeId, TextMeasurer}; use crate::input::hit_test::HitTestTolerance; use crate::util::Rect; use std::collections::{HashMap, HashSet}; @@ -58,8 +58,9 @@ pub(in crate::input::state::core) struct SpatialGrid { } impl SpatialGrid { - pub(super) fn build(frame: &Frame) -> Option { + pub(super) fn build(measurer: &TextMeasurer, frame: &Frame) -> Option { Self::build_with_membership_limit( + measurer, frame, SPATIAL_GRID_CELL_SIZE, MAX_SPATIAL_GRID_MEMBERSHIPS, @@ -67,6 +68,7 @@ impl SpatialGrid { } fn build_with_membership_limit( + measurer: &TextMeasurer, frame: &Frame, cell_size: i32, max_indexed_memberships: usize, @@ -87,7 +89,7 @@ impl SpatialGrid { }; for drawn in &frame.shapes { - grid.add_shape(drawn.id, drawn.bounding_box()); + grid.add_shape(drawn.id, drawn.bounding_box_with(measurer)); } if grid.cells.is_empty() && grid.global_shapes.is_empty() { diff --git a/src/input/state/core/index/grid/tests.rs b/src/input/state/core/index/grid/tests.rs index a4342c3db..de798ca71 100644 --- a/src/input/state/core/index/grid/tests.rs +++ b/src/input/state/core/index/grid/tests.rs @@ -40,8 +40,13 @@ fn mixed_candidate_grid() -> (SpatialGrid, [ShapeId; 3]) { let first = frame.add_shape(filled_rect(10, 10, 10, 10)); let second = frame.add_shape(filled_rect(74, 10, 10, 10)); let third = frame.add_shape(filled_rect(138, 10, 10, 10)); - let grid = SpatialGrid::build_with_membership_limit(&frame, SPATIAL_GRID_CELL_SIZE, 2) - .expect("spatial grid"); + let grid = SpatialGrid::build_with_membership_limit( + &TextMeasurer::default(), + &frame, + SPATIAL_GRID_CELL_SIZE, + 2, + ) + .expect("spatial grid"); (grid, [first, second, third]) } @@ -54,7 +59,7 @@ fn oversized_shape_is_queried_without_per_cell_index_entries() { let mut frame = Frame::new(); let shape_id = frame.add_shape(filled_rect(0, 0, 100_000, 100_000)); - let grid = SpatialGrid::build(&frame).expect("spatial grid"); + let grid = SpatialGrid::build(&TextMeasurer::default(), &frame).expect("spatial grid"); assert!(grid.cells.is_empty()); assert!(grid.shape_cells.is_empty()); @@ -85,7 +90,7 @@ fn shape_without_bounds_remains_a_global_candidate() { .is_none() ); - let grid = SpatialGrid::build(&frame).expect("spatial grid"); + let grid = SpatialGrid::build(&TextMeasurer::default(), &frame).expect("spatial grid"); assert!(grid.cells.is_empty()); assert!(grid.shape_cells.is_empty()); @@ -173,7 +178,7 @@ fn cell_coverage_enforces_per_shape_limit_boundary() { fn oversized_shape_can_move_back_into_regular_cells() { let mut frame = Frame::new(); let shape_id = frame.add_shape(filled_rect(0, 0, 100_000, 100_000)); - let mut grid = SpatialGrid::build(&frame).expect("spatial grid"); + let mut grid = SpatialGrid::build(&TextMeasurer::default(), &frame).expect("spatial grid"); grid.remove_shape(shape_id); grid.add_shape_with_bounds(shape_id, Rect::new(128, 128, 32, 32).expect("valid bounds")); @@ -193,8 +198,13 @@ fn aggregate_membership_budget_routes_excess_shapes_to_global_candidates() { let second = frame.add_shape(filled_rect(74, 10, 10, 10)); let third = frame.add_shape(filled_rect(138, 10, 10, 10)); - let grid = SpatialGrid::build_with_membership_limit(&frame, SPATIAL_GRID_CELL_SIZE, 2) - .expect("spatial grid"); + let grid = SpatialGrid::build_with_membership_limit( + &TextMeasurer::default(), + &frame, + SPATIAL_GRID_CELL_SIZE, + 2, + ) + .expect("spatial grid"); assert_eq!(grid.indexed_memberships, 2); assert_membership_accounting(&grid); @@ -212,8 +222,13 @@ fn aggregate_membership_budget_routes_excess_shapes_to_global_candidates() { fn aggregate_rejection_keeps_membership_storage_available_for_later_small_shape() { let mut frame = Frame::new(); let first = frame.add_shape(filled_rect(10, 10, 10, 10)); - let mut grid = SpatialGrid::build_with_membership_limit(&frame, SPATIAL_GRID_CELL_SIZE, 1) - .expect("spatial grid"); + let mut grid = SpatialGrid::build_with_membership_limit( + &TextMeasurer::default(), + &frame, + SPATIAL_GRID_CELL_SIZE, + 1, + ) + .expect("spatial grid"); let rejected = u64::MAX; let later_small = u64::MAX - 1; @@ -243,8 +258,13 @@ fn removing_indexed_shape_releases_aggregate_membership_budget() { .shape(third) .and_then(|shape| shape.bounding_box()) .expect("third shape bounds"); - let mut grid = SpatialGrid::build_with_membership_limit(&frame, SPATIAL_GRID_CELL_SIZE, 2) - .expect("spatial grid"); + let mut grid = SpatialGrid::build_with_membership_limit( + &TextMeasurer::default(), + &frame, + SPATIAL_GRID_CELL_SIZE, + 2, + ) + .expect("spatial grid"); grid.remove_shape(first); grid.remove_shape(third); @@ -261,8 +281,13 @@ fn reindexing_shape_beyond_remaining_budget_clears_old_cells_and_stays_queryable let mut frame = Frame::new(); let first = frame.add_shape(filled_rect(10, 10, 10, 10)); let _second = frame.add_shape(filled_rect(74, 10, 10, 10)); - let mut grid = SpatialGrid::build_with_membership_limit(&frame, SPATIAL_GRID_CELL_SIZE, 2) - .expect("spatial grid"); + let mut grid = SpatialGrid::build_with_membership_limit( + &TextMeasurer::default(), + &frame, + SPATIAL_GRID_CELL_SIZE, + 2, + ) + .expect("spatial grid"); grid.remove_shape(first); grid.add_shape_with_bounds(first, Rect::new(0, 0, 128, 32).expect("valid bounds")); diff --git a/src/input/state/core/index/measurement_tests.rs b/src/input/state/core/index/measurement_tests.rs new file mode 100644 index 000000000..315428d20 --- /dev/null +++ b/src/input/state/core/index/measurement_tests.rs @@ -0,0 +1,187 @@ +use super::*; +use crate::draw::{ArrowLabel, ArrowStyle, FontDescriptor, RED, Shape, StepMarkerLabel}; +use crate::input::hit_test; + +fn scene(threshold: usize) -> (InputState, Vec) { + let mut state = crate::input::state::test_support::make_test_input_state(); + state.set_hit_test_threshold(threshold); + state.set_hit_test_tolerance(1.0); + let shapes = [ + Shape::Text { + x: 100, + y: 120, + text: "Wide text".into(), + size: 24.0, + color: RED, + font_descriptor: FontDescriptor::default(), + background_enabled: true, + wrap_width: Some(150), + }, + Shape::StickyNote { + x: 300, + y: 120, + text: "A note".into(), + size: 24.0, + background: RED, + font_descriptor: FontDescriptor::default(), + wrap_width: Some(100), + }, + Shape::StepMarker { + x: 500, + y: 120, + color: RED, + label: StepMarkerLabel { + value: 8888, + size: 24.0, + font_descriptor: FontDescriptor::default(), + }, + }, + Shape::Arrow { + x1: 100, + y1: 300, + x2: 200, + y2: 300, + color: RED, + thick: 2.0, + arrow_length: 12.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, + label: Some(ArrowLabel { + value: 8888, + size: 24.0, + font_descriptor: FontDescriptor::default(), + }), + }, + ]; + let ids = shapes + .into_iter() + .map(|shape| state.boards.active_frame_mut().add_shape(shape)) + .collect(); + (state, ids) +} + +#[test] +fn cold_decorated_hits_match_linear_grid_and_public_entry_points() { + let probes = [(105, 115), (305, 115), (500, 120), (150, 315)]; + for threshold in [usize::MAX, 1] { + let measurer = TextMeasurer::default(); + let (mut state, ids) = scene(threshold); + for (&id, &point) in ids.iter().zip(&probes) { + assert_eq!( + state.hit_test_at_with(&measurer, point.0, point.1), + Some(id) + ); + assert_eq!( + state.hit_test_all_for_points_with(&measurer, &[point], 1.0), + vec![id] + ); + assert_eq!( + state.hit_test_all_for_points_cached_with(&measurer, &[point], 1.0), + vec![id] + ); + let drawn = state.boards.active_frame().shape(id).unwrap(); + assert!(hit_test::hit_test_with(&measurer, drawn, point, 1.0)); + assert!(hit_test::hit_test_for_point_targeting_with( + &measurer, drawn, point, 1.0 + )); + assert!( + hit_test::compute_hit_bounds_with(&measurer, drawn, 1.0) + .unwrap() + .contains(point.0, point.1) + ); + assert_eq!( + hit_test::compute_hit_bounds_with(&measurer, drawn, 1.0), + hit_test::compute_hit_bounds(drawn, 1.0) + ); + assert_eq!(state.hit_test_at(point.0, point.1), Some(id)); + } + assert_eq!(state.has_spatial_index(), threshold == 1); + for bad in [f64::NAN, f64::INFINITY, -1.0, f64::MAX] { + assert!( + state + .hit_test_all_for_points_with(&measurer, &probes, bad) + .is_empty() + ); + let drawn = state.boards.active_frame().shape(ids[0]).unwrap(); + assert_eq!( + hit_test::compute_hit_bounds_with(&measurer, drawn, bad), + None + ); + assert!(!hit_test::hit_test_with(&measurer, drawn, probes[0], bad)); + assert!(!hit_test::hit_test_for_point_targeting_with( + &measurer, drawn, probes[0], bad + )); + } + } +} + +#[test] +fn explicit_index_refreshes_text_replacement_and_z_order_without_count_change() { + let measurer = TextMeasurer::default(); + let (mut state, ids) = scene(1); + assert_eq!(state.hit_test_at_with(&measurer, 105, 115), Some(ids[0])); + let mut moved = state + .boards + .active_frame() + .shape(ids[0]) + .unwrap() + .shape + .clone(); + if let Shape::Text { + x, + text, + wrap_width, + font_descriptor, + .. + } = &mut moved + { + *x = 700; + *text = "Different wrapped text".into(); + *wrap_width = Some(70); + font_descriptor.weight = "bold".into(); + } + state + .boards + .active_frame_mut() + .shape_mut(ids[0]) + .unwrap() + .set_shape(moved.clone()); + state.invalidate_hit_cache_for_with(&measurer, ids[0]); + assert_eq!(state.hit_test_at_with(&measurer, 105, 115), None); + assert_eq!(state.hit_test_at_with(&measurer, 705, 115), Some(ids[0])); + state + .boards + .active_frame_mut() + .shape_mut(ids[1]) + .unwrap() + .set_shape(moved); + state.invalidate_hit_cache_for_with(&measurer, ids[1]); + assert_eq!(state.hit_test_at_with(&measurer, 705, 115), Some(ids[1])); + state.boards.active_frame_mut().move_shape(1, 0).unwrap(); + let shapes = &state.boards.active_frame().shapes; + assert_eq!((shapes[0].id, shapes[1].id), (ids[1], ids[0])); + assert_eq!(state.hit_test_at_with(&measurer, 705, 115), Some(ids[0])); +} + +#[test] +fn explicit_point_targeting_keeps_fill_interiors_out_of_stroke_erasing() { + let measurer = TextMeasurer::default(); + let (mut state, _) = scene(1); + let id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 700, + y: 300, + w: 100, + h: 100, + fill: true, + color: RED, + thick: 2.0, + }); + assert_eq!(state.hit_test_at_with(&measurer, 750, 350), Some(id)); + assert!( + state + .hit_test_all_for_points_with(&measurer, &[(750, 350)], 1.0) + .is_empty() + ); +} diff --git a/src/input/state/core/index/owner.rs b/src/input/state/core/index/owner.rs new file mode 100644 index 000000000..51c71233b --- /dev/null +++ b/src/input/state/core/index/owner.rs @@ -0,0 +1,480 @@ +//! Spatial index guards, invalidation, and hit-query policy. + +use super::grid::SpatialGrid; +use crate::draw::{Frame, ShapeId, TextMeasurer}; +use crate::input::boards::BoardIdentityGeneration; +use crate::input::hit_test::{self, HitTestTolerance}; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ActiveFrameOrderGuard { + pub(super) board_identity_generation: BoardIdentityGeneration, + pub(super) board_index: usize, + pub(super) page_generation: u64, + pub(super) page_index: usize, + pub(super) shape_count: usize, + pub(super) shape_order_generation: u64, +} + +impl ActiveFrameOrderGuard { + fn same_frame(self, other: Self) -> bool { + self.board_identity_generation == other.board_identity_generation + && self.board_index == other.board_index + && self.page_generation == other.page_generation + && self.page_index == other.page_index + } +} + +#[derive(Debug, Clone)] +struct SpatialIndexCache { + grid: SpatialGrid, + shape_indices: Option>, + guard: ActiveFrameOrderGuard, +} + +#[cfg(test)] +std::thread_local! { + pub(super) static SPATIAL_SHAPE_INDEX_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[derive(Debug, Clone)] +pub(in crate::input::state) struct CanvasIndex { + hit_test_cache: HashMap, + content_generation: u64, + tolerance: f64, + linear_threshold: usize, + spatial_index: Option, + max_shapes_per_frame: usize, +} + +impl Default for CanvasIndex { + fn default() -> Self { + Self { + hit_test_cache: HashMap::new(), + content_generation: 0, + tolerance: 6.0, + linear_threshold: 400, + spatial_index: None, + max_shapes_per_frame: 0, + } + } +} + +impl CanvasIndex { + pub(in crate::input::state) fn from_config( + hit_test_tolerance: f64, + max_shapes_per_frame: usize, + ) -> Self { + let mut index = Self { + max_shapes_per_frame, + ..Self::default() + }; + index.tolerance = Self::normalized_tolerance(hit_test_tolerance); + index + } + + pub(super) fn generation(&self) -> u64 { + self.content_generation + } + + pub(super) fn invalidate(&mut self) { + self.content_generation = self.content_generation.wrapping_add(1); + self.hit_test_cache.clear(); + self.spatial_index = None; + } + + pub(in crate::input::state) fn restore_from_rollback(&mut self, mut snapshot: Self) { + snapshot.content_generation = snapshot.content_generation.max(self.content_generation); + *self = snapshot; + } + + pub(super) fn invalidate_shape( + &mut self, + id: ShapeId, + new_bounds: Option>, + guard: ActiveFrameOrderGuard, + ) { + self.content_generation = self.content_generation.wrapping_add(1); + self.hit_test_cache.remove(&id); + + if self + .spatial_index + .as_ref() + .is_some_and(|index| !index.guard.same_frame(guard)) + { + self.spatial_index = None; + return; + } + + // `Frame::shapes` is public compatibility storage. If a caller + // replaced or inserted an id directly, we cannot know which old grid + // entry to remove, so rebuild the complete spatial cache. + if self + .spatial_index + .as_ref() + .and_then(|index| index.shape_indices.as_ref()) + .is_some_and(|shape_indices| !shape_indices.contains_key(&id)) + { + self.spatial_index = None; + return; + } + + if let Some(index) = &mut self.spatial_index { + index.grid.remove_shape(id); + if let Some(bounds) = new_bounds { + index.grid.add_shape(id, bounds); + } + + if index.guard.shape_count != guard.shape_count + || index.guard.shape_order_generation != guard.shape_order_generation + { + index.shape_indices = None; + } + index.guard = guard; + } + } + + pub(super) fn ensure_for_frame( + &mut self, + measurer: &TextMeasurer, + frame: &Frame, + guard: ActiveFrameOrderGuard, + ) { + let len = frame.shapes.len(); + if len <= self.linear_threshold { + self.spatial_index = None; + return; + } + + let needs_rebuild = match &self.spatial_index { + None => true, + Some(index) => { + let drift = (index.grid.shape_count() as i64 - len as i64).unsigned_abs() as usize; + index.guard != guard || drift > len / 5 + 1 + } + }; + + if needs_rebuild { + self.spatial_index = + SpatialGrid::build(measurer, frame).map(|grid| SpatialIndexCache { + grid, + shape_indices: None, + guard, + }); + } + + if self + .spatial_index + .as_ref() + .is_some_and(|index| index.shape_indices.is_none()) + { + let shape_indices = Self::build_spatial_shape_indices(frame); + if let Some(index) = &mut self.spatial_index { + index.shape_indices = Some(shape_indices); + } + } + } + + pub(super) fn hit_test_all_for_points( + &self, + measurer: &TextMeasurer, + frame: &Frame, + guard: ActiveFrameOrderGuard, + points: &[(i32, i32)], + tolerance: f64, + ) -> Vec { + let Some(tolerance) = HitTestTolerance::new(tolerance) else { + return Vec::new(); + }; + if points.is_empty() || frame.shapes.is_empty() { + return Vec::new(); + } + + let len = frame.shapes.len(); + let candidate_indices: Vec = if let Some((index, shape_indices)) = self + .spatial_index + .as_ref() + .filter(|index| index.guard == guard) + .and_then(|index| index.shape_indices.as_ref().map(|indices| (index, indices))) + { + let mut unique = HashSet::new(); + for &(x, y) in points { + for id in index.grid.query_with_tolerance((x, y), tolerance) { + unique.insert(id); + } + } + let mut indices = Vec::with_capacity(unique.len()); + let stale = unique.into_iter().any(|id| { + let Some(shape_index) = shape_indices.get(&id).copied() else { + return true; + }; + if frame + .shapes + .get(shape_index) + .is_none_or(|shape| shape.id != id) + { + return true; + } + indices.push(shape_index); + false + }); + if stale { (0..len).collect() } else { indices } + } else { + (0..len).collect() + }; + + let mut hits = Vec::new(); + for index in candidate_indices { + let Some(drawn) = frame.shapes.get(index) else { + continue; + }; + let bounds = hit_test::compute_hit_bounds_with_tolerance(measurer, drawn, tolerance); + let hit = points.iter().any(|&(x, y)| { + bounds.as_ref().is_none_or(|rect| rect.contains(x, y)) + && hit_test::hit_test_with_tolerance(measurer, drawn, (x, y), tolerance) + }); + if hit { + hits.push(drawn.id); + } + } + hits + } + + pub(super) fn hit_test_at( + &mut self, + measurer: &TextMeasurer, + frame: &Frame, + guard: ActiveFrameOrderGuard, + x: i32, + y: i32, + ) -> Option { + let tolerance = + HitTestTolerance::new(self.tolerance).unwrap_or(HitTestTolerance::ONE_PIXEL); + let len = frame.shapes.len(); + + if len > self.linear_threshold { + self.ensure_for_frame(measurer, frame, guard); + if let Some(index) = &self.spatial_index { + let candidates = index.grid.query_with_tolerance((x, y), tolerance); + let index_map = index + .shape_indices + .as_ref() + .expect("spatial shape indices are built with the grid"); + let mut stale = false; + let mut sorted_candidates: Vec<_> = candidates + .into_iter() + .filter_map(|id| { + let Some(shape_index) = index_map.get(&id).copied() else { + stale = true; + return None; + }; + if frame + .shapes + .get(shape_index) + .is_none_or(|shape| shape.id != id) + { + stale = true; + return None; + } + Some(shape_index) + }) + .collect(); + sorted_candidates.sort_unstable_by_key(|&index| std::cmp::Reverse(index)); + + if !stale + && let Some(id) = + self.hit_test_indices(measurer, frame, sorted_candidates, x, y, tolerance) + { + return Some(id); + } + } + } else { + self.spatial_index = None; + } + + self.hit_test_indices(measurer, frame, (0..len).rev(), x, y, tolerance) + } + + pub(super) fn tolerance(&self) -> f64 { + self.tolerance + } + + pub(super) fn set_tolerance(&mut self, tolerance: f64) { + self.tolerance = Self::normalized_tolerance(tolerance); + self.invalidate(); + } + + pub(super) fn set_linear_threshold(&mut self, threshold: usize) { + self.linear_threshold = threshold.max(1); + } + + pub(super) fn max_shapes_per_frame(&self) -> usize { + self.max_shapes_per_frame + } + + #[cfg(test)] + pub(super) fn set_max_shapes_per_frame(&mut self, limit: usize) { + self.max_shapes_per_frame = limit; + } + + #[cfg(test)] + pub(super) fn has_spatial_index(&self) -> bool { + self.spatial_index.is_some() + } + + fn normalized_tolerance(tolerance: f64) -> f64 { + HitTestTolerance::new(tolerance) + .unwrap_or(HitTestTolerance::ONE_PIXEL) + .at_least(HitTestTolerance::ONE_PIXEL) + .value() + } + + fn build_spatial_shape_indices(frame: &Frame) -> HashMap { + #[cfg(test)] + SPATIAL_SHAPE_INDEX_BUILDS.with(|count| count.set(count.get().saturating_add(1))); + frame + .shapes + .iter() + .enumerate() + .map(|(index, shape)| (shape.id, index)) + .collect() + } + + fn hit_test_single( + &mut self, + measurer: &TextMeasurer, + frame: &Frame, + index: usize, + x: i32, + y: i32, + tolerance: HitTestTolerance, + ) -> Option { + let drawn = frame.shapes.get(index)?; + let cached = self.hit_test_cache.get(&drawn.id).copied(); + let bounds = cached + .or_else(|| hit_test::compute_hit_bounds_with_tolerance(measurer, drawn, tolerance)); + let hit = bounds.as_ref().is_none_or(|rect| rect.contains(x, y)) + && hit_test::hit_test_for_point_targeting_with_tolerance( + measurer, + drawn, + (x, y), + tolerance, + ); + if let Some(bounds) = bounds { + self.hit_test_cache.entry(drawn.id).or_insert(bounds); + } + hit.then_some(drawn.id) + } + + fn hit_test_indices( + &mut self, + measurer: &TextMeasurer, + frame: &Frame, + indices: I, + x: i32, + y: i32, + tolerance: HitTestTolerance, + ) -> Option + where + I: IntoIterator, + { + for index in indices { + if let Some(shape_id) = self.hit_test_single(measurer, frame, index, x, y, tolerance) { + return Some(shape_id); + } + } + None + } +} + +#[cfg(test)] +mod canvas_index_owner_tests { + use super::*; + use crate::draw::{Color, Shape}; + use crate::util::Rect; + + fn frame_with_rectangles(count: usize) -> Frame { + let mut frame = Frame::new(); + for offset in 0..count as i32 { + frame.add_shape(Shape::Rect { + x: offset * 20, + y: 0, + w: 10, + h: 10, + color: Color::new(0.0, 0.0, 0.0, 1.0), + thick: 2.0, + fill: false, + }); + } + frame + } + + fn guard(identity: u64, frame: &Frame) -> ActiveFrameOrderGuard { + ActiveFrameOrderGuard { + board_identity_generation: BoardIdentityGeneration(identity), + board_index: 0, + page_generation: 1, + page_index: 0, + shape_count: frame.shapes.len(), + shape_order_generation: frame.shape_order_generation(), + } + } + + #[test] + fn invalidating_a_shape_with_a_stale_frame_guard_drops_the_index() { + let frame = frame_with_rectangles(2); + let mut index = CanvasIndex::default(); + index.set_linear_threshold(1); + index.ensure_for_frame(&TextMeasurer::default(), &frame, guard(1, &frame)); + assert!(index.has_spatial_index()); + + index.invalidate_shape( + frame.shapes[0].id, + Some(Some(Rect::new(0, 0, 10, 10).unwrap())), + guard(2, &frame), + ); + + assert!(!index.has_spatial_index()); + } + + #[test] + fn tolerance_and_linear_threshold_are_floored_at_one() { + let mut index = CanvasIndex::from_config(f64::NAN, 20); + assert_eq!(index.tolerance(), 1.0); + index.set_tolerance(-4.0); + assert_eq!(index.tolerance(), 1.0); + + index.set_linear_threshold(0); + let frame = frame_with_rectangles(1); + index.ensure_for_frame(&TextMeasurer::default(), &frame, guard(1, &frame)); + assert!(!index.has_spatial_index()); + } + + #[test] + fn ensuring_a_frame_below_the_threshold_clears_an_existing_index() { + let frame = frame_with_rectangles(2); + let mut index = CanvasIndex::default(); + index.set_linear_threshold(1); + index.ensure_for_frame(&TextMeasurer::default(), &frame, guard(1, &frame)); + assert!(index.has_spatial_index()); + + index.set_linear_threshold(2); + index.ensure_for_frame(&TextMeasurer::default(), &frame, guard(1, &frame)); + assert!(!index.has_spatial_index()); + } + + #[test] + fn rollback_restore_keeps_the_newer_content_generation() { + let mut snapshot = CanvasIndex::from_config(6.0, 20); + snapshot.invalidate(); + let mut current = snapshot.clone(); + current.invalidate(); + current.set_linear_threshold(1); + let newer_generation = current.generation(); + + current.restore_from_rollback(snapshot); + + assert_eq!(current.generation(), newer_generation); + assert_eq!(current.linear_threshold, 400); + } +} From 1a3f6bccbb7d8230aff94d7a22c915746397334a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:59:11 +0200 Subject: [PATCH 10/42] test(capture): cover selector transitions and stale device releases --- .../tests/event_characterization.rs | 164 ++++++++++++++++++ .../wayland/state/region_capture/tests/mod.rs | 1 + .../state/region_capture/tests/review.rs | 48 +++++ 3 files changed, 213 insertions(+) create mode 100644 src/backend/wayland/state/region_capture/tests/event_characterization.rs diff --git a/src/backend/wayland/state/region_capture/tests/event_characterization.rs b/src/backend/wayland/state/region_capture/tests/event_characterization.rs new file mode 100644 index 000000000..3be3058e0 --- /dev/null +++ b/src/backend/wayland/state/region_capture/tests/event_characterization.rs @@ -0,0 +1,164 @@ +use super::capture_selection::{capture_region, interactive_region}; +use super::lifecycle_and_measure::ocr_region; +use super::*; + +fn active_region_for(purpose: RegionPurposeTag) -> ActiveScreenRegion { + match purpose { + RegionPurposeTag::Ocr => ocr_region(1.0), + RegionPurposeTag::CaptureDeliver => capture_region(), + RegionPurposeTag::CaptureInteractive => interactive_region(), + RegionPurposeTag::Measure => ActiveScreenRegion::Measure { + generation: 1, + bounds: (100, 80), + anchor: None, + edge: None, + }, + } +} + +#[test] +fn each_purpose_keeps_its_event_geometry_and_terminal_ownership_contract() { + for purpose in [ + RegionPurposeTag::Ocr, + RegionPurposeTag::CaptureDeliver, + RegionPurposeTag::CaptureInteractive, + RegionPurposeTag::Measure, + ] { + let mut backend = Some(active_region_for(purpose)); + let mut input = make_test_input_state(); + if purpose == RegionPurposeTag::Measure { + input.activate_measure_mode(1); + } else { + input.activate_region(purpose, 1); + } + assert_eq!( + input.region_state(), + RegionSelectUiState::Armed { + purpose, + generation: 1 + } + ); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (10.0, 20.0) + )); + assert_eq!( + input.region_state(), + RegionSelectUiState::Selecting { + purpose, + generation: 1, + owner: RegionInputSource::Pointer, + start: (10.0, 20.0), + current: (10.0, 20.0), + } + ); + let assert_mirror = |backend: Option, ui: RegionSelectUiState| { + let active = backend.expect("active event backend"); + assert_eq!(active.purpose(), purpose); + assert_eq!(active.generation(), 1); + assert_eq!(active.display_selection(), ui.selection()); + }; + assert_mirror(backend, input.region_state()); + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (25.0, 35.0), + ); + assert_mirror(backend, input.region_state()); + let moving = input.region_state(); + let active = backend; + assert_eq!( + moving.selection(), + Some(RegionSelection { + start: (10.0, 20.0), + end: (25.0, 35.0) + }) + ); + assert!(!begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (70.0, 70.0) + )); + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (80.0, 75.0), + ); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (90.0, 79.0) + ), + RegionSelectionFinalize::NotOwned + ); + assert_eq!(input.region_state(), moving); + assert_eq!(backend, active); + + let result = finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (30.0, 40.0), + ); + let display = RegionSelection { + start: (10.0, 20.0), + end: (30.0, 40.0), + }; + let expected_ui = match purpose { + RegionPurposeTag::Measure => { + assert_eq!(result, RegionSelectionFinalize::Measured); + assert_eq!( + backend.and_then(ActiveScreenRegion::measure_selection), + Some(display) + ); + RegionSelectUiState::Measured { + purpose, + generation: 1, + display, + } + } + RegionPurposeTag::CaptureInteractive => { + assert_eq!(result, RegionSelectionFinalize::Reviewed); + RegionSelectUiState::Review { + purpose, + generation: 1, + display, + move_owner: None, + } + } + RegionPurposeTag::Ocr | RegionPurposeTag::CaptureDeliver => { + assert_eq!( + result, + RegionSelectionFinalize::Selected { + purpose, + rect: ImagePixelRect::new(10, 20, 20, 20, (100, 80)).unwrap(), + } + ); + // Delivery consumes this selection after the event adapter returns. + RegionSelectUiState::Selecting { + purpose, + generation: 1, + owner: RegionInputSource::Pointer, + start: display.start, + current: display.end, + } + } + }; + assert_eq!(input.region_state(), expected_ui); + assert_mirror(backend, input.region_state()); + if purpose != RegionPurposeTag::Measure { + assert_eq!( + backend.and_then(ActiveScreenRegion::selection_rect), + ImagePixelRect::new(10, 20, 20, 20, (100, 80)) + ); + } + assert!(screen_region_invariant(backend, input.region_state())); + } +} diff --git a/src/backend/wayland/state/region_capture/tests/mod.rs b/src/backend/wayland/state/region_capture/tests/mod.rs index 96f6652ee..a6c100909 100644 --- a/src/backend/wayland/state/region_capture/tests/mod.rs +++ b/src/backend/wayland/state/region_capture/tests/mod.rs @@ -2,6 +2,7 @@ use super::*; use crate::input::state::test_support::make_test_input_state; mod capture_selection; +mod event_characterization; mod lifecycle_and_measure; mod ocr; mod picker; diff --git a/src/backend/wayland/state/region_capture/tests/review.rs b/src/backend/wayland/state/region_capture/tests/review.rs index 3964a0752..7fa884eb2 100644 --- a/src/backend/wayland/state/region_capture/tests/review.rs +++ b/src/backend/wayland/state/region_capture/tests/review.rs @@ -741,6 +741,54 @@ fn selecting_the_whole_image_mid_resize_leaves_review_usable() { .is_none(), "the held grip does not survive the new rectangle" ); + let replaced_backend = backend; + let replaced_ui = input.region_state(); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (5.0, 5.0) + ), + RegionSelectionFinalize::NotOwned, + ); + assert_eq!(backend, replaced_backend); + assert_eq!(input.region_state(), replaced_ui); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (50.0, 40.0) + )); + assert_eq!( + input.region_state().selection_owner(), + Some(RegionInputSource::Touch) + ); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (5.0, 5.0) + ), + RegionSelectionFinalize::NotOwned, + ); + assert_eq!( + input.region_state().selection_owner(), + Some(RegionInputSource::Touch) + ); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (50.0, 40.0) + ), + RegionSelectionFinalize::Reviewed, + ); + assert_eq!(input.region_state().selection_owner(), None); + assert_eq!(input.region_state().selection(), Some(display)); + assert!(screen_region_invariant(backend, input.region_state())); assert!( backend .as_mut() From c4472f29afca3eaeee94107002b8a2872aaa7856 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:14:12 +0200 Subject: [PATCH 11/42] refactor(text): retain one engine across startup and UI preferences --- src/backend/wayland/backend/state_init/mod.rs | 4 +- src/backend/wayland/backend/tray.rs | 16 +++- src/backend/wayland/handlers/layer.rs | 4 +- .../wayland/runtime_ui_state/coordinator.rs | 18 ++-- .../wayland/runtime_ui_state/live_state.rs | 14 ++-- .../wayland/runtime_ui_state/rollback.rs | 7 +- src/backend/wayland/runtime_ui_state/tests.rs | 11 ++- .../tests/board_pin_resets.rs | 23 +++-- .../runtime_ui_state/tests/board_pins.rs | 60 +++++++++++--- .../runtime_ui_state/tests/drag_previews.rs | 34 ++++++-- .../runtime_ui_state/tests/layout_state.rs | 19 ++++- .../tests/preference_actions.rs | 11 ++- .../runtime_ui_state/tests/preferences.rs | 38 +++++++-- .../runtime_ui_state/tests/recovery.rs | 30 +++++-- .../runtime_ui_state/tests/text_geometry.rs | 83 +++++++++++++++++++ .../runtime_ui_state/tests/visibility.rs | 44 ++++++++-- .../tests/visibility_recovery.rs | 26 +++++- .../wayland/runtime_ui_state/wayland.rs | 21 ++++- src/backend/wayland/state.rs | 1 + src/backend/wayland/state/core/init.rs | 8 +- src/backend/wayland/state/render/runtime.rs | 21 +++-- src/input/state/core/status_hud.rs | 10 --- src/input/state/core/tool_controls/toolbar.rs | 12 --- src/input/state/tests/focus_mode.rs | 12 ++- src/input/state/tests/status_hud.rs | 12 ++- src/input/state/tests/toolbar_display.rs | 36 ++++++-- src/ui/status/bar/tests/width_budget.rs | 32 +++++-- 27 files changed, 484 insertions(+), 123 deletions(-) create mode 100644 src/backend/wayland/runtime_ui_state/tests/text_geometry.rs diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index d4725e8e8..e96f9b069 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -75,6 +75,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul backend.tokio_runtime.handle(), &keybindings.keybinding_conflicts, ); + let ui_text = crate::ui_text::UiTextEngine::default(); let runtime_ui_path = crate::paths::runtime_ui_state_file(); let (runtime_ui, runtime_ui_unavailable) = match crate::backend::wayland::runtime_ui_state::ToolbarRuntimeState::start( @@ -84,7 +85,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul runtime_wake.handle(), ) { Ok(runtime_ui) => { - runtime_ui.apply_startup_state(&mut input_state); + runtime_ui.apply_startup_state(&ui_text, &mut input_state); (Some(runtime_ui), None) } Err(error) => { @@ -180,6 +181,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul }; let mut state = WaylandState::new(WaylandStateInit { + ui_text, globals: setup.state_globals, config, input_state, diff --git a/src/backend/wayland/backend/tray.rs b/src/backend/wayland/backend/tray.rs index 2e89c5085..431da972a 100644 --- a/src/backend/wayland/backend/tray.rs +++ b/src/backend/wayland/backend/tray.rs @@ -115,19 +115,27 @@ fn apply_tray_action(state: &mut WaylandState, action: TrayAction) { state.input_state.needs_redraw = true; } TrayAction::ToggleLightMode => { - state.input_state.toggle_light_mode(); + state + .input_state + .toggle_light_mode_with_engine(state.render.ui_text()); state.input_state.needs_redraw = true; } TrayAction::LightDrawToggle => { - state.input_state.toggle_light_mode_drawing(); + state + .input_state + .toggle_light_mode_drawing_with_engine(state.render.ui_text()); state.input_state.needs_redraw = true; } TrayAction::LightDrawOn => { - state.input_state.set_light_mode_drawing(true); + state + .input_state + .set_light_mode_drawing_with_engine(state.render.ui_text(), true); state.input_state.needs_redraw = true; } TrayAction::LightDrawOff => { - state.input_state.set_light_mode_drawing(false); + state + .input_state + .set_light_mode_drawing_with_engine(state.render.ui_text(), false); state.input_state.needs_redraw = true; } } diff --git a/src/backend/wayland/handlers/layer.rs b/src/backend/wayland/handlers/layer.rs index f4c75567a..4055bce22 100644 --- a/src/backend/wayland/handlers/layer.rs +++ b/src/backend/wayland/handlers/layer.rs @@ -12,7 +12,9 @@ impl LayerShellHandler for WaylandState { fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle, layer: &LayerSurface) { if self.toolbar.is_toolbar_layer(layer) { info!("Toolbar surface closed by compositor; hiding toolbar"); - let _ = self.input_state.set_toolbar_visible(false); + let _ = self + .input_state + .set_toolbar_visible_with_engine(self.render.ui_text(), false); self.toolbar.set_visible(false); self.refresh_keyboard_interactivity(); return; diff --git a/src/backend/wayland/runtime_ui_state/coordinator.rs b/src/backend/wayland/runtime_ui_state/coordinator.rs index 672533a76..26453229e 100644 --- a/src/backend/wayland/runtime_ui_state/coordinator.rs +++ b/src/backend/wayland/runtime_ui_state/coordinator.rs @@ -59,10 +59,14 @@ impl ToolbarRuntimeState { Ok(runtime) } - pub(in crate::backend::wayland) fn apply_startup_state(&self, input: &mut InputState) { - apply_live_toolbar_state(input, self.controller.live_state(), |_| true); + pub(in crate::backend::wayland) fn apply_startup_state( + &self, + engine: &crate::ui_text::UiTextEngine, + input: &mut InputState, + ) { + apply_live_toolbar_state(engine, input, self.controller.live_state(), |_| true); apply_live_board_state(input, self.controller.live_state(), |_| true); - input.derive_toolbar_visibility_from_pins(); + input.derive_toolbar_visibility_from_pins_with_engine(engine); } /// Layer retained position overrides on top of the authored seeds the @@ -79,10 +83,11 @@ impl ToolbarRuntimeState { pub(super) fn apply_live_state( &self, + engine: &crate::ui_text::UiTextEngine, input: &mut InputState, positions: &mut ToolbarPositionSnapshot, ) { - apply_live_toolbar_state(input, self.controller.live_state(), |_| true); + apply_live_toolbar_state(engine, input, self.controller.live_state(), |_| true); apply_live_toolbar_positions(positions, self.controller.live_state(), |_| true); apply_live_board_state(input, self.controller.live_state(), |_| true); } @@ -268,6 +273,7 @@ impl ToolbarRuntimeState { pub(in crate::backend::wayland) fn refresh_config_seeds( &mut self, + engine: &crate::ui_text::UiTextEngine, config: &Config, input: &mut InputState, positions: &mut ToolbarPositionSnapshot, @@ -318,7 +324,7 @@ impl ToolbarRuntimeState { .retain(|target, _| !changed.contains(target)); rollback }); - apply_live_toolbar_state(input, self.controller.live_state(), |target| { + apply_live_toolbar_state(engine, input, self.controller.live_state(), |target| { changed.contains(target) }); apply_live_toolbar_positions(positions, self.controller.live_state(), |target| { @@ -330,7 +336,7 @@ impl ToolbarRuntimeState { // keeping toolbar preview updates scoped to changed targets above. apply_live_board_state(input, self.controller.live_state(), |_| true); if let Some(rollback) = position_rollback { - apply_toolbar_runtime_rollback(input, positions, &rollback); + apply_toolbar_runtime_rollback(engine, input, positions, &rollback); } self.dispatch_writer_command(); ToolbarSeedRefresh { diff --git a/src/backend/wayland/runtime_ui_state/live_state.rs b/src/backend/wayland/runtime_ui_state/live_state.rs index 321ba0fdf..f00683737 100644 --- a/src/backend/wayland/runtime_ui_state/live_state.rs +++ b/src/backend/wayland/runtime_ui_state/live_state.rs @@ -43,6 +43,7 @@ pub(super) fn top_display_mode_values( /// belongs to what presenter will restore, not to the live strip; anywhere /// else it is the live strip's mode. pub(super) fn apply_persisted_top_display_mode( + engine: &crate::ui_text::UiTextEngine, input: &mut InputState, mode: PersistedTopDisplayMode, ) { @@ -51,19 +52,20 @@ pub(super) fn apply_persisted_top_display_mode( return; } if input.toolbar_top_display_mode() != mode { - input.set_top_display_mode(mode); + input.set_top_display_mode_with_engine(engine, mode); } } pub(super) fn apply_live_toolbar_state( + engine: &crate::ui_text::UiTextEngine, input: &mut InputState, live: &RuntimeUiLiveState, include: impl Fn(&InteractionSeedTarget) -> bool, ) { - apply_live_display_flags(input, live, &include); + apply_live_display_flags(engine, input, live, &include); apply_live_toolbar_preferences(input, live, &include); apply_live_overlay_flags(input, live, &include); - apply_live_toolbar_structure(input, live, &include); + apply_live_toolbar_structure(engine, input, live, &include); } fn live_bool(live: &RuntimeUiLiveState, target: InteractionSeedTarget) -> Option { @@ -74,6 +76,7 @@ fn live_bool(live: &RuntimeUiLiveState, target: InteractionSeedTarget) -> Option } fn apply_live_display_flags( + engine: &crate::ui_text::UiTextEngine, input: &mut InputState, live: &RuntimeUiLiveState, include: &impl Fn(&InteractionSeedTarget) -> bool, @@ -85,7 +88,7 @@ fn apply_live_display_flags( && let Some(InteractionSeedValue::TopDisplayMode(mode)) = live.get(&InteractionSeedTarget::TopDisplayMode) { - apply_persisted_top_display_mode(input, *mode); + apply_persisted_top_display_mode(engine, input, *mode); } if include(&InteractionSeedTarget::StatusBar) && let Some(value) = live_bool(live, InteractionSeedTarget::StatusBar) @@ -189,6 +192,7 @@ fn apply_live_overlay_flags( } fn apply_live_toolbar_structure( + engine: &crate::ui_text::UiTextEngine, input: &mut InputState, live: &RuntimeUiLiveState, include: &impl Fn(&InteractionSeedTarget) -> bool, @@ -216,7 +220,7 @@ fn apply_live_toolbar_structure( if include(&InteractionSeedTarget::StatusBarItem(item)) && let Some(value) = live_bool(live, InteractionSeedTarget::StatusBarItem(item)) { - input.set_status_bar_item_visible(item, value); + input.set_status_bar_item_visible_with_engine(engine, item, value); } } if include(&InteractionSeedTarget::TopPinned) diff --git a/src/backend/wayland/runtime_ui_state/rollback.rs b/src/backend/wayland/runtime_ui_state/rollback.rs index 3b42d97c0..cbcd62275 100644 --- a/src/backend/wayland/runtime_ui_state/rollback.rs +++ b/src/backend/wayland/runtime_ui_state/rollback.rs @@ -1,6 +1,7 @@ use super::*; pub(in crate::backend::wayland) fn apply_toolbar_runtime_rollback( + engine: &crate::ui_text::UiTextEngine, input: &mut InputState, positions: &mut ToolbarPositionSnapshot, rollback: &PreviewRollbackSnapshot, @@ -37,14 +38,14 @@ pub(in crate::backend::wayland) fn apply_toolbar_runtime_rollback( } Target::TopDisplayMode => { if let InteractionSeedValue::TopDisplayMode(mode) = value { - apply_persisted_top_display_mode(input, *mode); + apply_persisted_top_display_mode(engine, input, *mode); } } Target::StatusBarInteractive => { set_bool(value, |v| input.ui_visibility.status_bar_interactive = v) } Target::StatusBarItem(item) => set_bool(value, |v| { - input.set_status_bar_item_visible(*item, v); + input.set_status_bar_item_visible_with_engine(engine, *item, v); }), Target::StatusBar => set_bool(value, |v| input.ui_visibility.show_status_bar = v), Target::StatusBoardBadge => { @@ -106,7 +107,7 @@ pub(in crate::backend::wayland) fn apply_toolbar_runtime_rollback( // leaves live visibility alone. The preview records which path created the // rollback so this cannot be guessed incorrectly from `TopPinned` alone. if rollback.derive_toolbar_visibility_from_pins { - input.derive_toolbar_visibility_from_pins(); + input.derive_toolbar_visibility_from_pins_with_engine(engine); } input.needs_redraw = true; } diff --git a/src/backend/wayland/runtime_ui_state/tests.rs b/src/backend/wayland/runtime_ui_state/tests.rs index c8d482d7f..22e50070a 100644 --- a/src/backend/wayland/runtime_ui_state/tests.rs +++ b/src/backend/wayland/runtime_ui_state/tests.rs @@ -122,7 +122,12 @@ fn apply_finish( finish: ToolbarRuntimeFinish, ) { if let ToolbarRuntimeFinish::Rollback(rollback) = finish { - apply_toolbar_runtime_rollback(input, positions, &rollback); + apply_toolbar_runtime_rollback( + &crate::ui_text::UiTextEngine::default(), + input, + positions, + &rollback, + ); } } @@ -208,7 +213,7 @@ fn commit_display_mode( let prepared = runtime .begin_toolbar_mutation(target, input) .expect("display mode permit"); - input.set_top_display_mode(mode); + input.set_top_display_mode_with_engine(&crate::ui_text::UiTextEngine::default(), mode); runtime.finish_toolbar_mutation(prepared, true, input) } @@ -246,3 +251,5 @@ fn section_toggle(flag: crate::config::ToolbarSectionFlag, show: bool) -> Toolba Flag::TextControls => ToolbarEvent::ToggleTextControls(show), } } + +mod text_geometry; diff --git a/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs b/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs index 01c2c0053..a5fdc59f7 100644 --- a/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs +++ b/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs @@ -22,7 +22,7 @@ future_entry = [1, 2, 3] let config = Config::default(); let mut input = input_from_config(&config); let mut runtime = test_runtime(&config, &runtime_path); - runtime.apply_startup_state(&mut input); + runtime.apply_startup_state(&crate::ui_text::UiTextEngine::default(), &mut input); assert!(board_pinned(&input, "whiteboard")); assert!(matches!( @@ -65,7 +65,11 @@ fn global_runtime_reset_clears_board_pin_override_and_live_value() { assert!(drain.rollbacks.is_empty()); assert!(drain.rebuild_live); let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; - runtime.apply_live_state(&mut input, &mut positions); + runtime.apply_live_state( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + ); assert!(!board_pinned(&input, "whiteboard")); assert!(!runtime_path.exists()); runtime.shutdown_blocking(); @@ -103,7 +107,10 @@ fn unsupported_runtime_file_keeps_toolbar_mutations_live_only_and_byte_exact() { let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert!(!restarted_input.toolbar_top_minimized()); assert_eq!(fs::read(&runtime_path).unwrap(), UNSUPPORTED); restarted.shutdown_blocking(); @@ -155,7 +162,10 @@ fn factory_visibility_reset_survives_restart_over_nondefault_authored_config() { let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert!( !restarted_input .resolved_toolbar_items() @@ -204,7 +214,10 @@ fn factory_order_reset_survives_restart_over_nondefault_authored_config() { let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert_eq!( restarted_input .resolved_toolbar_items() diff --git a/src/backend/wayland/runtime_ui_state/tests/board_pins.rs b/src/backend/wayland/runtime_ui_state/tests/board_pins.rs index c75e9d553..09b2dd4d2 100644 --- a/src/backend/wayland/runtime_ui_state/tests/board_pins.rs +++ b/src/backend/wayland/runtime_ui_state/tests/board_pins.rs @@ -107,7 +107,10 @@ fn board_pin_is_runtime_owned_and_survives_restart_without_touching_config() { let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert!(board_pinned(&restarted_input, "whiteboard")); assert_eq!(fs::read(&config_path).unwrap(), AUTHORED); restarted.shutdown_blocking(); @@ -137,7 +140,12 @@ value = true .sync_pin_seeds_from_config(&config.resolved_boards()); let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; - let refresh = runtime.refresh_config_seeds(&config, &mut input, &mut positions); + let refresh = runtime.refresh_config_seeds( + &crate::ui_text::UiTextEngine::default(), + &config, + &mut input, + &mut positions, + ); assert!(refresh.applied); assert!(board_pinned(&input, "session-board")); assert!( @@ -167,7 +175,12 @@ value = true let mut runtime = test_runtime(&config, &runtime_path); let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; - let refresh = runtime.refresh_config_seeds(&config, &mut input, &mut positions); + let refresh = runtime.refresh_config_seeds( + &crate::ui_text::UiTextEngine::default(), + &config, + &mut input, + &mut positions, + ); assert!(refresh.applied); assert!(settle_runtime(&mut runtime).rollbacks.is_empty()); assert!( @@ -205,14 +218,21 @@ value = true runtime.restore_board_identity(&config, &mut input, board_id.clone(), pin_seed, pinned); assert!(finish.is_none()); assert!(settle_runtime(&mut runtime).rollbacks.is_empty()); - runtime.apply_live_state(&mut input, &mut ToolbarPositionSnapshot { top: (0.0, 0.0) }); + runtime.apply_live_state( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut ToolbarPositionSnapshot { top: (0.0, 0.0) }, + ); assert!(!board_pinned(&input, &board_id)); runtime.shutdown_blocking(); let mut restarted_input = input_from_config(&config); assert!(restarted_input.create_board()); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert!(!board_pinned(&restarted_input, &board_id)); restarted.shutdown_blocking(); } @@ -242,7 +262,11 @@ fn restored_board_pin_is_replayed_after_same_authority_recovery() { let rebuild_live = recover_board_pin_test_persistence(&mut runtime, incident); assert!(rebuild_live); - runtime.apply_live_state(&mut input, &mut ToolbarPositionSnapshot { top: (0.0, 0.0) }); + runtime.apply_live_state( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut ToolbarPositionSnapshot { top: (0.0, 0.0) }, + ); assert!(!board_pinned(&input, &board_id)); let finishes = runtime.finish_deferred_board_pin_restores(&mut input); @@ -288,7 +312,11 @@ fn deferred_board_pin_restore_is_discarded_when_reset_changes_authority() { assert!(drain.rebuild_live); assert!(runtime.controller.active_barrier().is_none()); assert_ne!(runtime.controller.authority_epoch(), original_epoch); - runtime.apply_live_state(&mut input, &mut ToolbarPositionSnapshot { top: (0.0, 0.0) }); + runtime.apply_live_state( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut ToolbarPositionSnapshot { top: (0.0, 0.0) }, + ); assert!(!board_pinned(&input, &board_id)); assert!( @@ -320,13 +348,20 @@ fn delayed_delete_and_same_id_reuse_cannot_resurrect_old_board_pin() { assert!(finish.is_none()); assert!(settle_runtime(&mut runtime).rollbacks.is_empty()); let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; - runtime.apply_live_state(&mut input, &mut positions); + runtime.apply_live_state( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + ); assert!(!board_pinned(&input, "whiteboard")); runtime.shutdown_blocking(); let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert!(!board_pinned(&restarted_input, "whiteboard")); restarted.shutdown_blocking(); } @@ -355,7 +390,12 @@ fn stale_deferred_board_pin_is_rejected_after_authored_pin_reload() { .boards .sync_pin_seeds_from_config(&config_b.resolved_boards()); let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; - let refresh = runtime.refresh_config_seeds(&config_b, &mut input, &mut positions); + let refresh = runtime.refresh_config_seeds( + &crate::ui_text::UiTextEngine::default(), + &config_b, + &mut input, + &mut positions, + ); assert!(refresh.applied); assert!(board_pinned(&input, "whiteboard")); diff --git a/src/backend/wayland/runtime_ui_state/tests/drag_previews.rs b/src/backend/wayland/runtime_ui_state/tests/drag_previews.rs index a31840bde..dd03236ac 100644 --- a/src/backend/wayland/runtime_ui_state/tests/drag_previews.rs +++ b/src/backend/wayland/runtime_ui_state/tests/drag_previews.rs @@ -178,7 +178,12 @@ fn relevant_reload_aborts_item_and_position_previews_without_restoring_old_seed( .order .ordered_ids(ToolbarItemOrderGroup::TopTools) .to_vec(); - let refresh = runtime.refresh_config_seeds(&config_b, &mut input, &mut positions); + let refresh = runtime.refresh_config_seeds( + &crate::ui_text::UiTextEngine::default(), + &config_b, + &mut input, + &mut positions, + ); assert!(refresh.applied); assert!(refresh.item_drag_aborted); assert!(!refresh.position_drag_aborted); @@ -203,7 +208,12 @@ fn relevant_reload_aborts_item_and_position_previews_without_restoring_old_seed( let mut config_c = config_b; config_c.ui.toolbar.top_offset = 100.0; config_c.ui.toolbar.top_offset_y = 101.0; - let refresh = runtime.refresh_config_seeds(&config_c, &mut input, &mut positions); + let refresh = runtime.refresh_config_seeds( + &crate::ui_text::UiTextEngine::default(), + &config_c, + &mut input, + &mut positions, + ); assert!(refresh.applied); assert!(!refresh.item_drag_aborted); assert!(refresh.position_drag_aborted); @@ -231,7 +241,12 @@ fn unrelated_position_reload_preserves_preview_and_cancel_only_restores_its_scop let mut config_b = config_a; config_b.ui.toolbar.top_minimized = !config_b.ui.toolbar.top_minimized; - let refresh = runtime.refresh_config_seeds(&config_b, &mut input, &mut positions); + let refresh = runtime.refresh_config_seeds( + &crate::ui_text::UiTextEngine::default(), + &config_b, + &mut input, + &mut positions, + ); assert!(refresh.applied); assert!(!refresh.position_drag_aborted); assert_eq!( @@ -293,7 +308,12 @@ fn release_during_barrier_is_consumed_once_and_never_replayed() { }); let drain = runtime.drain_writer_completions(); assert_eq!(drain.rollbacks.len(), 1); - apply_toolbar_runtime_rollback(&mut input, &mut positions, &drain.rollbacks[0]); + apply_toolbar_runtime_rollback( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + &drain.rollbacks[0], + ); assert_eq!( input .resolved_toolbar_items() @@ -371,7 +391,11 @@ fn external_source_conflict_rebuilds_live_toolbar_from_external_authority() { let drain = runtime.drain_writer_completions(); assert!(drain.rebuild_live); assert!(drain.rollbacks.is_empty()); - runtime.apply_live_state(&mut input, &mut positions); + runtime.apply_live_state( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + ); assert!(input.toolbar_top_pinned()); assert_eq!( input diff --git a/src/backend/wayland/runtime_ui_state/tests/layout_state.rs b/src/backend/wayland/runtime_ui_state/tests/layout_state.rs index 92535fe89..b36542650 100644 --- a/src/backend/wayland/runtime_ui_state/tests/layout_state.rs +++ b/src/backend/wayland/runtime_ui_state/tests/layout_state.rs @@ -84,7 +84,12 @@ fn an_authored_position_edit_drops_the_stale_drag_override() { let mut config_b = config_a; config_b.ui.toolbar.top_offset = 200.0; config_b.ui.toolbar.top_offset_y = 201.0; - let refresh = runtime.refresh_config_seeds(&config_b, &mut input, &mut positions); + let refresh = runtime.refresh_config_seeds( + &crate::ui_text::UiTextEngine::default(), + &config_b, + &mut input, + &mut positions, + ); assert!(refresh.applied); assert!(settle_runtime(&mut runtime).rollbacks.is_empty()); assert_eq!( @@ -185,7 +190,10 @@ fn a_stored_display_mode_is_restored_at_startup_over_the_config_seed() { TopDisplayMode::Full ); let restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert_eq!( restarted_input.toolbar_top_display_mode(), TopDisplayMode::Micro @@ -262,7 +270,12 @@ fn an_authored_display_mode_edit_drops_the_stale_cycle_override() { let mut config_b = config_a; config_b.ui.toolbar.top_display_mode = TopDisplayMode::Micro; - let refresh = runtime.refresh_config_seeds(&config_b, &mut input, &mut positions); + let refresh = runtime.refresh_config_seeds( + &crate::ui_text::UiTextEngine::default(), + &config_b, + &mut input, + &mut positions, + ); assert!(refresh.applied); assert!(settle_runtime(&mut runtime).rollbacks.is_empty()); assert_eq!( diff --git a/src/backend/wayland/runtime_ui_state/tests/preference_actions.rs b/src/backend/wayland/runtime_ui_state/tests/preference_actions.rs index 1c704bf40..6237b2569 100644 --- a/src/backend/wayland/runtime_ui_state/tests/preference_actions.rs +++ b/src/backend/wayland/runtime_ui_state/tests/preference_actions.rs @@ -41,7 +41,10 @@ fn click_highlight_survives_restart_from_either_path() { let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert_eq!(restarted_input.click_highlight_enabled(), enabled); assert_eq!(restarted_input.highlight_tool_ring_enabled(), ring); @@ -115,7 +118,10 @@ fn keyboard_only_chrome_toggles_survive_restart_without_touching_config() { let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert_eq!(restarted_input.ui_visibility.show_status_bar, expected[0]); assert_eq!( @@ -274,6 +280,7 @@ fn a_rollback_restores_every_durable_chrome_preference() { input.apply_toolbar_event(section_toggle(ToolbarSectionFlag::Presets, false)); apply_toolbar_runtime_rollback( + &crate::ui_text::UiTextEngine::default(), &mut input, &mut positions, &PreviewRollbackSnapshot { diff --git a/src/backend/wayland/runtime_ui_state/tests/preferences.rs b/src/backend/wayland/runtime_ui_state/tests/preferences.rs index 3bec2c089..a9c32691d 100644 --- a/src/backend/wayland/runtime_ui_state/tests/preferences.rs +++ b/src/backend/wayland/runtime_ui_state/tests/preferences.rs @@ -36,7 +36,11 @@ fn status_bar_content_survives_restart_without_touching_config() { let prepared = runtime .begin_toolbar_mutation(target, &input) .expect("item permit"); - input.set_status_bar_item_visible(StatusBarItem::Size, false); + input.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + StatusBarItem::Size, + false, + ); let finish = runtime.finish_toolbar_mutation(prepared, true, &input); assert!(matches!(finish, ToolbarRuntimeFinish::KeepPreview)); @@ -46,7 +50,10 @@ fn status_bar_content_survives_restart_without_touching_config() { let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert!( !restarted_input.ui_visibility.status_bar_interactive, @@ -137,7 +144,10 @@ fn toolbar_preference_toggles_survive_restart_without_touching_config() { let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert_eq!( restarted_input.ui_visibility.show_status_bar, @@ -189,13 +199,21 @@ fn a_seed_refresh_does_not_prune_persisted_preference_overrides() { // Whatever else the run does, the seed baseline stays authored. let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; - runtime.refresh_config_seeds(&config, &mut input, &mut positions); + runtime.refresh_config_seeds( + &crate::ui_text::UiTextEngine::default(), + &config, + &mut input, + &mut positions, + ); assert!(settle_runtime(&mut runtime).rollbacks.is_empty()); runtime.shutdown_blocking(); let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert_eq!( restarted_input.toolbar_use_icons(), flipped, @@ -246,7 +264,10 @@ fn section_visibility_survives_restart_without_touching_config() { let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert_eq!( live(&restarted_input, toggled), @@ -313,7 +334,10 @@ fn toolbar_layout_mode_survives_restart_without_touching_config() { let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert_eq!( restarted_input.toolbar_layout_mode(), diff --git a/src/backend/wayland/runtime_ui_state/tests/recovery.rs b/src/backend/wayland/runtime_ui_state/tests/recovery.rs index 306d25081..73ea1592f 100644 --- a/src/backend/wayland/runtime_ui_state/tests/recovery.rs +++ b/src/backend/wayland/runtime_ui_state/tests/recovery.rs @@ -103,7 +103,11 @@ fn runtime_rebuild_reuses_minimize_transition_cleanup() { ); let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; - runtime.apply_live_state(&mut rebuilt, &mut positions); + runtime.apply_live_state( + &crate::ui_text::UiTextEngine::default(), + &mut rebuilt, + &mut positions, + ); assert!(rebuilt.toolbar_top_minimized()); assert_eq!( @@ -155,7 +159,11 @@ fn supported_runtime_reset_returns_live_state_to_configured_defaults() { assert!(!runtime_path.exists()); let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; - runtime.apply_live_state(&mut input, &mut positions); + runtime.apply_live_state( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + ); assert_eq!(input.toolbar_top_pinned(), config.ui.toolbar.top_pinned); } @@ -362,7 +370,12 @@ fn cancelling_read_only_recovery_rebuilds_a_staged_seed_reload() { ); let mut config_b = config_a; config_b.ui.toolbar.top_pinned = false; - let refresh = runtime.refresh_config_seeds(&config_b, &mut input, &mut positions); + let refresh = runtime.refresh_config_seeds( + &crate::ui_text::UiTextEngine::default(), + &config_b, + &mut input, + &mut positions, + ); assert!(!refresh.applied, "the reload is staged behind recovery"); assert!( input.toolbar_top_pinned(), @@ -375,7 +388,11 @@ fn cancelling_read_only_recovery_rebuilds_a_staged_seed_reload() { drain.rebuild_live, "synchronous cancellation must publish the staged live authority" ); - runtime.apply_live_state(&mut input, &mut positions); + runtime.apply_live_state( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + ); assert!(!input.toolbar_top_pinned()); runtime.shutdown_blocking(); } @@ -411,7 +428,10 @@ fn runtime_toolbar_routes_leave_authored_config_bytes_exactly_unchanged() { input.test_set_toolbar_display_state(input.toolbar_top_display_mode(), true); } ToolbarRuntimeUiPersistenceTarget::TopDisplayMode => { - input.set_top_display_mode(crate::config::TopDisplayMode::Micro); + input.set_top_display_mode_with_engine( + &crate::ui_text::UiTextEngine::default(), + crate::config::TopDisplayMode::Micro, + ); } _ => unreachable!(), } diff --git a/src/backend/wayland/runtime_ui_state/tests/text_geometry.rs b/src/backend/wayland/runtime_ui_state/tests/text_geometry.rs new file mode 100644 index 000000000..d444abee3 --- /dev/null +++ b/src/backend/wayland/runtime_ui_state/tests/text_geometry.rs @@ -0,0 +1,83 @@ +use super::*; +use crate::config::{StatusBarItem, StatusBarStyle, StatusPosition}; +use crate::ui::StatusHudSegmentKind; +use crate::ui_text::UiTextEngine; + +#[test] +fn preference_reapply_and_rollback_refresh_text_geometry_before_another_frame() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut config = Config::default(); + config + .ui + .set_status_bar_item_visible(StatusBarItem::Help, true); + let engine = UiTextEngine::default(); + let mut input = input_from_config(&config); + let mut runtime = controller_only_runtime(&config, &temp.path().join("runtime-ui.toml")); + runtime.apply_startup_state(&engine, &mut input); + assert!( + input.status_hud_layout().is_none(), + "startup has no previous frame dimensions" + ); + input.update_status_hud_layout_for_pointer_with_engine( + &engine, + StatusPosition::BottomLeft, + &StatusBarStyle::default(), + 1280, + 720, + true, + ); + let help = input + .status_hud_layout() + .unwrap() + .segments + .iter() + .find(|s| s.kind == StatusHudSegmentKind::Help) + .unwrap(); + let (x, y) = ( + (help.x + help.width / 2.0).round() as i32, + (help.y + help.height / 2.0).round() as i32, + ); + input.on_mouse_motion(x, y); + assert_eq!(input.status_hud.hover(), Some(StatusHudSegmentKind::Help)); + let mut positions = ToolbarPositionSnapshot { top: (12.0, 24.0) }; + config + .ui + .set_status_bar_item_visible(StatusBarItem::Help, false); + assert!( + runtime + .refresh_config_seeds(&engine, &config, &mut input, &mut positions) + .applied + ); + assert!(!input.status_bar_item_visible(StatusBarItem::Help)); + assert_eq!(input.status_hud.hover(), None); + assert!( + !input + .status_hud_layout() + .unwrap() + .segments + .iter() + .any(|s| s.kind == StatusHudSegmentKind::Help) + ); + let hidden_geometry = format!("{:?}", input.status_hud_layout()); + let rollback = PreviewRollbackSnapshot { + values: BTreeMap::from([( + InteractionSeedTarget::StatusBarItem(StatusBarItem::Help), + InteractionSeedValue::Bool(true), + )]), + derive_toolbar_visibility_from_pins: false, + }; + apply_toolbar_runtime_rollback(&engine, &mut input, &mut positions, &rollback); + assert!(input.status_bar_item_visible(StatusBarItem::Help)); + assert!( + input + .status_hud_layout() + .unwrap() + .segments + .iter() + .any(|s| s.kind == StatusHudSegmentKind::Help) + ); + assert_ne!(format!("{:?}", input.status_hud_layout()), hidden_geometry); + runtime.apply_live_state(&engine, &mut input, &mut positions); + assert!(!input.status_bar_item_visible(StatusBarItem::Help)); + assert_eq!(format!("{:?}", input.status_hud_layout()), hidden_geometry); +} diff --git a/src/backend/wayland/runtime_ui_state/tests/visibility.rs b/src/backend/wayland/runtime_ui_state/tests/visibility.rs index 31c2f40d3..fef7fcaf5 100644 --- a/src/backend/wayland/runtime_ui_state/tests/visibility.rs +++ b/src/backend/wayland/runtime_ui_state/tests/visibility.rs @@ -59,7 +59,10 @@ fn keyboard_visibility_toggle_persists_both_pins_and_startup_hides_the_toolbar() let mut restarted_input = input_from_config(&config); assert!(restarted_input.toolbar_visible()); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert!(!restarted_input.toolbar_top_pinned()); assert!( !restarted_input.toolbar_visible() && !restarted_input.toolbar_top_visible(), @@ -80,7 +83,12 @@ fn a_rolled_back_hide_toggle_restores_live_visibility_from_the_pins() { assert!(input.set_toolbar_visible(false)); input.set_toolbar_top_pinned(false); - apply_toolbar_runtime_rollback(&mut input, &mut positions, &pins_rollback(true)); + apply_toolbar_runtime_rollback( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + &pins_rollback(true), + ); assert!(input.toolbar_top_pinned()); assert!( @@ -101,7 +109,12 @@ fn a_rolled_back_show_toggle_re_hides_the_toolbar() { assert!(input.set_toolbar_visible(true)); input.set_toolbar_top_pinned(true); - apply_toolbar_runtime_rollback(&mut input, &mut positions, &pins_rollback(false)); + apply_toolbar_runtime_rollback( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + &pins_rollback(false), + ); assert!(!input.toolbar_top_pinned()); assert!( @@ -128,7 +141,12 @@ fn a_rolled_back_pin_button_keeps_a_visible_unpinned_toolbar_visible() { derive_toolbar_visibility_from_pins: false, }; - apply_toolbar_runtime_rollback(&mut input, &mut positions, &rollback); + apply_toolbar_runtime_rollback( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + &rollback, + ); assert!(!input.toolbar_top_pinned()); assert!( @@ -188,7 +206,12 @@ fn visibility_toggle_rollback_through_a_failed_reset_restores_the_screen() { }); let drain = runtime.drain_writer_completions(); assert_eq!(drain.rollbacks.len(), 1); - apply_toolbar_runtime_rollback(&mut input, &mut positions, &drain.rollbacks[0]); + apply_toolbar_runtime_rollback( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + &drain.rollbacks[0], + ); assert!(input.toolbar_top_pinned()); assert!( input.toolbar_visible() && input.toolbar_top_visible(), @@ -328,7 +351,11 @@ fn an_exit_during_an_active_reset_barrier_still_lands_the_deferred_toggle() { let drain = runtime.drain_writer_completions(); assert!(drain.rollbacks.is_empty()); if drain.rebuild_live { - runtime.apply_live_state(&mut input, &mut positions); + runtime.apply_live_state( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + ); } // ...then drains the queue; resetting an empty store changed no live // state, so the entry still describes a genuine pin change. @@ -354,7 +381,10 @@ fn an_exit_during_an_active_reset_barrier_still_lands_the_deferred_toggle() { // Restart: the exit-time screen survived the mid-reset exit. let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert!(!restarted_input.toolbar_top_pinned()); assert!( !restarted_input.toolbar_visible(), diff --git a/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs b/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs index 772f55fc2..91420b8cc 100644 --- a/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs +++ b/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs @@ -71,7 +71,10 @@ fn an_exit_during_retry_pending_recovery_still_lands_the_deferred_toggle() { // Restart: the retried write and the deferred toggle both survived. let mut restarted_input = input_from_config(&config); let mut restarted = test_runtime(&config, &runtime_path); - restarted.apply_startup_state(&mut restarted_input); + restarted.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &mut restarted_input, + ); assert_eq!( stored_display_mode(&restarted), Some(PersistedTopDisplayMode::Micro), @@ -107,7 +110,12 @@ fn a_deferred_hide_rollback_lands_in_the_presenter_restore_snapshot() { input.toggle_presenter_mode(); assert!(input.presenter_mode_active()); - apply_toolbar_runtime_rollback(&mut input, &mut positions, &pins_rollback(true)); + apply_toolbar_runtime_rollback( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + &pins_rollback(true), + ); assert!(input.toolbar_top_pinned()); assert!( @@ -151,7 +159,12 @@ fn a_deferred_hide_rollback_lands_in_the_focus_mode_snapshot() { input.handle_action(Action::ToggleFocusMode); assert!(input.focus_mode_active()); - apply_toolbar_runtime_rollback(&mut input, &mut positions, &pins_rollback(true)); + apply_toolbar_runtime_rollback( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + &pins_rollback(true), + ); assert!(input.toolbar_top_pinned()); assert!( @@ -186,7 +199,12 @@ fn a_deferred_hide_rollback_lands_in_the_light_mode_snapshot() { input.handle_action(Action::ToggleLightMode); assert!(input.light_mode_active()); - apply_toolbar_runtime_rollback(&mut input, &mut positions, &pins_rollback(true)); + apply_toolbar_runtime_rollback( + &crate::ui_text::UiTextEngine::default(), + &mut input, + &mut positions, + &pins_rollback(true), + ); assert!(input.toolbar_top_pinned()); assert!( diff --git a/src/backend/wayland/runtime_ui_state/wayland.rs b/src/backend/wayland/runtime_ui_state/wayland.rs index ccbbbef7b..e7c09fec2 100644 --- a/src/backend/wayland/runtime_ui_state/wayland.rs +++ b/src/backend/wayland/runtime_ui_state/wayland.rs @@ -19,7 +19,12 @@ impl WaylandState { return; }; let mut positions = self.toolbar_position_snapshot(); - apply_toolbar_runtime_rollback(&mut self.input_state, &mut positions, &rollback); + apply_toolbar_runtime_rollback( + self.render.ui_text(), + &mut self.input_state, + &mut positions, + &rollback, + ); self.toolbar_chrome.set_top_offset(positions.top); self.toolbar.mark_dirty(); self.input_state.dirty_tracker.mark_full(); @@ -333,8 +338,12 @@ impl WaylandState { let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() else { return; }; - let refresh = - runtime.refresh_config_seeds(&self.config, &mut self.input_state, &mut positions); + let refresh = runtime.refresh_config_seeds( + self.render.ui_text(), + &self.config, + &mut self.input_state, + &mut positions, + ); if !refresh.applied { return; } @@ -446,7 +455,11 @@ impl WaylandState { self.cancel_gtk_toolbar_drag_lifecycle(); let mut positions = self.toolbar_position_snapshot(); if let Some(runtime) = self.preferences.runtime_ui().state() { - runtime.apply_live_state(&mut self.input_state, &mut positions); + runtime.apply_live_state( + self.render.ui_text(), + &mut self.input_state, + &mut positions, + ); } self.toolbar_chrome.set_top_offset(positions.top); self.toolbar.mark_dirty(); diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index 4f7f2f3cc..5535bc082 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -126,6 +126,7 @@ pub(super) use helpers::{ }; pub(in crate::backend::wayland) struct WaylandStateInit { + pub ui_text: crate::ui_text::UiTextEngine, pub globals: ProtocolGlobals, pub config: Config, pub input_state: InputState, diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index 8e1693abb..d90bf3c52 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -5,6 +5,7 @@ use crate::env_vars::FORCE_INLINE_TOOLBARS_ENV; impl WaylandState { pub(in crate::backend::wayland) fn new(init: WaylandStateInit) -> Self { let WaylandStateInit { + ui_text, globals, config, input_state, @@ -115,9 +116,10 @@ impl WaylandState { toolbar: ToolbarSurfaceManager::new(), toolbar_chrome, toolbar_drag: super::super::toolbar::ToolbarDrag::new(), - render: super::super::render::RenderRuntime::new(crate::ui::theme::Theme::resolve( - config.ui.theme.to_theme_mode(), - )), + render: super::super::render::RenderRuntime::new( + crate::ui::theme::Theme::resolve(config.ui.theme.to_theme_mode()), + ui_text, + ), 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 2c3eab9f7..09fea91b5 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -129,13 +129,16 @@ pub(in crate::backend::wayland) struct RenderRuntime { } impl RenderRuntime { - pub(in crate::backend::wayland) fn new(theme: crate::ui::theme::Theme) -> Self { + pub(in crate::backend::wayland) fn new( + theme: crate::ui::theme::Theme, + ui_text: crate::ui_text::UiTextEngine, + ) -> Self { Self { canvas_layer_cache: CanvasLayerCache::new(), draw_caches: crate::draw::RenderCaches::default(), theme, ui_caches: crate::ui::UiRenderCaches::default(), - ui_text: crate::ui_text::UiTextEngine::default(), + ui_text, text_measurer: crate::draw::TextMeasurer::default(), ui_damage: UiDamageHistory::default(), profile_ui_baseline: Vec::new(), @@ -199,12 +202,14 @@ mod tests { #[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, - )); + let mut dark = RenderRuntime::new( + crate::ui::theme::Theme::resolve(crate::ui::theme::ThemeMode::Dark), + crate::ui_text::UiTextEngine::default(), + ); + let light = RenderRuntime::new( + crate::ui::theme::Theme::resolve(crate::ui::theme::ThemeMode::Light), + crate::ui_text::UiTextEngine::default(), + ); 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(); diff --git a/src/input/state/core/status_hud.rs b/src/input/state/core/status_hud.rs index 9dc171285..64eacb219 100644 --- a/src/input/state/core/status_hud.rs +++ b/src/input/state/core/status_hud.rs @@ -47,16 +47,6 @@ impl InputState { } } - pub(crate) fn set_status_bar_item_visible( - &mut self, - item: StatusBarItem, - visible: bool, - ) -> bool { - crate::ui_text::with_legacy_engine(|engine| { - self.set_status_bar_item_visible_with_engine(engine, item, visible) - }) - } - pub(crate) fn set_status_bar_item_visible_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs index d01958239..df82f30a8 100644 --- a/src/input/state/core/tool_controls/toolbar.rs +++ b/src/input/state/core/tool_controls/toolbar.rs @@ -32,12 +32,6 @@ impl InputState { /// Re-derive live visibility from the persisted pin without surfacing a /// toolbar hidden by a transient chrome owner. - pub(crate) fn derive_toolbar_visibility_from_pins(&mut self) { - crate::ui_text::with_legacy_engine(|engine| { - self.derive_toolbar_visibility_from_pins_with_engine(engine) - }) - } - pub(crate) fn derive_toolbar_visibility_from_pins_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, @@ -239,12 +233,6 @@ impl InputState { } } - pub(crate) fn set_top_display_mode(&mut self, mode: TopDisplayMode) { - crate::ui_text::with_legacy_engine(|engine| { - self.set_top_display_mode_with_engine(engine, mode) - }) - } - pub(crate) fn set_top_display_mode_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, diff --git a/src/input/state/tests/focus_mode.rs b/src/input/state/tests/focus_mode.rs index 8ba370cec..2f2a54706 100644 --- a/src/input/state/tests/focus_mode.rs +++ b/src/input/state/tests/focus_mode.rs @@ -231,7 +231,11 @@ fn focus_mode_rescues_when_the_enabled_status_bar_has_no_visible_content() { state.ui_visibility.show_floating_badge = false; state.ui_visibility.show_zoom_chip = false; for item in StatusBarItem::ALL { - state.set_status_bar_item_visible(item, false); + state.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + item, + false, + ); } state.update_status_hud_layout( StatusPosition::BottomLeft, @@ -314,7 +318,11 @@ fn focus_mode_hides_a_fallback_badge_when_the_enabled_status_bar_is_empty() { state.ui_visibility.show_zoom_chip = false; state.set_zoom_status(true, false, 2.0, (0.0, 0.0)); for item in StatusBarItem::ALL { - state.set_status_bar_item_visible(item, false); + state.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + item, + false, + ); } state.update_status_hud_layout( StatusPosition::BottomLeft, diff --git a/src/input/state/tests/status_hud.rs b/src/input/state/tests/status_hud.rs index c031897b2..ff1005ffe 100644 --- a/src/input/state/tests/status_hud.rs +++ b/src/input/state/tests/status_hud.rs @@ -394,7 +394,11 @@ fn disabling_every_content_item_removes_the_hud_and_restores_badge_fallback() { let mut input = create_test_input_state(); input.boards.new_page(); for item in StatusBarItem::ALL { - input.set_status_bar_item_visible(item, false); + input.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + item, + false, + ); } update_hud_layout(&mut input, 1280, 720); @@ -422,7 +426,11 @@ fn changing_status_hud_content_leaves_damage_to_the_effect_pass() { update_hud_layout(&mut input, 1280, 720); let _ = input.take_dirty_region_report(); - assert!(input.set_status_bar_item_visible(StatusBarItem::About, false)); + assert!(input.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + StatusBarItem::About, + false + )); assert!(input.needs_redraw, "the HUD change still schedules a frame"); assert!( diff --git a/src/input/state/tests/toolbar_display.rs b/src/input/state/tests/toolbar_display.rs index 5c05a8536..6f4a2eac1 100644 --- a/src/input/state/tests/toolbar_display.rs +++ b/src/input/state/tests/toolbar_display.rs @@ -437,7 +437,11 @@ fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { refresh_status_hud_layout(&mut state); assert!(state.status_hud_layout().is_some()); for item in StatusBarItem::ALL { - state.set_status_bar_item_visible(item, false); + state.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + item, + false, + ); } assert!( state.ui_visibility.show_status_bar, @@ -449,7 +453,11 @@ fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { ); assert!(!state.status_hud_effectively_visible()); - assert!(state.set_status_bar_item_visible(StatusBarItem::About, true)); + assert!(state.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + StatusBarItem::About, + true + )); assert!( state.status_hud_layout().is_some(), "enabling content refreshes an empty cache before the next frame" @@ -458,7 +466,11 @@ fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { state.status_hud_effectively_visible(), "policy sees the synchronously refreshed measured cache" ); - assert!(state.set_status_bar_item_visible(StatusBarItem::About, false)); + assert!(state.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + StatusBarItem::About, + false + )); state.handle_action(Action::ToggleToolbar); @@ -482,7 +494,11 @@ fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { fn width_shed_content_never_reports_an_effectively_visible_hud() { let mut state = create_test_input_state(); for item in StatusBarItem::ALL { - state.set_status_bar_item_visible(item, false); + state.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + item, + false, + ); } state.update_status_hud_layout( StatusPosition::BottomLeft, @@ -492,7 +508,11 @@ fn width_shed_content_never_reports_an_effectively_visible_hud() { ); assert!(state.status_hud_layout().is_none()); - assert!(state.set_status_bar_item_visible(StatusBarItem::About, true)); + assert!(state.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + StatusBarItem::About, + true + )); assert!( state.status_hud_layout().is_none(), "the narrow output sheds the About-only HUD entirely" @@ -507,7 +527,11 @@ fn width_shed_content_never_reports_an_effectively_visible_hud() { fn toolbar_hint_prevents_a_false_all_chrome_warning_when_it_becomes_visible() { let mut state = create_test_input_state(); for item in StatusBarItem::ALL { - state.set_status_bar_item_visible(item, item == StatusBarItem::ToolbarHint); + state.set_status_bar_item_visible_with_engine( + &crate::ui_text::UiTextEngine::default(), + item, + item == StatusBarItem::ToolbarHint, + ); } refresh_status_hud_layout(&mut state); assert!( diff --git a/src/ui/status/bar/tests/width_budget.rs b/src/ui/status/bar/tests/width_budget.rs index 42aea7e4c..3c6b9028d 100644 --- a/src/ui/status/bar/tests/width_budget.rs +++ b/src/ui/status/bar/tests/width_budget.rs @@ -44,7 +44,11 @@ fn each_core_content_flag_removes_only_its_segment() { for (item, kind) in cases { let mut state = make_state(); - assert!(state.set_status_bar_item_visible(item, false)); + assert!(state.set_status_bar_item_visible_with_engine( + &UiTextEngine::default(), + item, + false + )); let pieces = build_cluster_pieces(&state); assert!( !pieces.iter().any(|piece| piece.kind == Some(kind)), @@ -82,9 +86,17 @@ fn prefix_content_keeps_output_before_selection_and_honors_both_flags() { Some("Output: DP-3 · 34×44px") ); - state.set_status_bar_item_visible(StatusBarItem::ActiveOutput, false); + state.set_status_bar_item_visible_with_engine( + &UiTextEngine::default(), + StatusBarItem::ActiveOutput, + false, + ); assert_eq!(build_prefix_text(&state).as_deref(), Some("34×44px")); - state.set_status_bar_item_visible(StatusBarItem::SelectionInfo, false); + state.set_status_bar_item_visible_with_engine( + &UiTextEngine::default(), + StatusBarItem::SelectionInfo, + false, + ); assert_eq!(build_prefix_text(&state), None); } @@ -99,7 +111,11 @@ fn context_indicator_flag_gates_transient_status_text() { .any(|piece| piece.text.as_deref() == Some(label)) ); - state.set_status_bar_item_visible(StatusBarItem::ContextIndicators, false); + state.set_status_bar_item_visible_with_engine( + &UiTextEngine::default(), + StatusBarItem::ContextIndicators, + false, + ); assert!( !build_cluster_pieces(&state) .iter() @@ -111,9 +127,13 @@ fn context_indicator_flag_gates_transient_status_text() { fn shedding_the_last_optional_piece_does_not_leave_an_empty_pill() { let mut state = make_state(); for item in StatusBarItem::ALL { - state.set_status_bar_item_visible(item, false); + state.set_status_bar_item_visible_with_engine(&UiTextEngine::default(), item, false); } - state.set_status_bar_item_visible(StatusBarItem::About, true); + state.set_status_bar_item_visible_with_engine( + &UiTextEngine::default(), + StatusBarItem::About, + true, + ); assert!( compute_status_hud_layout( From e4fb7506b89a920f03255cc3ee849f9cdc380718 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:14:20 +0200 Subject: [PATCH 12/42] refactor(help): share text resources across layout and painting --- src/backend/wayland/state/render/runtime.rs | 10 ++ src/backend/wayland/state/render/ui.rs | 3 +- src/ui/help_overlay/grid.rs | 21 ++-- src/ui/help_overlay/keycaps.rs | 31 ++++-- src/ui/help_overlay/layout.rs | 21 ++-- src/ui/help_overlay/nav/render.rs | 15 +-- src/ui/help_overlay/nav/state.rs | 20 ++-- src/ui/help_overlay/render/cache.rs | 2 + src/ui/help_overlay/render/entry.rs | 2 + src/ui/help_overlay/render/footer.rs | 10 +- src/ui/help_overlay/render/header.rs | 35 +++--- src/ui/help_overlay/render/mod.rs | 15 ++- src/ui/help_overlay/render/state.rs | 13 ++- src/ui/help_overlay/render/tests/cache.rs | 114 ++++++++++++++------ src/ui/help_overlay/search.rs | 12 ++- src/ui/primitives.rs | 29 ++++- src/ui/text_highlight.rs | 69 +++++++++++- 17 files changed, 317 insertions(+), 105 deletions(-) diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index 09fea91b5..dcc678c7f 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -163,6 +163,16 @@ impl RenderRuntime { (&self.theme, &mut self.ui_caches) } + pub(in crate::backend::wayland::state) fn ui_parts_with_text_mut( + &mut self, + ) -> ( + &crate::ui::theme::Theme, + &mut crate::ui::UiRenderCaches, + &crate::ui_text::UiTextEngine, + ) { + (&self.theme, &mut self.ui_caches, &self.ui_text) + } + pub(in crate::backend::wayland::state) fn canvas_layer_cache_mut( &mut self, ) -> &mut CanvasLayerCache { diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 55ff7bf3a..8dac69966 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -198,13 +198,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 result = { - let (theme, caches) = self.render.ui_parts_mut(); + let (theme, caches, engine) = self.render.ui_parts_with_text_mut(); let mut render = crate::ui::UiRenderCtx { cairo: ctx, theme, caches, }; crate::ui::render_help_overlay_result_with_context( + engine, &mut render, &self.config.ui.help_overlay_style, width, diff --git a/src/ui/help_overlay/grid.rs b/src/ui/help_overlay/grid.rs index e355fe978..1f636044e 100644 --- a/src/ui/help_overlay/grid.rs +++ b/src/ui/help_overlay/grid.rs @@ -1,10 +1,10 @@ -use super::super::primitives::{draw_rounded_rect, text_extents_for}; +use super::super::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use super::keycaps::{KeyComboStyle, draw_key_combo, draw_key_combo_highlight, measure_key_combo}; use super::layout::GridLayout; -use super::search::{HighlightStyle, draw_highlight, find_match_range}; +use super::search::{HighlightStyle, draw_highlight_with_engine, find_match_range}; use super::types::HelpRowHit; use crate::ui::theme::{self, Rgba}; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::UiTextStyle; /// Section badge label text: near-white, slightly softer than the pure-white /// token so it sits comfortably on the tinted badge fill (no matching theme @@ -44,6 +44,7 @@ pub(crate) struct GridColors { #[allow(clippy::too_many_arguments)] pub(crate) fn draw_sections_grid( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, grid: &GridLayout, grid_start_y: f64, @@ -162,7 +163,7 @@ pub(crate) fn draw_sections_grid( heading_text_x += style.heading_icon_size + style.heading_icon_gap; } let heading_baseline = section_y + style.heading_font_size; - draw_text_baseline( + engine.draw_baseline( ctx, heading_style, section.title, @@ -197,6 +198,7 @@ pub(crate) fn draw_sections_grid( search_active && find_match_range(&row_data.key, search_lower).is_some(); if key_match && !row_data.key.is_empty() { let key_width = measure_key_combo( + engine, ctx, row_data.key.as_str(), style.help_font_family, @@ -220,7 +222,8 @@ pub(crate) fn draw_sections_grid( font_weight: cairo::FontWeight::Normal, color: colors.highlight, }; - draw_highlight( + draw_highlight_with_engine( + engine, ctx, desc_x, baseline, @@ -232,6 +235,7 @@ pub(crate) fn draw_sections_grid( // Draw key with keycap styling let _ = draw_key_combo( + engine, ctx, content_x, baseline, @@ -246,7 +250,7 @@ pub(crate) fn draw_sections_grid( colors.description[2], colors.description[3], ); - draw_text_baseline(ctx, body_style, row_data.action, desc_x, baseline, None); + engine.draw_baseline(ctx, body_style, row_data.action, desc_x, baseline, None); section_y += style.row_line_height; } @@ -267,7 +271,8 @@ pub(crate) fn draw_sections_grid( .get(badge_index) .map(|metrics| (metrics.width, metrics.height, metrics.y_bearing)) .unwrap_or_else(|| { - let extents = text_extents_for( + let extents = text_extents_for_with_engine( + engine, ctx, style.help_font_family, cairo::FontSlant::Normal, @@ -298,7 +303,7 @@ pub(crate) fn draw_sections_grid( let text_x = badge_x + style.badge_padding_x; let text_y = section_y + (style.badge_height - badge_metrics.1) / 2.0 - badge_metrics.2; - draw_text_baseline( + engine.draw_baseline( ctx, badge_style, badge.label.as_str(), diff --git a/src/ui/help_overlay/keycaps.rs b/src/ui/help_overlay/keycaps.rs index 24a70ec25..2ec9fc979 100644 --- a/src/ui/help_overlay/keycaps.rs +++ b/src/ui/help_overlay/keycaps.rs @@ -1,7 +1,9 @@ -use super::super::primitives::{draw_rounded_rect, keycap_size, text_extents_for}; -use crate::ui::primitives::draw_keycap; +use super::super::primitives::{ + draw_rounded_rect, keycap_size_with_engine, text_extents_for_with_engine, +}; +use crate::ui::primitives::draw_keycap_with_engine; use crate::ui::theme::toolbar; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::UiTextStyle; pub(crate) struct KeyComboStyle<'a> { pub(crate) font_family: &'a str, @@ -31,7 +33,7 @@ fn for_each_key_token(combo: &str, mut emit: impl FnMut(&str)) { } } -/// Draw a single keycap in the shared keycap language ([`crate::ui::primitives::draw_keycap`]), +/// Draw a single keycap in the shared keycap language ([`crate::ui::primitives::draw_keycap_with_engine`]), /// anchored on a text `baseline` so it lines up with the row's description /// text. Returns the drawn cap width. /// @@ -39,6 +41,7 @@ fn for_each_key_token(combo: &str, mut emit: impl FnMut(&str)) { /// used (`baseline - font_size / 2`), so replacing the cap did not shift the /// rows or the highlight geometry in [`draw_key_combo_highlight`]. fn draw_single_keycap( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, x: f64, baseline: f64, @@ -46,9 +49,10 @@ fn draw_single_keycap( font_size: f64, text_color: [f64; 4], ) -> f64 { - let (_, cap_height) = keycap_size(ctx, text, font_size); + let (_, cap_height) = keycap_size_with_engine(engine, ctx, text, font_size); let top_y = baseline - font_size / 2.0 - cap_height / 2.0; - let (cap_width, _) = draw_keycap( + let (cap_width, _) = draw_keycap_with_engine( + engine, ctx, x, top_y, @@ -62,6 +66,7 @@ fn draw_single_keycap( /// Measure the width of a key combination string with keycap styling pub(crate) fn measure_key_combo( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, key_str: &str, font_family: &str, @@ -79,7 +84,8 @@ pub(crate) fn measure_key_combo( for (alt_idx, alt) in alternatives.iter().enumerate() { if alt_idx > 0 { // Add separator "/" width - let slash_ext = text_extents_for( + let slash_ext = text_extents_for_with_engine( + engine, ctx, font_family, cairo::FontSlant::Normal, @@ -94,7 +100,8 @@ pub(crate) fn measure_key_combo( for_each_key_token(alt, |key| { if key_idx > 0 { // Add "+" separator width (matches draw_key_combo) - let plus_ext = text_extents_for( + let plus_ext = text_extents_for_with_engine( + engine, ctx, font_family, cairo::FontSlant::Normal, @@ -107,7 +114,7 @@ pub(crate) fn measure_key_combo( // Cap width comes from the shared keycap sizer so measuring and // drawing can never disagree about the chip footprint. - let (cap_width, _) = keycap_size(ctx, key, font_size); + let (cap_width, _) = keycap_size_with_engine(engine, ctx, key, font_size); total_width += cap_width + key_gap; key_idx += 1; any_key = true; @@ -123,6 +130,7 @@ pub(crate) fn measure_key_combo( /// Draw a key combination string with keycap styling, returns total width pub(crate) fn draw_key_combo( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, x: f64, baseline: f64, @@ -153,7 +161,7 @@ pub(crate) fn draw_key_combo( style.separator_color[2], 0.85, ); - let slash_ext = draw_text_baseline(ctx, slash_style, "/", cursor_x, slash_y, None); + let slash_ext = engine.draw_baseline(ctx, slash_style, "/", cursor_x, slash_y, None); cursor_x += slash_ext.width() + separator_gap; } @@ -174,11 +182,12 @@ pub(crate) fn draw_key_combo( 0.85, ); cursor_x += 3.0; - let plus_ext = draw_text_baseline(ctx, plus_style, "+", cursor_x, baseline, None); + let plus_ext = engine.draw_baseline(ctx, plus_style, "+", cursor_x, baseline, None); cursor_x += plus_ext.width() + 3.0; } let cap_width = draw_single_keycap( + engine, ctx, cursor_x, baseline, diff --git a/src/ui/help_overlay/layout.rs b/src/ui/help_overlay/layout.rs index fa109065a..aa9097b9d 100644 --- a/src/ui/help_overlay/layout.rs +++ b/src/ui/help_overlay/layout.rs @@ -1,4 +1,4 @@ -use super::super::primitives::text_extents_for; +use super::super::primitives::text_extents_for_with_engine; use super::keycaps::measure_key_combo; use super::types::{BadgeTextMetrics, MeasuredSection, Section}; @@ -13,6 +13,7 @@ pub(crate) struct GridLayout { #[allow(clippy::too_many_arguments)] pub(crate) fn measure_sections( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, sections: Vec
, help_font_family: &str, @@ -39,15 +40,21 @@ pub(crate) fn measure_sections( continue; } // Measure with keycap styling padding - let key_width = - measure_key_combo(ctx, row.key.as_str(), help_font_family, body_font_size); + let key_width = measure_key_combo( + engine, + ctx, + row.key.as_str(), + help_font_family, + body_font_size, + ); key_max_width = key_max_width.max(key_width); } let mut section_width: f64 = 0.0; let mut section_height: f64 = 0.0; - let heading_extents = text_extents_for( + let heading_extents = text_extents_for_with_engine( + engine, ctx, help_font_family, cairo::FontSlant::Normal, @@ -65,7 +72,8 @@ pub(crate) fn measure_sections( if !section.rows.is_empty() { section_height += row_gap_after_heading; for row in §ion.rows { - let desc_extents = text_extents_for( + let desc_extents = text_extents_for_with_engine( + engine, ctx, help_font_family, cairo::FontSlant::Normal, @@ -85,7 +93,8 @@ pub(crate) fn measure_sections( let mut badge_text_metrics = Vec::with_capacity(section.badges.len()); for (index, badge) in section.badges.iter().enumerate() { - let badge_extents = text_extents_for( + let badge_extents = text_extents_for_with_engine( + engine, ctx, help_font_family, cairo::FontSlant::Normal, diff --git a/src/ui/help_overlay/nav/render.rs b/src/ui/help_overlay/nav/render.rs index 40b94567f..1830f3c6b 100644 --- a/src/ui/help_overlay/nav/render.rs +++ b/src/ui/help_overlay/nav/render.rs @@ -1,8 +1,8 @@ use crate::ui::primitives::draw_rounded_rect; use crate::ui::theme::{self, Rgba}; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::UiTextStyle; -use super::super::search::{draw_segmented_text, ellipsize_to_fit}; +use super::super::search::{draw_segmented_text, ellipsize_to_fit_with_engine}; use super::NavState; /// Dark inset fill behind the search input. Numerically equal to the theme @@ -28,6 +28,7 @@ pub(crate) struct NavRender { } pub(crate) fn draw_nav( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, inner_x: f64, mut cursor_y: f64, @@ -48,7 +49,7 @@ pub(crate) fn draw_nav( style.subtitle_color[3], ); let nav_baseline = cursor_y + nav.nav_font_size; - draw_text_baseline( + engine.draw_baseline( ctx, nav_style, &nav.nav_text_primary, @@ -60,6 +61,7 @@ pub(crate) fn draw_nav( let nav_secondary_baseline = cursor_y + nav.nav_font_size; draw_segmented_text( + engine, ctx, inner_x, nav_secondary_baseline, @@ -116,7 +118,8 @@ pub(crate) fn draw_nav( if let Some(ref extra_line_text) = nav.extra_line_text { // Search text with clipping. - let display_text = ellipsize_to_fit( + let display_text = ellipsize_to_fit_with_engine( + engine, ctx, extra_line_text, style.font_family, @@ -131,7 +134,7 @@ pub(crate) fn draw_nav( style.search_color[2], style.search_color[3], ); - draw_text_baseline( + engine.draw_baseline( ctx, nav_style, &display_text, @@ -147,7 +150,7 @@ pub(crate) fn draw_nav( style.search_color[2], 0.5, ); - draw_text_baseline( + engine.draw_baseline( ctx, nav_style, "Type to search... (Esc clears)", diff --git a/src/ui/help_overlay/nav/state.rs b/src/ui/help_overlay/nav/state.rs index ca6189188..689651610 100644 --- a/src/ui/help_overlay/nav/state.rs +++ b/src/ui/help_overlay/nav/state.rs @@ -1,6 +1,6 @@ -use crate::ui::primitives::text_extents_for; +use crate::ui::primitives::text_extents_for_with_engine; -use super::super::search::ellipsize_to_fit; +use super::super::search::ellipsize_to_fit_with_engine; const BULLET: &str = "\u{2022}"; @@ -18,6 +18,7 @@ pub(crate) struct NavState { #[allow(clippy::too_many_arguments)] pub(crate) fn build_nav_state( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, help_font_family: &str, nav_title: &str, @@ -73,7 +74,8 @@ pub(crate) fn build_nav_state( .map(|(text, _)| text.as_str()) .collect(); - let nav_primary_width = text_extents_for( + let nav_primary_width = text_extents_for_with_engine( + engine, ctx, help_font_family, cairo::FontSlant::Normal, @@ -82,7 +84,8 @@ pub(crate) fn build_nav_state( &nav_text_primary, ) .width(); - let nav_secondary_width = text_extents_for( + let nav_secondary_width = text_extents_for_with_engine( + engine, ctx, help_font_family, cairo::FontSlant::Normal, @@ -94,7 +97,8 @@ pub(crate) fn build_nav_state( let search_text = if search_active { let prefix = "Search: "; - let prefix_extents = text_extents_for( + let prefix_extents = text_extents_for_with_engine( + engine, ctx, help_font_family, cairo::FontSlant::Normal, @@ -103,7 +107,8 @@ pub(crate) fn build_nav_state( prefix, ); let max_query_width = (max_search_width - prefix_extents.width()).max(0.0); - let query_display = ellipsize_to_fit( + let query_display = ellipsize_to_fit_with_engine( + engine, ctx, search_query, help_font_family, @@ -118,7 +123,8 @@ pub(crate) fn build_nav_state( let search_hint_text = (!search_active).then(|| "Type to search".to_string()); let extra_line_text = search_text.or(search_hint_text); let extra_line_width = extra_line_text.as_ref().map(|text| { - text_extents_for( + text_extents_for_with_engine( + engine, ctx, help_font_family, cairo::FontSlant::Normal, diff --git a/src/ui/help_overlay/render/cache.rs b/src/ui/help_overlay/render/cache.rs index ee25e0fc9..7fbeaf337 100644 --- a/src/ui/help_overlay/render/cache.rs +++ b/src/ui/help_overlay/render/cache.rs @@ -80,6 +80,7 @@ impl HelpLayoutCache { #[allow(clippy::too_many_arguments)] pub(super) fn get_or_build_overlay_layout( &mut self, + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, style: &crate::config::HelpOverlayStyle, screen_width: u32, @@ -126,6 +127,7 @@ impl HelpLayoutCache { } // Cache miss - build new layout let layout = build_overlay_layout( + engine, ctx, style, screen_width, diff --git a/src/ui/help_overlay/render/entry.rs b/src/ui/help_overlay/render/entry.rs index cd072e19b..90b8a7949 100644 --- a/src/ui/help_overlay/render/entry.rs +++ b/src/ui/help_overlay/render/entry.rs @@ -59,9 +59,11 @@ pub fn render_help_overlay_result( scroll_offset: f64, quick_mode: bool, ) -> HelpRenderResult { + let engine = crate::ui_text::UiTextEngine::default(); let mut caches = crate::ui::UiRenderCaches::default(); let theme = crate::ui::theme::Theme::dark(); render_help_overlay_result_with_context( + &engine, &mut crate::ui::UiRenderCtx { cairo: ctx, theme: &theme, diff --git a/src/ui/help_overlay/render/footer.rs b/src/ui/help_overlay/render/footer.rs index 428425f48..53f85da52 100644 --- a/src/ui/help_overlay/render/footer.rs +++ b/src/ui/help_overlay/render/footer.rs @@ -1,8 +1,8 @@ use super::super::types::HelpRowHit; use super::header; use crate::config::{Action, action_label}; -use crate::ui::primitives::{draw_rounded_rect, text_extents_for}; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; +use crate::ui_text::UiTextStyle; /// Horizontal padding inside the "Replay tour" footer pill, between its border /// and the icon/label content. @@ -33,6 +33,7 @@ pub(super) struct FooterPillLayout<'a> { /// Draw the footer pills as one centred row and return their clickable rects, /// each tagged with the action a click should run. pub(super) fn draw_footer_pills( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, layout: FooterPillLayout<'_>, pills: &[FooterPill], @@ -51,7 +52,8 @@ pub(super) fn draw_footer_pills( .iter() .map(|pill| { let label = action_label(pill.action); - let label_width = text_extents_for( + let label_width = text_extents_for_with_engine( + engine, ctx, layout.font_family, cairo::FontSlant::Normal, @@ -113,7 +115,7 @@ pub(super) fn draw_footer_pills( layout.accent_muted[2], layout.accent_muted[3], ); - draw_text_baseline( + engine.draw_baseline( ctx, label_style, label, diff --git a/src/ui/help_overlay/render/header.rs b/src/ui/help_overlay/render/header.rs index beb8f5e2b..381dd3dcb 100644 --- a/src/ui/help_overlay/render/header.rs +++ b/src/ui/help_overlay/render/header.rs @@ -3,9 +3,9 @@ //! The hint line mirrors the keycap styling used throughout the grid rows so the //! header reads as part of the same visual system instead of flat plain text. -use super::super::super::primitives::{draw_rounded_rect, text_extents_for}; +use super::super::super::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use super::super::keycaps::{KeyComboStyle, draw_key_combo, measure_key_combo}; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::UiTextStyle; /// Vertical headroom the subtitle keycap chips need above the text baseline, /// so the subtitle row reserves space for the [`super::super::keycaps`] chips. @@ -50,13 +50,15 @@ fn normal_style(font_family: &str, font_size: f64) -> UiTextStyle<'_> { /// Measure the total width the hint line occupies (intro + hints + separators). pub(super) fn measure_hints( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, font_family: &str, font_size: f64, content: &HeaderContent<'_>, ) -> f64 { let text_width = |text: &str| { - text_extents_for( + text_extents_for_with_engine( + engine, ctx, font_family, cairo::FontSlant::Normal, @@ -79,7 +81,7 @@ pub(super) fn measure_hints( if has_leading { width += SEP_GAP + text_width(BULLET) + SEP_GAP; } - width += measure_key_combo(ctx, hint.keys, font_family, font_size); + width += measure_key_combo(engine, ctx, hint.keys, font_family, font_size); width += CHIP_LABEL_GAP + text_width(hint.label); has_leading = true; } @@ -90,6 +92,7 @@ pub(super) fn measure_hints( /// Draw the hint line starting at `x`, on `baseline`. Returns the width drawn. #[allow(clippy::too_many_arguments)] pub(super) fn draw_hints( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, x: f64, baseline: f64, @@ -112,7 +115,7 @@ pub(super) fn draw_hints( muted_color[2], muted_color[3], ); - let ext = draw_text_baseline(ctx, style, BULLET, *cursor_x, baseline, None); + let ext = engine.draw_baseline(ctx, style, BULLET, *cursor_x, baseline, None); *cursor_x += ext.width() + SEP_GAP; }; @@ -123,7 +126,7 @@ pub(super) fn draw_hints( label_color[2], label_color[3], ); - let ext = draw_text_baseline(ctx, style, intro, cursor_x, baseline, None); + let ext = engine.draw_baseline(ctx, style, intro, cursor_x, baseline, None); cursor_x += ext.width(); has_leading = true; } @@ -133,7 +136,8 @@ pub(super) fn draw_hints( draw_separator(ctx, &mut cursor_x); } - let combo_width = draw_key_combo(ctx, cursor_x, baseline, hint.keys, key_combo_style); + let combo_width = + draw_key_combo(engine, ctx, cursor_x, baseline, hint.keys, key_combo_style); cursor_x += combo_width + CHIP_LABEL_GAP; ctx.set_source_rgba( @@ -142,7 +146,7 @@ pub(super) fn draw_hints( label_color[2], label_color[3], ); - let label_ext = draw_text_baseline(ctx, style, hint.label, cursor_x, baseline, None); + let label_ext = engine.draw_baseline(ctx, style, hint.label, cursor_x, baseline, None); cursor_x += label_ext.width(); has_leading = true; @@ -153,12 +157,14 @@ pub(super) fn draw_hints( /// Width of the version pill (rounded chip) for the given text. pub(super) fn measure_version_pill( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, font_family: &str, font_size: f64, version: &str, ) -> f64 { - let text_width = text_extents_for( + let text_width = text_extents_for_with_engine( + engine, ctx, font_family, cairo::FontSlant::Normal, @@ -172,6 +178,7 @@ pub(super) fn measure_version_pill( /// Extra width the title row needs so the title and version pill never overlap. pub(super) fn title_row_width( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, font_family: &str, title_font_size: f64, @@ -179,7 +186,8 @@ pub(super) fn title_row_width( title: &str, version: &str, ) -> f64 { - let title_width = text_extents_for( + let title_width = text_extents_for_with_engine( + engine, ctx, font_family, cairo::FontSlant::Normal, @@ -188,7 +196,7 @@ pub(super) fn title_row_width( title, ) .width(); - let pill_width = measure_version_pill(ctx, font_family, pill_font_size, version); + let pill_width = measure_version_pill(engine, ctx, font_family, pill_font_size, version); title_width + TITLE_PILL_GAP + pill_width } @@ -196,6 +204,7 @@ pub(super) fn title_row_width( /// centred against a title whose baseline is `title_baseline`. #[allow(clippy::too_many_arguments)] pub(super) fn draw_version_pill( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, right_edge: f64, title_baseline: f64, @@ -206,7 +215,7 @@ pub(super) fn draw_version_pill( accent: [f64; 4], text_color: [f64; 4], ) { - let pill_width = measure_version_pill(ctx, font_family, font_size, version); + let pill_width = measure_version_pill(engine, ctx, font_family, font_size, version); let pill_height = font_size + PILL_PADDING_Y * 2.0; let pill_x = right_edge - pill_width; // Centre the pill against the title's optical centre (~0.34 of the cap height @@ -231,7 +240,7 @@ pub(super) fn draw_version_pill( }; let text_baseline = pill_y + PILL_PADDING_Y + font_size * 0.82; ctx.set_source_rgba(text_color[0], text_color[1], text_color[2], text_color[3]); - draw_text_baseline( + engine.draw_baseline( ctx, text_style, version, diff --git a/src/ui/help_overlay/render/mod.rs b/src/ui/help_overlay/render/mod.rs index f33b4149c..24ce45c17 100644 --- a/src/ui/help_overlay/render/mod.rs +++ b/src/ui/help_overlay/render/mod.rs @@ -15,7 +15,7 @@ mod state; 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 crate::ui_text::UiTextStyle; pub(in crate::ui) use cache::HelpLayoutCache; pub use entry::{render_help_overlay, render_help_overlay_result}; use footer::{FooterPill, FooterPillLayout, draw_footer_pills}; @@ -28,6 +28,7 @@ const BULLET: &str = "\u{2022}"; #[allow(clippy::too_many_arguments)] pub(crate) fn render_help_overlay_result_with_context( + engine: &crate::ui_text::UiTextEngine, render: &mut crate::ui::UiRenderCtx<'_, '_, '_>, style: &crate::config::HelpOverlayStyle, screen_width: u32, @@ -110,6 +111,7 @@ pub(crate) fn render_help_overlay_result_with_context( let close_hint_text: &str = &close_hint_owned; let layout = render.caches.help_mut().get_or_build_overlay_layout( + engine, ctx, style, screen_width, @@ -198,10 +200,11 @@ pub(crate) fn render_help_overlay_result_with_context( palette.body_text[3], ); let title_baseline = cursor_y + metrics.title_font_size; - draw_text_baseline(ctx, title_style, title_text, inner_x, title_baseline, None); + engine.draw_baseline(ctx, title_style, title_text, inner_x, title_baseline, None); // Version pill, right-aligned on the title row. draw_version_pill( + engine, ctx, inner_x + inner_width, title_baseline, @@ -223,6 +226,7 @@ pub(crate) fn render_help_overlay_result_with_context( ]; let subtitle_baseline = cursor_y + metrics.subtitle_font_size + header::KEYCAP_PAD_Y; draw_hints( + engine, ctx, inner_x, subtitle_baseline, @@ -245,6 +249,7 @@ pub(crate) fn render_help_overlay_result_with_context( extra_line_bottom_spacing: metrics.extra_line_bottom_spacing, }; let nav_render = draw_nav( + engine, ctx, inner_x, cursor_y, @@ -291,6 +296,7 @@ pub(crate) fn render_help_overlay_result_with_context( // pointer hit map tests the real layout rather than an approximation. let mut row_hits: Vec = Vec::new(); draw_sections_grid( + engine, ctx, &layout.grid, grid_start_y, @@ -313,6 +319,7 @@ pub(crate) fn render_help_overlay_result_with_context( // map as clickable rows. About lives here rather than in the header hint // row because it needs no keybinding to be reachable. let footer_hits = draw_footer_pills( + engine, ctx, FooterPillLayout { inner_x, @@ -353,7 +360,7 @@ pub(crate) fn render_help_overlay_result_with_context( ); let note_x = inner_x + (inner_width - layout.note_width) / 2.0; let note_baseline = cursor_y + metrics.note_font_size; - draw_text_baseline( + engine.draw_baseline( ctx, note_style, layout.note_text.as_str(), @@ -378,7 +385,7 @@ pub(crate) fn render_help_overlay_result_with_context( ); let close_x = inner_x + (inner_width - layout.close_hint_width) / 2.0; let close_baseline = cursor_y + metrics.note_font_size; - draw_text_baseline( + engine.draw_baseline( ctx, close_style, close_hint_text, diff --git a/src/ui/help_overlay/render/state.rs b/src/ui/help_overlay/render/state.rs index 75ab275ee..895024207 100644 --- a/src/ui/help_overlay/render/state.rs +++ b/src/ui/help_overlay/render/state.rs @@ -1,4 +1,4 @@ -use super::super::super::primitives::text_extents_for; +use super::super::super::primitives::text_extents_for_with_engine; use super::super::fonts::resolve_help_font_family; use super::super::layout::{GridLayout, build_grid, measure_sections}; use super::super::nav::{NavState, build_nav_state}; @@ -31,6 +31,7 @@ pub(super) struct OverlayLayout { #[allow(clippy::too_many_arguments)] pub(super) fn build_overlay_layout( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, style: &crate::config::HelpOverlayStyle, screen_width: u32, @@ -84,6 +85,7 @@ pub(super) fn build_overlay_layout( let max_search_width = (screen_width as f64 * 0.9 - metrics.padding * 2.0).max(0.0); let nav_state = build_nav_state( + engine, ctx, help_font_family.as_str(), nav_title, @@ -102,6 +104,7 @@ pub(super) fn build_overlay_layout( ); let measured_sections = measure_sections( + engine, ctx, sections, help_font_family.as_str(), @@ -131,6 +134,7 @@ pub(super) fn build_overlay_layout( ); let title_row = title_row_width( + engine, ctx, help_font_family.as_str(), metrics.title_font_size, @@ -139,12 +143,14 @@ pub(super) fn build_overlay_layout( header.version, ); let subtitle_width = measure_hints( + engine, ctx, help_font_family.as_str(), metrics.subtitle_font_size, header, ); - let close_hint_width = text_extents_for( + let close_hint_width = text_extents_for_with_engine( + engine, ctx, help_font_family.as_str(), cairo::FontSlant::Normal, @@ -183,7 +189,8 @@ pub(super) fn build_overlay_layout( } else { format!("{} {} {}", note_text_base, BULLET, page_label) }; - let note_width = text_extents_for( + let note_width = text_extents_for_with_engine( + engine, ctx, help_font_family.as_str(), cairo::FontSlant::Normal, diff --git a/src/ui/help_overlay/render/tests/cache.rs b/src/ui/help_overlay/render/tests/cache.rs index 795ee1544..47fec124b 100644 --- a/src/ui/help_overlay/render/tests/cache.rs +++ b/src/ui/help_overlay/render/tests/cache.rs @@ -37,6 +37,7 @@ fn layout(cache: &mut HelpLayoutCache, inputs: &Inputs, scroll: f64) -> OverlayL let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); cache.get_or_build_overlay_layout( + &crate::ui_text::UiTextEngine::default(), &ctx, &inputs.style, inputs.width, @@ -153,7 +154,12 @@ fn style_quantization_retains_existing_hundredths_policy() { assert_eq!(cache.builds, 2); } -fn paint(caches: &mut crate::ui::UiRenderCaches, inputs: &Inputs, scroll: f64) -> (Vec, f64) { +fn paint( + engine: &crate::ui_text::UiTextEngine, + caches: &mut crate::ui::UiRenderCaches, + inputs: &Inputs, + scroll: f64, +) -> (Vec, crate::help_overlay_interaction::HelpRenderResult) { let mut surface = cairo::ImageSurface::create( cairo::Format::ARgb32, inputs.width as i32, @@ -170,6 +176,7 @@ fn paint(caches: &mut crate::ui::UiRenderCaches, inputs: &Inputs, scroll: f64) - caches, }; extent = super::super::render_help_overlay_result_with_context( + engine, &mut render, &inputs.style, inputs.width, @@ -183,49 +190,90 @@ fn paint(caches: &mut crate::ui::UiRenderCaches, inputs: &Inputs, scroll: f64) - inputs.capture, scroll, inputs.quick, - ) - .scroll_max; + ); } surface.flush(); (surface.data().unwrap().to_vec(), extent) } +fn assert_frame_matches( + actual: &(Vec, crate::help_overlay_interaction::HelpRenderResult), + expected: &(Vec, crate::help_overlay_interaction::HelpRenderResult), +) { + assert_eq!( + actual.1, expected.1, + "scroll and every hit rectangle must match" + ); + assert!( + actual.0 == expected.0, + "help pixels differ at byte {:?}", + actual.0.iter().zip(&expected.0).position(|(a, b)| a != b) + ); +} + #[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); + let engine = crate::ui_text::UiTextEngine::default(); + let second_engine = crate::ui_text::UiTextEngine::default(); + let mut caches = crate::ui::UiRenderCaches::default(); + let mut other_caches = crate::ui::UiRenderCaches::default(); + let inputs = Inputs::default(); + let initial = paint(&engine, &mut caches, &inputs, 0.0); + assert!(initial.0.iter().any(|&byte| byte != 0)); + for scroll in [0.0, 30.0, 0.0] { + let actual = paint(&engine, &mut caches, &inputs, scroll); + let expected = paint(&second_engine, &mut other_caches, &inputs, scroll); + assert_frame_matches(&actual, &expected); + } assert_eq!( - paint(&mut first, &first_input, 30.0), - paint( - &mut crate::ui::UiRenderCaches::default(), - &first_input, - 30.0 - ) + caches.help_mut().builds, + 1, + "scroll keeps the cached layout" ); - assert_eq!( - paint(&mut second, &second_input, 0.0), - paint( - &mut crate::ui::UiRenderCaches::default(), - &second_input, - 0.0 - ) + assert_eq!(other_caches.help_mut().builds, 1); + for input in [ + Inputs { + width: 320, + height: 180, + ..Inputs::default() + }, + Inputs { + quick: true, + ..Inputs::default() + }, + Inputs { + query: "draw".into(), + ..Inputs::default() + }, + Inputs { + query: "你好".into(), + ..Inputs::default() + }, + Inputs { + page: 1, + ..Inputs::default() + }, + ] { + for scroll in [0.0, 40.0] { + let actual = paint(&engine, &mut caches, &input, scroll); + let expected = paint( + &crate::ui_text::UiTextEngine::default(), + &mut crate::ui::UiRenderCaches::default(), + &input, + scroll, + ); + assert_frame_matches(&actual, &expected); + assert_frame_matches(&paint(&engine, &mut caches, &input, scroll), &expected); + } + } + assert_frame_matches(&paint(&engine, &mut caches, &inputs, 0.0), &initial); + assert_frame_matches( + &paint(&second_engine, &mut other_caches, &inputs, 0.0), + &initial, ); assert_eq!( - first.help_mut().builds, + other_caches.help_mut().builds, 1, - "scroll-only painting must reuse layout" + "another owner's changes cannot evict this layout" ); - assert!(initial.0.iter().any(|&byte| byte != 0)); } diff --git a/src/ui/help_overlay/search.rs b/src/ui/help_overlay/search.rs index 0ba048976..e6aa64696 100644 --- a/src/ui/help_overlay/search.rs +++ b/src/ui/help_overlay/search.rs @@ -3,14 +3,16 @@ use super::types::Row; // scorer, reused directly so help search and the command palette rank // identically (no per-surface reimplementation). use crate::input::state::{action_meta_token_score, fuzzy_score, query_tokens}; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::UiTextStyle; // Substring match highlighting is shared with the command palette; matching // itself is fuzzy (see [`row_matches`]), so a fuzzy-only match draws none. -pub(crate) use crate::ui::text_highlight::{HighlightStyle, draw_highlight, find_match_range}; +pub(crate) use crate::ui::text_highlight::{ + HighlightStyle, draw_highlight_with_engine, find_match_range, +}; // Measured trimming lives with the other shared text primitives so surfaces // outside this overlay (the color picker's title) use the same implementation. -pub(crate) use crate::ui::primitives::ellipsize_to_fit; +pub(crate) use crate::ui::primitives::ellipsize_to_fit_with_engine; /// Fuzzy row match: every query token must fuzzy-match the shortcut string /// (`key`), the visible action description, or — for rows that carry an action @@ -40,7 +42,9 @@ pub(crate) fn title_matches(title: &str, needle_lower: &str) -> bool { tokens.iter().all(|token| fuzzy_score(token, title) > 0) } +#[allow(clippy::too_many_arguments)] pub(crate) fn draw_segmented_text( + engine: &crate::ui_text::UiTextEngine, ctx: &cairo::Context, x: f64, baseline: f64, @@ -58,7 +62,7 @@ pub(crate) fn draw_segmented_text( }; for (text, color) in segments { ctx.set_source_rgba(color[0], color[1], color[2], color[3]); - let extents = draw_text_baseline(ctx, style, text, cursor_x, baseline, None); + let extents = engine.draw_baseline(ctx, style, text, cursor_x, baseline, None); cursor_x += extents.width(); } } diff --git a/src/ui/primitives.rs b/src/ui/primitives.rs index a4b94336a..8cb366aea 100644 --- a/src/ui/primitives.rs +++ b/src/ui/primitives.rs @@ -275,7 +275,16 @@ const KEYCAP_PAD_Y_FACTOR: f64 = 0.3; /// Measured (width, height) the [`draw_keycap`] chip occupies for `label` at /// `font_size`, for callers that need to center the chip before drawing it. pub(crate) fn keycap_size(ctx: &cairo::Context, label: &str, font_size: f64) -> (f64, f64) { - let layout = text_layout( + with_legacy_engine(|engine| keycap_size_with_engine(engine, ctx, label, font_size)) +} + +pub(crate) fn keycap_size_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + label: &str, + font_size: f64, +) -> (f64, f64) { + let layout = engine.layout( ctx, UiTextStyle { family: "Sans", @@ -305,7 +314,23 @@ pub(crate) fn draw_keycap( fill: Rgba, text_color: Rgba, ) -> (f64, f64) { - let layout = text_layout( + with_legacy_engine(|engine| { + draw_keycap_with_engine(engine, ctx, x, y, label, font_size, fill, text_color) + }) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn draw_keycap_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + x: f64, + y: f64, + label: &str, + font_size: f64, + fill: Rgba, + text_color: Rgba, +) -> (f64, f64) { + let layout = engine.layout( ctx, UiTextStyle { family: "Sans", diff --git a/src/ui/text_highlight.rs b/src/ui/text_highlight.rs index 3b7ea869d..74dabf5d8 100644 --- a/src/ui/text_highlight.rs +++ b/src/ui/text_highlight.rs @@ -4,7 +4,8 @@ //! simply draws no highlight — the same graceful degradation both callers //! want. -use super::primitives::text_extents_for; +use super::primitives::text_extents_for_with_engine; +use crate::ui_text::{UiTextEngine, with_legacy_engine}; /// Case-insensitive substring range (byte offsets) of `needle_lower` inside /// `haystack`, or `None` when it does not appear literally. `needle_lower` @@ -36,6 +37,20 @@ pub(crate) fn draw_highlight( text: &str, range: (usize, usize), style: &HighlightStyle<'_>, +) { + with_legacy_engine(|engine| { + draw_highlight_with_engine(engine, ctx, x, baseline, text, range, style) + }); +} + +pub(crate) fn draw_highlight_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + x: f64, + baseline: f64, + text: &str, + range: (usize, usize), + style: &HighlightStyle<'_>, ) { let (start, end) = range; if start >= end || end > text.len() { @@ -50,7 +65,8 @@ pub(crate) fn draw_highlight( return; } - let prefix_extents = text_extents_for( + let prefix_extents = text_extents_for_with_engine( + engine, ctx, style.font_family, cairo::FontSlant::Normal, @@ -58,7 +74,8 @@ pub(crate) fn draw_highlight( style.font_size, prefix, ); - let match_extents = text_extents_for( + let match_extents = text_extents_for_with_engine( + engine, ctx, style.font_family, cairo::FontSlant::Normal, @@ -108,3 +125,49 @@ mod tests { assert_eq!(find_match_range("a b a", "a"), Some((0, 1))); } } + +#[cfg(test)] +mod engine_tests { + use super::*; + + fn paint(engine: &UiTextEngine, range: (usize, usize)) -> Vec { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 80).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + draw_highlight_with_engine( + engine, + &ctx, + 10.0, + 40.0, + "a你好b", + range, + &HighlightStyle { + font_family: "Sans", + font_size: 20.0, + font_weight: cairo::FontWeight::Normal, + color: [1.0, 0.5, 0.0, 1.0], + }, + ); + } + surface.flush(); + surface.data().unwrap().to_vec() + } + + #[test] + fn explicit_unicode_highlight_reuses_measurements_and_rejects_split_characters() { + let engine = UiTextEngine::default(); + assert_eq!(find_match_range("a你好b", "你好"), Some((1, 7))); + let pixels = paint(&engine, (1, 7)); + assert!(pixels.iter().any(|&byte| byte != 0)); + assert!(pixels == paint(&UiTextEngine::default(), (1, 7))); + for invalid in [(2, 7), (1, 6), (1, 20), (7, 1), (1, 1)] { + assert!(paint(&engine, invalid).iter().all(|&byte| byte == 0)); + } + assert!(pixels == paint(&engine, (1, 7))); + assert_eq!( + find_match_range("Éclair", "é"), + None, + "matching preserves ASCII-only folding" + ); + } +} From 4753bd609434a51399fe3f1372dca9aac7de6330 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:14:26 +0200 Subject: [PATCH 13/42] refactor(selection): share measurement across geometry and mutations --- src/backend/wayland/session/tests.rs | 5 +- src/input/state/core/index.rs | 10 - src/input/state/core/selection.rs | 22 +- .../core/selection_actions/arrow_bend.rs | 26 +- .../state/core/selection_actions/delete.rs | 37 +- .../state/core/selection_actions/geometry.rs | 11 +- .../selection_actions/measurement_tests.rs | 357 ++++++++++++++++++ src/input/state/core/selection_actions/mod.rs | 3 + .../state/core/selection_actions/resize.rs | 49 ++- .../state/core/selection_actions/spotlight.rs | 37 +- .../state/core/selection_actions/state.rs | 14 +- .../core/selection_actions/text/handles.rs | 21 +- .../state/core/selection_actions/text/wrap.rs | 18 +- .../selection_actions/translation/bounds.rs | 21 +- .../core/selection_actions/translation/mod.rs | 57 ++- .../selection_actions/translation/restore.rs | 17 +- src/input/state/spotlight.rs | 20 +- src/input/state/tests/hit_testing.rs | 14 +- src/input/state/tests/selection/deletion.rs | 2 +- 19 files changed, 670 insertions(+), 71 deletions(-) create mode 100644 src/input/state/core/selection_actions/measurement_tests.rs diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index 610feff43..be970faaa 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -1381,6 +1381,7 @@ fn runtime_open_current_save_failure_preserves_active_selection_move() { #[cfg(unix)] #[test] fn runtime_open_current_save_failure_preserves_spatial_index_for_active_selection_move() { + let measurer = crate::draw::TextMeasurer::default(); let temp = crate::test_temp::tempdir().expect("tempdir"); let current_options = named_options(temp.path(), "current-spatial-save-fail"); let current_target = temp.path().join("current-spatial-symlink-target"); @@ -1410,7 +1411,7 @@ fn runtime_open_current_save_failure_preserves_spatial_index_for_active_selectio assert!(input.apply_translation_to_selection(200, 0)); assert!( input - .hit_test_all_for_points(&[(205, 5)], input.hit_test_tolerance()) + .hit_test_all_for_points_with(&measurer, &[(205, 5)], input.hit_test_tolerance()) .contains(&shape_id) ); input.state = DrawingState::MovingSelection { @@ -1434,7 +1435,7 @@ fn runtime_open_current_save_failure_preserves_spatial_index_for_active_selectio assert!(input.has_spatial_index()); assert!( input - .hit_test_all_for_points(&[(205, 5)], input.hit_test_tolerance()) + .hit_test_all_for_points_with(&measurer, &[(205, 5)], input.hit_test_tolerance()) .contains(&shape_id), "hit testing should use the restored in-progress selection position" ); diff --git a/src/input/state/core/index.rs b/src/input/state/core/index.rs index 2d7e676a2..7ee69fc23 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -22,16 +22,6 @@ impl InputState { } /// Returns all shapes intersecting any of the provided points within tolerance. - pub(crate) fn hit_test_all_for_points( - &mut self, - points: &[(i32, i32)], - tolerance: f64, - ) -> Vec { - with_legacy_measurer(|measurer| { - self.hit_test_all_for_points_with(measurer, points, tolerance) - }) - } - pub(crate) fn hit_test_all_for_points_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection.rs b/src/input/state/core/selection.rs index adee4db3e..173deb1a1 100644 --- a/src/input/state/core/selection.rs +++ b/src/input/state/core/selection.rs @@ -4,7 +4,7 @@ pub(crate) use clipboard::LocalSelectionContext; pub(in crate::input::state::core) use clipboard::SelectionClipboard; use super::base::{InputState, SelectionAxis}; -use crate::draw::ShapeId; +use crate::draw::{ShapeId, TextMeasurer, with_legacy_measurer}; use crate::util::Rect; use std::collections::HashSet; use std::time::Instant; @@ -175,6 +175,14 @@ impl InputState { } pub(crate) fn selection_bounding_box(&self, ids: &[ShapeId]) -> Option { + with_legacy_measurer(|measurer| self.selection_bounding_box_with(measurer, ids)) + } + + pub(crate) fn selection_bounding_box_with( + &self, + measurer: &TextMeasurer, + ids: &[ShapeId], + ) -> Option { let frame = self.boards.active_frame(); let mut min_x = i32::MAX; let mut min_y = i32::MAX; @@ -184,7 +192,7 @@ impl InputState { for id in ids { if let Some(shape) = frame.shape(*id) - && let Some(bounds) = shape.bounding_box() + && let Some(bounds) = shape.bounding_box_with(measurer) { min_x = min_x.min(bounds.x); min_y = min_y.min(bounds.y); @@ -202,7 +210,15 @@ impl InputState { } pub(crate) fn selection_screen_bounding_box(&self, ids: &[ShapeId]) -> Option { - self.selection_bounding_box(ids) + with_legacy_measurer(|measurer| self.selection_screen_bounding_box_with(measurer, ids)) + } + + pub(crate) fn selection_screen_bounding_box_with( + &self, + measurer: &TextMeasurer, + ids: &[ShapeId], + ) -> Option { + self.selection_bounding_box_with(measurer, ids) .and_then(|bounds| self.screen_rect_for_canvas(bounds)) } } diff --git a/src/input/state/core/selection_actions/arrow_bend.rs b/src/input/state/core/selection_actions/arrow_bend.rs index 529fecb25..547e664bd 100644 --- a/src/input/state/core/selection_actions/arrow_bend.rs +++ b/src/input/state/core/selection_actions/arrow_bend.rs @@ -7,6 +7,7 @@ //! the chord. use crate::draw::{ArrowStyle, Shape, ShapeId}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; use crate::util::{self, Rect}; @@ -88,6 +89,16 @@ impl InputState { /// never moves the endpoints, so the mapping is stable for the whole /// gesture and cannot drift from what is on screen. pub(crate) fn drag_arrow_bend_to(&mut self, x: i32, y: i32, snap: bool) -> bool { + with_legacy_measurer(|measurer| self.drag_arrow_bend_to_with(measurer, x, y, snap)) + } + + pub(crate) fn drag_arrow_bend_to_with( + &mut self, + measurer: &TextMeasurer, + x: i32, + y: i32, + snap: bool, + ) -> bool { let crate::input::state::DrawingState::BendingArrow { shape_id, .. } = self.state else { return false; }; @@ -115,20 +126,25 @@ impl InputState { } else { bend }; - self.set_arrow_shape_bend(shape_id, bend) + self.set_arrow_shape_bend_with(measurer, shape_id, bend) } /// Writes a bend onto one arrow, marking what the change repainted. /// /// Records no undo entry: the drag pushes one entry when it ends, so the /// whole gesture undoes in a single step instead of once per motion event. - pub(crate) fn set_arrow_shape_bend(&mut self, shape_id: ShapeId, bend: f64) -> bool { + pub(crate) fn set_arrow_shape_bend_with( + &mut self, + measurer: &TextMeasurer, + shape_id: ShapeId, + bend: f64, + ) -> bool { let clamped = util::clamp_arrow_bend(bend); let frame = self.boards.active_frame_mut(); let Some(drawn) = frame.shape_mut(shape_id) else { return false; }; - let before = drawn.bounding_box(); + let before = drawn.bounding_box_with(measurer); let Shape::Arrow { bend: current, .. } = &mut drawn.shape else { return false; }; @@ -137,10 +153,10 @@ impl InputState { } *current = clamped; drawn.invalidate_bounds(); - let after = drawn.bounding_box(); + let after = drawn.bounding_box_with(measurer); self.mark_selection_dirty_region(before); self.mark_selection_dirty_region(after); - self.invalidate_hit_cache_for(shape_id); + self.invalidate_hit_cache_for_with(measurer, shape_id); self.mark_session_dirty(); self.needs_redraw = true; true diff --git a/src/input/state/core/selection_actions/delete.rs b/src/input/state/core/selection_actions/delete.rs index 8fd687b97..7620744a8 100644 --- a/src/input/state/core/selection_actions/delete.rs +++ b/src/input/state/core/selection_actions/delete.rs @@ -1,11 +1,16 @@ use super::super::base::InputState; use crate::draw::ShapeId; use crate::draw::frame::UndoAction; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use std::borrow::Cow; use std::collections::HashSet; impl InputState { pub(crate) fn delete_selection(&mut self) -> bool { + with_legacy_measurer(|measurer| self.delete_selection_with(measurer)) + } + + pub(crate) fn delete_selection_with(&mut self, measurer: &TextMeasurer) -> bool { let id_set: HashSet = { let ids = self.selected_shape_ids(); if ids.is_empty() { @@ -13,19 +18,27 @@ impl InputState { } ids.iter().copied().collect() }; - self.delete_shapes_by_id_set(&id_set) + self.delete_shapes_by_id_set(measurer, &id_set) } - pub(crate) fn delete_shapes_by_ids(&mut self, ids: &[ShapeId]) -> bool { + pub(crate) fn delete_shapes_by_ids_with( + &mut self, + measurer: &TextMeasurer, + ids: &[ShapeId], + ) -> bool { if ids.is_empty() { return false; } let id_set: HashSet = ids.iter().copied().collect(); - self.delete_shapes_by_id_set(&id_set) + self.delete_shapes_by_id_set(measurer, &id_set) } - fn delete_shapes_by_id_set(&mut self, id_set: &HashSet) -> bool { + fn delete_shapes_by_id_set( + &mut self, + measurer: &TextMeasurer, + id_set: &HashSet, + ) -> bool { if id_set.is_empty() { return false; } @@ -39,7 +52,7 @@ impl InputState { if shape.locked { continue; } - dirty.push((shape.id, shape.bounding_box())); + dirty.push((shape.id, shape.bounding_box_with(measurer))); removed.push((index, shape.clone())); } } @@ -64,7 +77,7 @@ impl InputState { for (shape_id, bounds) in dirty { self.mark_selection_dirty_region(bounds); - self.invalidate_hit_cache_for(shape_id); + self.invalidate_hit_cache_for_with(measurer, shape_id); } self.clear_selection(); @@ -74,9 +87,17 @@ impl InputState { } pub(crate) fn erase_strokes_by_points(&mut self, points: &[(i32, i32)]) -> bool { + with_legacy_measurer(|measurer| self.erase_strokes_by_points_with(measurer, points)) + } + + pub(crate) fn erase_strokes_by_points_with( + &mut self, + measurer: &TextMeasurer, + points: &[(i32, i32)], + ) -> bool { let sampled = self.sample_eraser_path_points(points); - let ids = self.hit_test_all_for_points(&sampled, self.eraser_hit_radius()); - self.delete_shapes_by_ids(&ids) + let ids = self.hit_test_all_for_points_with(measurer, &sampled, self.eraser_hit_radius()); + self.delete_shapes_by_ids_with(measurer, &ids) } /// Samples eraser path points to ensure adequate coverage for hit testing. diff --git a/src/input/state/core/selection_actions/geometry.rs b/src/input/state/core/selection_actions/geometry.rs index c86f5c74f..c95e5c552 100644 --- a/src/input/state/core/selection_actions/geometry.rs +++ b/src/input/state/core/selection_actions/geometry.rs @@ -1,5 +1,6 @@ use super::super::base::InputState; use crate::draw::ShapeId; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::util::Rect; fn selection_rect(start_x: i32, start_y: i32, end_x: i32, end_y: i32) -> Option { @@ -25,6 +26,14 @@ impl InputState { } pub(crate) fn shape_ids_in_rect(&self, rect: Rect) -> Vec { + with_legacy_measurer(|measurer| self.shape_ids_in_rect_with(measurer, rect)) + } + + pub(crate) fn shape_ids_in_rect_with( + &self, + measurer: &TextMeasurer, + rect: Rect, + ) -> Vec { let frame = self.boards.active_frame(); frame .shapes @@ -32,7 +41,7 @@ impl InputState { .filter_map(|shape| { shape .shape - .bounding_box() + .bounding_box_with(measurer) .and_then(|bounds| rects_intersect(rect, bounds).then_some(shape.id)) }) .collect() diff --git a/src/input/state/core/selection_actions/measurement_tests.rs b/src/input/state/core/selection_actions/measurement_tests.rs new file mode 100644 index 000000000..67d6a0e47 --- /dev/null +++ b/src/input/state/core/selection_actions/measurement_tests.rs @@ -0,0 +1,357 @@ +use crate::draw::{ + ArrowLabel, ArrowStyle, FontDescriptor, RED, Shape, ShapeId, StepMarkerLabel, TextMeasurer, +}; +use crate::input::InputState; +use crate::input::state::core::base::SelectionHandle; +use crate::util::Rect; + +fn fixtures() -> Vec<(Shape, (i32, i32))> { + vec![ + ( + Shape::Text { + x: 100, + y: 200, + text: "Wide wrapping words wrap more".into(), + size: 24.0, + color: RED, + font_descriptor: FontDescriptor::default(), + background_enabled: true, + wrap_width: Some(150), + }, + (105, 195), + ), + ( + Shape::StickyNote { + x: 100, + y: 200, + text: "Wide wrapping words wrap more".into(), + size: 24.0, + background: RED, + font_descriptor: FontDescriptor::default(), + wrap_width: Some(150), + }, + (105, 195), + ), + ( + Shape::StepMarker { + x: 100, + y: 200, + color: RED, + label: StepMarkerLabel { + value: 8888, + size: 24.0, + font_descriptor: FontDescriptor::default(), + }, + }, + (100, 200), + ), + ( + Shape::Arrow { + x1: 100, + y1: 200, + x2: 200, + y2: 200, + color: RED, + thick: 2.0, + arrow_length: 12.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, + label: Some(ArrowLabel { + value: 8888, + size: 24.0, + font_descriptor: FontDescriptor::default(), + }), + }, + (150, 215), + ), + ] +} + +fn state_with(shape: Shape) -> (InputState, ShapeId, ShapeId) { + let mut state = crate::input::state::test_support::make_test_input_state(); + state.view.set_screen_dimensions(1000, 800); + state.set_hit_test_threshold(1); + state.set_hit_test_tolerance(1.0); + let mut locked = shape.clone(); + locked.translate(500, 0); + let frame = state.boards.active_frame_mut(); + let id = frame.add_shape(shape); + let locked_id = frame.add_shape(locked); + frame.shape_mut(locked_id).unwrap().locked = true; + state.set_selection(vec![id, locked_id]); + state.take_dirty_regions(); + (state, id, locked_id) +} + +fn bounds(state: &InputState, owner: &TextMeasurer, id: ShapeId) -> Rect { + state + .boards + .active_frame() + .shape(id) + .unwrap() + .bounding_box_with(owner) + .unwrap() +} + +fn assert_dirty_covers(dirty: &[Rect], expected: Rect) { + for (x, y) in [ + (expected.x, expected.y), + ( + expected.x + expected.width - 1, + expected.y + expected.height - 1, + ), + ] { + assert!( + dirty.iter().any(|rect| rect.contains(x, y)), + "missing dirty point ({x}, {y}) for {expected:?}: {dirty:?}" + ); + } + assert!( + !dirty.iter().any(|rect| rect.contains(999, 799)), + "fixture should exercise local damage rather than full damage" + ); +} + +#[test] +fn explicit_translation_restores_decorated_shapes_and_keeps_locked_geometry() { + for (shape, probe) in fixtures() { + let owner = TextMeasurer::default(); + let (mut state, id, locked_id) = state_with(shape); + let original = bounds(&state, &owner, id); + let locked = bounds(&state, &owner, locked_id); + let snapshots = state.capture_movable_selection_snapshots(); + assert_eq!(snapshots.len(), 1); + assert_eq!(state.hit_test_at_with(&owner, probe.0, probe.1), Some(id)); + assert!(state.has_spatial_index()); + assert!(state.translate_selection_with_undo_with(&owner, 80, 60)); + let moved = bounds(&state, &owner, id); + assert_eq!( + (moved.x, moved.y, moved.width, moved.height), + ( + original.x + 80, + original.y + 60, + original.width, + original.height + ) + ); + assert_eq!(bounds(&state, &owner, locked_id), locked); + assert_eq!(state.hit_test_at_with(&owner, probe.0, probe.1), None); + assert_eq!( + state.hit_test_at_with(&owner, probe.0 + 80, probe.1 + 60), + Some(id) + ); + let dirty = state.take_dirty_regions(); + assert_dirty_covers(&dirty, original); + assert_dirty_covers(&dirty, moved); + state.restore_selection_from_snapshots_with(&owner, snapshots); + assert_eq!(bounds(&state, &owner, id), original); + assert_eq!(state.hit_test_at_with(&owner, probe.0, probe.1), Some(id)); + let dirty = state.take_dirty_regions(); + assert_dirty_covers(&dirty, moved); + assert_dirty_covers(&dirty, original); + } +} + +#[test] +fn explicit_resize_and_restore_use_previous_live_damage() { + for (shape, _) in fixtures() { + let moves_content_only = matches!( + shape, + Shape::Text { .. } | Shape::StickyNote { .. } | Shape::StepMarker { .. } + ); + let owner = TextMeasurer::default(); + let (mut state, id, locked_id) = state_with(shape); + state.set_selection(vec![id]); + let original = state.selection_bounds_with(&owner).unwrap(); + let locked = bounds(&state, &owner, locked_id); + let snapshots = state.capture_resize_selection_snapshots(); + state.ensure_spatial_index_for_active_frame_with(&owner); + state.apply_selection_resize_with( + &owner, + SelectionHandle::BottomRight, + &original, + 100, + 60, + &snapshots, + ); + let first_resize = bounds(&state, &owner, id); + assert_ne!(first_resize, original); + if moves_content_only { + assert_eq!( + (first_resize.width, first_resize.height), + (original.width, original.height) + ); + assert_ne!((first_resize.x, first_resize.y), (original.x, original.y)); + } else { + assert!(first_resize.width > original.width); + } + state.take_dirty_regions(); + state.apply_selection_resize_with( + &owner, + SelectionHandle::BottomRight, + &original, + 20, + 10, + &snapshots, + ); + let second_resize = bounds(&state, &owner, id); + assert_ne!(second_resize, first_resize); + if moves_content_only { + assert_eq!( + (second_resize.width, second_resize.height), + (original.width, original.height) + ); + assert_ne!( + (second_resize.x, second_resize.y), + (first_resize.x, first_resize.y) + ); + } else { + assert!(second_resize.width < first_resize.width); + } + let dirty = state.take_dirty_regions(); + assert_dirty_covers(&dirty, first_resize); + assert_dirty_covers(&dirty, second_resize); + state.restore_resize_from_snapshots_with(&owner, &snapshots); + assert_eq!(bounds(&state, &owner, id), original); + assert_eq!(bounds(&state, &owner, locked_id), locked); + let dirty = state.take_dirty_regions(); + assert_dirty_covers(&dirty, second_resize); + assert_dirty_covers(&dirty, original); + } +} + +#[test] +fn explicit_text_wrap_handles_and_screen_bounds_follow_live_geometry() { + for (shape, _) in fixtures().into_iter().take(2) { + let owner = TextMeasurer::default(); + let (mut state, id, locked_id) = state_with(shape); + state.set_selection(vec![id]); + let before = bounds(&state, &owner, id); + let (_, handle) = state.selected_text_resize_handle_with(&owner).unwrap(); + assert_eq!( + state.hit_text_resize_handle_with( + &owner, + handle.x + handle.width / 2, + handle.y + handle.height / 2 + ), + Some(id) + ); + state.ensure_spatial_index_for_active_frame_with(&owner); + assert!(state.update_text_wrap_width_with(&owner, id, 60)); + let after = bounds(&state, &owner, id); + assert!(after.height > before.height); + assert!(!state.update_text_wrap_width_with(&owner, locked_id, 60)); + let dirty = state.take_dirty_regions(); + assert_dirty_covers(&dirty, before); + assert_dirty_covers(&dirty, after); + assert_ne!( + state.selected_text_resize_handle_with(&owner).unwrap().1, + handle + ); + assert!(state.shape_ids_in_rect_with(&owner, after).contains(&id)); + assert_eq!( + state.selection_bounds_with(&owner), + state.selection_bounding_box_with(&owner, &[id]) + ); + state.view.set_zoom_status(true, false, 2.0, (20.0, 10.0)); + assert_eq!( + state.selection_screen_bounding_box_with(&owner, &[id]), + Rect::new( + (after.x - 20) * 2, + (after.y - 10) * 2, + after.width * 2, + after.height * 2 + ) + ); + assert_eq!(state.selection_bounds(), Some(after)); + } +} + +#[test] +fn explicit_lock_delete_and_sampled_erase_preserve_locked_shapes() { + for (shape, probe) in fixtures() { + let owner = TextMeasurer::default(); + let (mut state, id, locked_id) = state_with(shape); + let original = bounds(&state, &owner, id); + state.ensure_spatial_index_for_active_frame_with(&owner); + assert!(state.set_selection_locked_with(&owner, true)); + assert!(!state.delete_selection_with(&owner)); + assert_eq!(state.boards.active_frame().shapes.len(), 2); + state.set_selection(vec![id]); + assert!(state.set_selection_locked_with(&owner, false)); + state.take_dirty_regions(); + // A sparse path crosses the decorated hit region; sampling and both + // hit/deletion stages must use the supplied owner. + assert!(state.erase_strokes_by_points_with( + &owner, + &[(probe.0 - 80, probe.1), (probe.0 + 80, probe.1)] + )); + assert!(state.boards.active_frame().shape(id).is_none()); + assert!(state.boards.active_frame().shape(locked_id).is_some()); + assert_eq!(state.hit_test_at_with(&owner, probe.0, probe.1), None); + assert_dirty_covers(&state.take_dirty_regions(), original); + } +} + +#[test] +fn explicit_arrow_and_spotlight_drag_chains_refresh_geometry_and_index() { + use crate::input::DrawingState; + let owner = TextMeasurer::default(); + let (mut arrow, _) = fixtures().pop().unwrap(); + if let Shape::Arrow { style, .. } = &mut arrow { + *style = ArrowStyle::Curved; + } + let (mut state, id, _) = state_with(arrow); + state.set_selection(vec![id]); + let before = bounds(&state, &owner, id); + state.ensure_spatial_index_for_active_frame_with(&owner); + let generation = state.canvas_content_generation(); + state.state = DrawingState::BendingArrow { + shape_id: id, + snapshot: state.shape_snapshot(id).unwrap(), + }; + assert!(state.drag_arrow_bend_to_with(&owner, 150, 150, false)); + let after = bounds(&state, &owner, id); + assert_ne!(before, after); + assert!(state.canvas_content_generation() > generation); + let dirty = state.take_dirty_regions(); + assert_dirty_covers(&dirty, before); + assert_dirty_covers(&dirty, after); + + let spotlight = Shape::Spotlight { + cx: 150, + cy: 200, + rx: 60, + ry: 40, + magnification: 1.0, + }; + let (mut state, id, _) = state_with(spotlight); + state.set_selection(vec![id]); + let control = state.selected_spotlight_control_with(&owner).unwrap(); + state.state = DrawingState::AdjustingSpotlightMagnification { + shape_id: id, + snapshot: state.shape_snapshot(id).unwrap(), + }; + let generation = state.canvas_content_generation(); + let track = control.track.track; + assert!( + state + .hit_spotlight_magnification_track_with( + &owner, + track.x + track.width / 2, + track.y + track.height / 2 + ) + .is_some() + ); + assert!(state.drag_spotlight_magnification_to_with(&owner, track.x + track.width)); + assert!( + state + .selected_spotlight_control_with(&owner) + .unwrap() + .magnification + > 1.0 + ); + assert!(state.canvas_content_generation() > generation); +} diff --git a/src/input/state/core/selection_actions/mod.rs b/src/input/state/core/selection_actions/mod.rs index 26eff0018..ab5cf83b0 100644 --- a/src/input/state/core/selection_actions/mod.rs +++ b/src/input/state/core/selection_actions/mod.rs @@ -11,3 +11,6 @@ pub(crate) use spotlight::SpotlightMagnificationTrack; mod state; mod text; mod translation; + +#[cfg(test)] +mod measurement_tests; diff --git a/src/input/state/core/selection_actions/resize.rs b/src/input/state/core/selection_actions/resize.rs index bdbbfc436..8d7ba6ab4 100644 --- a/src/input/state/core/selection_actions/resize.rs +++ b/src/input/state/core/selection_actions/resize.rs @@ -2,6 +2,7 @@ use crate::draw::ShapeId; use crate::draw::frame::ShapeSnapshot; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; use crate::input::state::core::base::SelectionHandle; use crate::util::Rect; @@ -14,7 +15,17 @@ const HANDLE_TOLERANCE: i32 = 4; impl InputState { /// Hit test for selection handles. Returns the handle if mouse is over one. pub fn hit_selection_handle(&self, x: i32, y: i32) -> Option { - let bounds = self.selection_bounds()?; + with_legacy_measurer(|measurer| self.hit_selection_handle_with(measurer, x, y)) + } + + /// Hit-tests selection handles using the supplied text measurement owner. + pub fn hit_selection_handle_with( + &self, + measurer: &TextMeasurer, + x: i32, + y: i32, + ) -> Option { + let bounds = self.selection_bounds_with(measurer)?; let corner_radius = (HANDLE_SIZE / 2) + HANDLE_TOLERANCE; let edge_radius = (HANDLE_SIZE * 3 / 4) / 2 + HANDLE_TOLERANCE; @@ -55,12 +66,26 @@ impl InputState { dx: i32, dy: i32, snapshots: &[(ShapeId, ShapeSnapshot)], + ) { + with_legacy_measurer(|measurer| { + self.apply_selection_resize_with(measurer, handle, original_bounds, dx, dy, snapshots) + }) + } + + pub(crate) fn apply_selection_resize_with( + &mut self, + measurer: &TextMeasurer, + handle: SelectionHandle, + original_bounds: &Rect, + dx: i32, + dy: i32, + snapshots: &[(ShapeId, ShapeSnapshot)], ) { if snapshots.is_empty() { return; } - let previous_bounds = self.selection_bounds(); + let previous_bounds = self.selection_bounds_with(measurer); self.mark_selection_dirty_region(previous_bounds); // Calculate scale factors based on handle and delta let (scale_x, scale_y, anchor_x, anchor_y) = @@ -81,14 +106,24 @@ impl InputState { } for shape_id in ids_to_invalidate { - self.invalidate_hit_cache_for(shape_id); + self.invalidate_hit_cache_for_with(measurer, shape_id); } - self.mark_selection_dirty_region(self.selection_bounds()); + self.mark_selection_dirty_region(self.selection_bounds_with(measurer)); } /// Restore shapes from snapshots (used for cancel). pub(crate) fn restore_resize_from_snapshots(&mut self, snapshots: &[(ShapeId, ShapeSnapshot)]) { - let previous_bounds = self.selection_bounds(); + with_legacy_measurer(|measurer| { + self.restore_resize_from_snapshots_with(measurer, snapshots) + }) + } + + pub(crate) fn restore_resize_from_snapshots_with( + &mut self, + measurer: &TextMeasurer, + snapshots: &[(ShapeId, ShapeSnapshot)], + ) { + let previous_bounds = self.selection_bounds_with(measurer); let mut ids_to_invalidate = Vec::with_capacity(snapshots.len()); { @@ -103,9 +138,9 @@ impl InputState { } self.mark_selection_dirty_region(previous_bounds); - self.mark_selection_dirty_region(self.selection_bounds()); + self.mark_selection_dirty_region(self.selection_bounds_with(measurer)); for shape_id in ids_to_invalidate { - self.invalidate_hit_cache_for(shape_id); + self.invalidate_hit_cache_for_with(measurer, shape_id); } self.needs_redraw = true; } diff --git a/src/input/state/core/selection_actions/spotlight.rs b/src/input/state/core/selection_actions/spotlight.rs index 68e85ef4f..3e32781ba 100644 --- a/src/input/state/core/selection_actions/spotlight.rs +++ b/src/input/state/core/selection_actions/spotlight.rs @@ -1,4 +1,5 @@ use crate::draw::{Shape, ShapeId}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; use crate::util::Rect; @@ -156,6 +157,13 @@ impl InputState { /// was just derived from is a message chain waiting to disagree with the /// knob position. pub(crate) fn selected_spotlight_control(&self) -> Option { + with_legacy_measurer(|measurer| self.selected_spotlight_control_with(measurer)) + } + + pub(crate) fn selected_spotlight_control_with( + &self, + measurer: &TextMeasurer, + ) -> Option { let ids = self.selected_shape_ids(); if ids.len() != 1 { return None; @@ -168,7 +176,7 @@ impl InputState { let Shape::Spotlight { magnification, .. } = drawn.shape else { return None; }; - let bounds = drawn.bounding_box()?; + let bounds = drawn.bounding_box_with(measurer)?; // Canvas coordinates, so the clamp survives pan and zoom. let track = spotlight_magnification_track(bounds, magnification, Some(self.visible_canvas_rect()))?; @@ -185,18 +193,30 @@ impl InputState { /// loupe's bounding box, which magnification does not move, so the mapping /// is stable for the whole gesture. pub(crate) fn drag_spotlight_magnification_to(&mut self, x: i32) -> bool { + with_legacy_measurer(|measurer| self.drag_spotlight_magnification_to_with(measurer, x)) + } + + pub(crate) fn drag_spotlight_magnification_to_with( + &mut self, + measurer: &TextMeasurer, + x: i32, + ) -> bool { let crate::input::state::DrawingState::AdjustingSpotlightMagnification { shape_id, .. } = self.state else { return false; }; - let Some(control) = self.selected_spotlight_control() else { + let Some(control) = self.selected_spotlight_control_with(measurer) else { return false; }; if control.shape_id != shape_id { return false; } - self.set_spotlight_shape_magnification(shape_id, control.track.magnification_at(x)) + self.set_spotlight_shape_magnification_with( + measurer, + shape_id, + control.track.magnification_at(x), + ) } /// Whether the pointer is on the magnification control, and which loupe it @@ -206,7 +226,16 @@ impl InputState { x: i32, y: i32, ) -> Option { - let control = self.selected_spotlight_control()?; + with_legacy_measurer(|measurer| self.hit_spotlight_magnification_track_with(measurer, x, y)) + } + + pub(crate) fn hit_spotlight_magnification_track_with( + &self, + measurer: &TextMeasurer, + x: i32, + y: i32, + ) -> Option { + let control = self.selected_spotlight_control_with(measurer)?; let tolerance = self.hit_test_tolerance().ceil() as i32; let hit = control .track diff --git a/src/input/state/core/selection_actions/state.rs b/src/input/state/core/selection_actions/state.rs index 8495c34d1..a4042d004 100644 --- a/src/input/state/core/selection_actions/state.rs +++ b/src/input/state/core/selection_actions/state.rs @@ -1,12 +1,21 @@ use super::super::base::InputState; use crate::draw::DirtyFullReason; use crate::draw::frame::{ShapeSnapshot, UndoAction}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::util::Rect; const SELECTION_DAMAGE_PADDING: i32 = 8; impl InputState { pub(crate) fn set_selection_locked(&mut self, locked: bool) -> bool { + with_legacy_measurer(|measurer| self.set_selection_locked_with(measurer, locked)) + } + + pub(crate) fn set_selection_locked_with( + &mut self, + measurer: &TextMeasurer, + locked: bool, + ) -> bool { let ids_len = self.selected_shape_ids().len(); if ids_len == 0 { return false; @@ -39,8 +48,9 @@ impl InputState { if let Some((before, after, shape_for_dirty)) = result { actions.push(UndoAction::modify_from_snapshots(id, before, after)); - self.dirty_tracker.mark_shape(&shape_for_dirty); - self.invalidate_hit_cache_for(id); + self.dirty_tracker + .mark_shape_with(&shape_for_dirty, measurer); + self.invalidate_hit_cache_for_with(measurer, id); } } diff --git a/src/input/state/core/selection_actions/text/handles.rs b/src/input/state/core/selection_actions/text/handles.rs index 529fa024c..ea6a10573 100644 --- a/src/input/state/core/selection_actions/text/handles.rs +++ b/src/input/state/core/selection_actions/text/handles.rs @@ -1,4 +1,5 @@ use crate::draw::{Shape, ShapeId}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; use crate::util::Rect; @@ -15,6 +16,13 @@ impl InputState { } pub(crate) fn selected_text_resize_handle(&self) -> Option<(ShapeId, Rect)> { + with_legacy_measurer(|measurer| self.selected_text_resize_handle_with(measurer)) + } + + pub(crate) fn selected_text_resize_handle_with( + &self, + measurer: &TextMeasurer, + ) -> Option<(ShapeId, Rect)> { if self.selected_shape_ids().len() != 1 { return None; } @@ -27,13 +35,22 @@ impl InputState { if !matches!(shape.shape, Shape::Text { .. } | Shape::StickyNote { .. }) { return None; } - let bounds = shape.bounding_box()?; + let bounds = shape.bounding_box_with(measurer)?; let handle = Self::text_resize_handle_rect(bounds)?; Some((shape_id, handle)) } pub(crate) fn hit_text_resize_handle(&self, x: i32, y: i32) -> Option { - let (shape_id, handle) = self.selected_text_resize_handle()?; + with_legacy_measurer(|measurer| self.hit_text_resize_handle_with(measurer, x, y)) + } + + pub(crate) fn hit_text_resize_handle_with( + &self, + measurer: &TextMeasurer, + x: i32, + y: i32, + ) -> Option { + let (shape_id, handle) = self.selected_text_resize_handle_with(measurer)?; let tolerance = self.hit_test_tolerance().ceil() as i32; let hit_rect = handle.inflated(tolerance).unwrap_or(handle); if hit_rect.contains(x, y) { diff --git a/src/input/state/core/selection_actions/text/wrap.rs b/src/input/state/core/selection_actions/text/wrap.rs index 19bb56b0c..34ceb3c32 100644 --- a/src/input/state/core/selection_actions/text/wrap.rs +++ b/src/input/state/core/selection_actions/text/wrap.rs @@ -1,4 +1,5 @@ use crate::draw::{Shape, ShapeId}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; const TEXT_WRAP_MIN_WIDTH: i32 = 40; @@ -21,13 +22,24 @@ impl InputState { } pub(crate) fn update_text_wrap_width(&mut self, shape_id: ShapeId, new_width: i32) -> bool { + with_legacy_measurer(|measurer| { + self.update_text_wrap_width_with(measurer, shape_id, new_width) + }) + } + + pub(crate) fn update_text_wrap_width_with( + &mut self, + measurer: &TextMeasurer, + shape_id: ShapeId, + new_width: i32, + ) -> bool { let updated = { let frame = self.boards.active_frame_mut(); if let Some(shape) = frame.shape_mut(shape_id) { if shape.locked { return false; } - let before = shape.bounding_box(); + let before = shape.bounding_box_with(measurer); match &mut shape.shape { Shape::Text { wrap_width, .. } | Shape::StickyNote { wrap_width, .. } => { if *wrap_width == Some(new_width) { @@ -38,7 +50,7 @@ impl InputState { _ => return false, } shape.invalidate_bounds(); - let after = shape.bounding_box(); + let after = shape.bounding_box_with(measurer); Some((before, after)) } else { None @@ -48,7 +60,7 @@ impl InputState { if let Some((before, after)) = updated { self.mark_selection_dirty_region(before); self.mark_selection_dirty_region(after); - self.invalidate_hit_cache_for(shape_id); + self.invalidate_hit_cache_for_with(measurer, shape_id); self.needs_redraw = true; true } else { diff --git a/src/input/state/core/selection_actions/translation/bounds.rs b/src/input/state/core/selection_actions/translation/bounds.rs index 874a277f0..7fd016ba6 100644 --- a/src/input/state/core/selection_actions/translation/bounds.rs +++ b/src/input/state/core/selection_actions/translation/bounds.rs @@ -1,9 +1,15 @@ +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; use crate::util::Rect; impl InputState { /// Returns the combined bounding box of all selected shapes (public for rendering). pub fn selection_bounds(&self) -> Option { + with_legacy_measurer(|measurer| self.selection_bounds_with(measurer)) + } + + /// Combined selection bounds using the supplied text measurement owner. + pub fn selection_bounds_with(&self, measurer: &TextMeasurer) -> Option { let ids = self.selected_shape_ids(); if ids.is_empty() { return None; @@ -18,7 +24,7 @@ impl InputState { for id in ids { if let Some(shape) = frame.shape(*id) - && let Some(bounds) = shape.bounding_box() + && let Some(bounds) = shape.bounding_box_with(measurer) { min_x = min_x.min(bounds.x); min_y = min_y.min(bounds.y); @@ -35,7 +41,7 @@ impl InputState { } } - pub(super) fn movable_selection_bounds(&self) -> Option { + pub(super) fn movable_selection_bounds(&self, measurer: &TextMeasurer) -> Option { let ids = self.selected_shape_ids(); if ids.is_empty() { return None; @@ -53,7 +59,7 @@ impl InputState { if shape.locked { continue; } - if let Some(bounds) = shape.bounding_box() { + if let Some(bounds) = shape.bounding_box_with(measurer) { min_x = min_x.min(bounds.x); min_y = min_y.min(bounds.y); max_x = max_x.max(bounds.x + bounds.width); @@ -85,8 +91,13 @@ impl InputState { delta.clamp(min_delta, max_delta) } - pub(super) fn clamp_selection_translation(&self, dx: i32, dy: i32) -> Option<(i32, i32)> { - let bounds = self.movable_selection_bounds()?; + pub(super) fn clamp_selection_translation( + &self, + measurer: &TextMeasurer, + dx: i32, + dy: i32, + ) -> Option<(i32, i32)> { + let bounds = self.movable_selection_bounds(measurer)?; let (screen_width, screen_height) = self.view.screen_size(); let screen_width = screen_width.min(i32::MAX as u32) as i32; let screen_height = screen_height.min(i32::MAX as u32) as i32; diff --git a/src/input/state/core/selection_actions/translation/mod.rs b/src/input/state/core/selection_actions/translation/mod.rs index 2c3df1f24..f07f1bd31 100644 --- a/src/input/state/core/selection_actions/translation/mod.rs +++ b/src/input/state/core/selection_actions/translation/mod.rs @@ -1,5 +1,6 @@ use crate::draw::ShapeId; use crate::draw::frame::ShapeSnapshot; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; mod bounds; @@ -30,10 +31,19 @@ impl InputState { } pub(crate) fn apply_translation_to_selection(&mut self, dx: i32, dy: i32) -> bool { + with_legacy_measurer(|measurer| self.apply_translation_to_selection_with(measurer, dx, dy)) + } + + pub(crate) fn apply_translation_to_selection_with( + &mut self, + measurer: &TextMeasurer, + dx: i32, + dy: i32, + ) -> bool { if dx == 0 && dy == 0 { return false; } - let (dx, dy) = match self.clamp_selection_translation(dx, dy) { + let (dx, dy) = match self.clamp_selection_translation(measurer, dx, dy) { Some((dx, dy)) => (dx, dy), None => return false, }; @@ -54,10 +64,10 @@ impl InputState { if shape.locked { None } else { - let before = shape.bounding_box(); + let before = shape.bounding_box_with(measurer); shape.shape.translate(dx, dy); shape.invalidate_bounds(); - let after = shape.bounding_box(); + let after = shape.bounding_box_with(measurer); Some((before, after)) } } else { @@ -68,7 +78,7 @@ impl InputState { if let Some((before_bounds, after_bounds)) = bounds { self.mark_selection_dirty_region(before_bounds); self.mark_selection_dirty_region(after_bounds); - self.invalidate_hit_cache_for(id); + self.invalidate_hit_cache_for_with(measurer, id); moved_any = true; } } @@ -80,6 +90,15 @@ impl InputState { } pub(crate) fn translate_selection_with_undo(&mut self, dx: i32, dy: i32) -> bool { + with_legacy_measurer(|measurer| self.translate_selection_with_undo_with(measurer, dx, dy)) + } + + pub(crate) fn translate_selection_with_undo_with( + &mut self, + measurer: &TextMeasurer, + dx: i32, + dy: i32, + ) -> bool { if dx == 0 && dy == 0 { return false; } @@ -87,7 +106,7 @@ impl InputState { if before.is_empty() { return false; } - if !self.apply_translation_to_selection(dx, dy) { + if !self.apply_translation_to_selection_with(measurer, dx, dy) { return false; } self.push_translation_undo(before); @@ -95,7 +114,17 @@ impl InputState { } pub(crate) fn move_selection_to_horizontal_edge(&mut self, to_start: bool) -> bool { - let Some(bounds) = self.movable_selection_bounds() else { + with_legacy_measurer(|measurer| { + self.move_selection_to_horizontal_edge_with(measurer, to_start) + }) + } + + pub(crate) fn move_selection_to_horizontal_edge_with( + &mut self, + measurer: &TextMeasurer, + to_start: bool, + ) -> bool { + let Some(bounds) = self.movable_selection_bounds(measurer) else { return false; }; let screen_width = self.view.screen_width().min(i32::MAX as u32) as i32; @@ -112,11 +141,21 @@ impl InputState { if dx == 0 { return false; } - self.translate_selection_with_undo(dx, 0) + self.translate_selection_with_undo_with(measurer, dx, 0) } pub(crate) fn move_selection_to_vertical_edge(&mut self, to_start: bool) -> bool { - let Some(bounds) = self.movable_selection_bounds() else { + with_legacy_measurer(|measurer| { + self.move_selection_to_vertical_edge_with(measurer, to_start) + }) + } + + pub(crate) fn move_selection_to_vertical_edge_with( + &mut self, + measurer: &TextMeasurer, + to_start: bool, + ) -> bool { + let Some(bounds) = self.movable_selection_bounds(measurer) else { return false; }; let screen_height = self.view.screen_height().min(i32::MAX as u32) as i32; @@ -133,6 +172,6 @@ impl InputState { if dy == 0 { return false; } - self.translate_selection_with_undo(0, dy) + self.translate_selection_with_undo_with(measurer, 0, dy) } } diff --git a/src/input/state/core/selection_actions/translation/restore.rs b/src/input/state/core/selection_actions/translation/restore.rs index 4ea6d8dbf..cbf37a0d3 100644 --- a/src/input/state/core/selection_actions/translation/restore.rs +++ b/src/input/state/core/selection_actions/translation/restore.rs @@ -1,11 +1,22 @@ use crate::draw::ShapeId; use crate::draw::frame::ShapeSnapshot; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; impl InputState { pub(crate) fn restore_selection_from_snapshots( &mut self, snapshots: Vec<(ShapeId, ShapeSnapshot)>, + ) { + with_legacy_measurer(|measurer| { + self.restore_selection_from_snapshots_with(measurer, snapshots) + }) + } + + pub(crate) fn restore_selection_from_snapshots_with( + &mut self, + measurer: &TextMeasurer, + snapshots: Vec<(ShapeId, ShapeSnapshot)>, ) { if snapshots.is_empty() { return; @@ -15,10 +26,10 @@ impl InputState { let bounds = { let frame = self.boards.active_frame_mut(); if let Some(shape) = frame.shape_mut(shape_id) { - let before = shape.bounding_box(); + let before = shape.bounding_box_with(measurer); shape.set_shape(snapshot.shape); shape.locked = snapshot.locked; - let after = shape.bounding_box(); + let after = shape.bounding_box_with(measurer); Some((before, after)) } else { None @@ -27,7 +38,7 @@ impl InputState { if let Some((before_bounds, after_bounds)) = bounds { self.mark_selection_dirty_region(before_bounds); self.mark_selection_dirty_region(after_bounds); - self.invalidate_hit_cache_for(shape_id); + self.invalidate_hit_cache_for_with(measurer, shape_id); } } self.needs_redraw = true; diff --git a/src/input/state/spotlight.rs b/src/input/state/spotlight.rs index bc5b74591..4973926c2 100644 --- a/src/input/state/spotlight.rs +++ b/src/input/state/spotlight.rs @@ -4,7 +4,10 @@ //! layer and punches all the openings out of it. That makes spotlights the only //! shape kind the renderer collects up front instead of drawing in z-order. -use crate::draw::{Shape, ShapeId, SpotlightRegion, spotlight_regions_for_frame}; +use crate::draw::{ + Shape, ShapeId, SpotlightRegion, TextMeasurer, spotlight_regions_for_frame, + with_legacy_measurer, +}; use crate::input::Tool; use super::{DrawingState, InputState}; @@ -290,6 +293,17 @@ impl InputState { &mut self, shape_id: ShapeId, magnification: f64, + ) -> bool { + with_legacy_measurer(|measurer| { + self.set_spotlight_shape_magnification_with(measurer, shape_id, magnification) + }) + } + + pub(crate) fn set_spotlight_shape_magnification_with( + &mut self, + measurer: &TextMeasurer, + shape_id: ShapeId, + magnification: f64, ) -> bool { let normalized = crate::draw::normalize_spotlight_magnification(magnification); let frame = self.boards.active_frame_mut(); @@ -307,9 +321,9 @@ impl InputState { return false; } *current = normalized; - let bounds = drawn.bounding_box(); + let bounds = drawn.bounding_box_with(measurer); self.mark_selection_dirty_region(bounds); - self.invalidate_hit_cache_for(shape_id); + self.invalidate_hit_cache_for_with(measurer, shape_id); self.mark_session_dirty(); self.needs_redraw = true; true diff --git a/src/input/state/tests/hit_testing.rs b/src/input/state/tests/hit_testing.rs index 6346e671b..3a40bb052 100644 --- a/src/input/state/tests/hit_testing.rs +++ b/src/input/state/tests/hit_testing.rs @@ -41,13 +41,14 @@ fn indexed_state_with_two_overlapping_rects() -> InputState { #[test] fn explicit_hit_testing_rejects_invalid_tolerances() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); add_test_line(&mut state); for tolerance in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, f64::MAX] { assert!( state - .hit_test_all_for_points(&[(10, 0)], tolerance) + .hit_test_all_for_points_with(&measurer, &[(10, 0)], tolerance) .is_empty(), "invalid tolerance {tolerance:?} must fail closed" ); @@ -69,6 +70,7 @@ fn stored_hit_test_tolerance_is_always_valid() { #[test] fn extreme_persisted_rectangle_is_selectable_through_the_spatial_index() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.set_hit_test_threshold(1); let color = Color { @@ -99,7 +101,7 @@ fn extreme_persisted_rectangle_is_selectable_through_the_spatial_index() { assert_eq!(state.hit_test_at(i32::MIN, 10), Some(extreme_id)); assert!(state.has_spatial_index()); assert_eq!( - state.hit_test_all_for_points(&[(i32::MIN, 10)], 1.0), + state.hit_test_all_for_points_with(&measurer, &[(i32::MIN, 10)], 1.0), vec![extreme_id] ); } @@ -157,11 +159,17 @@ fn unchanged_spatial_hit_tests_build_shape_indices_once() { #[test] fn unchanged_spatial_multi_point_hit_tests_build_shape_indices_once() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = indexed_state_with_two_overlapping_rects(); InputState::reset_spatial_shape_index_build_count(); for _ in 0..100 { - assert_eq!(state.hit_test_all_for_points(&[(0, 10)], 1.0).len(), 2); + assert_eq!( + state + .hit_test_all_for_points_with(&measurer, &[(0, 10)], 1.0) + .len(), + 2 + ); } assert_eq!(InputState::spatial_shape_index_build_count(), 1); diff --git a/src/input/state/tests/selection/deletion.rs b/src/input/state/tests/selection/deletion.rs index ee6257fd1..cdad20297 100644 --- a/src/input/state/tests/selection/deletion.rs +++ b/src/input/state/tests/selection/deletion.rs @@ -12,7 +12,7 @@ fn delete_shapes_by_ids_ignores_missing_ids() { thick: state.style.current_thickness, }); - let removed = state.delete_shapes_by_ids(&[9999]); + let removed = state.delete_shapes_by_ids_with(&crate::draw::TextMeasurer::default(), &[9999]); assert!(!removed); assert_eq!(state.boards.active_frame().shapes.len(), 1); } From d7c964136b2e1fba2602149b461ebf6242d1ed11 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:26:12 +0200 Subject: [PATCH 14/42] refactor(text): share resources across feedback layout and painting --- .../wayland/state/render/measure_badge.rs | 18 +- src/backend/wayland/state/render/ui.rs | 44 +++- .../wayland/state/render/ui_effect_damage.rs | 47 ++++- src/ui.rs | 8 +- src/ui/measure_badge.rs | 92 ++++++++- src/ui/ocr_scan.rs | 109 ++++++++-- src/ui/primitives.rs | 32 +-- src/ui/status/badges.rs | 109 +++++++++- src/ui/status/badges/tests.rs | 85 ++++++++ src/ui/status/mod.rs | 5 + src/ui/toasts.rs | 189 +++++++----------- src/ui/toasts/layout.rs | 120 +++++++++++ src/ui/toasts/tests/engine.rs | 94 +++++++++ 13 files changed, 746 insertions(+), 206 deletions(-) create mode 100644 src/ui/status/badges/tests.rs create mode 100644 src/ui/toasts/layout.rs create mode 100644 src/ui/toasts/tests/engine.rs diff --git a/src/backend/wayland/state/render/measure_badge.rs b/src/backend/wayland/state/render/measure_badge.rs index ca540ad80..58e596efe 100644 --- a/src/backend/wayland/state/render/measure_badge.rs +++ b/src/backend/wayland/state/render/measure_badge.rs @@ -1,6 +1,5 @@ use super::super::*; use crate::ui::theme; -use crate::ui_text::text_layout; impl WaylandState { pub(super) fn shape_measure_badge_visual( @@ -12,6 +11,7 @@ impl WaylandState { let world = self.canvas_world_coords(pointer.0 as f64, pointer.1 as f64); let size = self.input_state.provisional_shape_size(world.0, world.1)?; crate::ui::measure_shape_badge( + self.render.ui_text(), self.config.ui.show_shape_size_readout, size, (pointer.0 as f64, pointer.1 as f64), @@ -42,13 +42,15 @@ impl WaylandState { theme::set_color(ctx, (1.0, 1.0, 1.0, 1.0)); ctx.rectangle(x, y, badge_width, badge_height); ctx.clip(); - text_layout( - ctx, - crate::ui::shape_measure_badge_text_style(), - &badge.text, - None, - ) - .show_at_baseline(ctx, badge.baseline.0, badge.baseline.1); + self.render + .ui_text() + .layout( + ctx, + crate::ui::shape_measure_badge_text_style(), + &badge.text, + None, + ) + .show_at_baseline(ctx, badge.baseline.0, badge.baseline.1); let _ = ctx.restore(); } diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 8dac69966..5030ec5de 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -97,7 +97,7 @@ impl WaylandState { && !status_visible && fallback_visible { - crate::ui::render_frozen_badge(ctx, width, height); + crate::ui::render_frozen_badge_with_engine(self.render.ui_text(), ctx, width, height); } let mut offset = 0.0; if self.input_state.zoom_active() @@ -105,7 +105,8 @@ impl WaylandState { && !self.zoom_chip_visible() && fallback_visible { - offset += crate::ui::render_zoom_badge( + offset += crate::ui::render_zoom_badge_with_engine( + self.render.ui_text(), ctx, width, height, @@ -119,7 +120,8 @@ impl WaylandState { && !status_visible && fallback_visible { - offset += crate::ui::render_pan_badge( + offset += crate::ui::render_pan_badge_with_engine( + self.render.ui_text(), ctx, width, height, @@ -132,7 +134,13 @@ impl WaylandState { && !status_visible && fallback_visible { - crate::ui::render_editing_badge(ctx, width, height, offset); + crate::ui::render_editing_badge_with_engine( + self.render.ui_text(), + ctx, + width, + height, + offset, + ); } } @@ -144,7 +152,8 @@ impl WaylandState { capture_picker: bool, ) { if !capture_picker && self.input_state.floating_badge_visible() { - crate::ui::render_page_badge( + crate::ui::render_page_badge_with_engine( + self.render.ui_text(), ctx, width, height, @@ -296,14 +305,26 @@ impl WaylandState { } else { self.input_state.clear_radial_menu_layout(); } - let toast_geometry = crate::ui::render_ui_toast(ctx, &self.input_state, width, height); + let toast_geometry = crate::ui::render_ui_toast_with_engine( + self.render.ui_text(), + ctx, + &self.input_state, + width, + height, + ); self.input_state.set_toast_geometry( toast_geometry.map(|geometry| geometry.0), toast_geometry .map(|geometry| geometry.1) .unwrap_or([None, None]), ); - crate::ui::render_preset_toast(ctx, &self.input_state, width, height); + crate::ui::render_preset_toast_with_engine( + self.render.ui_text(), + ctx, + &self.input_state, + width, + height, + ); crate::ui::render_blocked_feedback(ctx, &self.input_state, width, height); } @@ -374,7 +395,14 @@ impl WaylandState { }; let now = std::time::Instant::now(); if let Some((outcome, shown)) = scan.result(now) { - crate::ui::render_ocr_scan_result(ctx, scan.region(), outcome, shown, (width, height)); + crate::ui::render_ocr_scan_result( + self.render.ui_text(), + ctx, + scan.region(), + outcome, + shown, + (width, height), + ); } else if let Some(progress) = scan.sweep_progress(now) { crate::ui::render_ocr_scan_sweep(ctx, scan.region(), progress); } else if scan.is_scanning() { diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index 1800dbb97..80c4932bf 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -88,8 +88,13 @@ impl WaylandState { let mut regions = Vec::new(); let toast_rect = if flags.active(UiEffect::UiToast) { - crate::ui::ui_toast_geometry(&self.input_state, width, height) - .and_then(|bounds| effect_rect(bounds, width, height)) + crate::ui::ui_toast_geometry_with_engine( + self.render.ui_text(), + &self.input_state, + width, + height, + ) + .and_then(|bounds| effect_rect(bounds, width, height)) } else { None }; @@ -98,8 +103,13 @@ impl WaylandState { .roll(UiEffect::UiToast, toast_rect, &mut regions); let preset_rect = if flags.active(UiEffect::PresetToast) { - crate::ui::preset_toast_geometry(&self.input_state, width, height) - .and_then(|bounds| effect_rect(bounds, width, height)) + crate::ui::preset_toast_geometry_with_engine( + self.render.ui_text(), + &self.input_state, + width, + height, + ) + .and_then(|bounds| effect_rect(bounds, width, height)) } else { None }; @@ -264,7 +274,12 @@ impl WaylandState { .result(std::time::Instant::now()) .map(|(outcome, _)| outcome); effect_rect( - crate::ui::ocr_scan_geometry(scan.region(), outcome, (width, height)), + crate::ui::ocr_scan_geometry( + self.render.ui_text(), + scan.region(), + outcome, + (width, height), + ), width, height, ) @@ -340,10 +355,24 @@ mod tests { #[test] fn shape_badge_damage_unions_appear_move_and_disappear_footprints() { - let first = crate::ui::measure_shape_badge(true, (20, 30), (100.0, 100.0), 800, 600) - .and_then(|badge| effect_rect(badge.bounds, 800, 600)); - let second = crate::ui::measure_shape_badge(true, (200, 300), (300.0, 250.0), 800, 600) - .and_then(|badge| effect_rect(badge.bounds, 800, 600)); + let first = crate::ui::measure_shape_badge( + &crate::ui_text::UiTextEngine::default(), + true, + (20, 30), + (100.0, 100.0), + 800, + 600, + ) + .and_then(|badge| effect_rect(badge.bounds, 800, 600)); + let second = crate::ui::measure_shape_badge( + &crate::ui_text::UiTextEngine::default(), + true, + (200, 300), + (300.0, 250.0), + 800, + 600, + ) + .and_then(|badge| effect_rect(badge.bounds, 800, 600)); let mut damage = Vec::new(); push_effect_damage(&mut damage, None, first); diff --git a/src/ui.rs b/src/ui.rs index f79162e53..598cac804 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -81,12 +81,18 @@ pub use status::{ }; pub(crate) use status::{ compute_status_hud_layout_with_engine, compute_zoom_chip_layout_with_engine, - render_status_bar_with_resources, render_zoom_chip_with_resources, + render_editing_badge_with_engine, render_frozen_badge_with_engine, + render_page_badge_with_engine, render_pan_badge_with_engine, render_status_bar_with_resources, + render_zoom_badge_with_engine, render_zoom_chip_with_resources, }; pub use toasts::{ blocked_feedback_rects, preset_toast_geometry, render_blocked_feedback, render_preset_toast, render_ui_toast, ui_toast_geometry, }; +pub(crate) use toasts::{ + preset_toast_geometry_with_engine, render_preset_toast_with_engine, + render_ui_toast_with_engine, ui_toast_geometry_with_engine, +}; pub use tour::render_tour; #[cfg(test)] diff --git a/src/ui/measure_badge.rs b/src/ui/measure_badge.rs index f8f809585..bc86a05f9 100644 --- a/src/ui/measure_badge.rs +++ b/src/ui/measure_badge.rs @@ -1,6 +1,6 @@ //! Pure layout for the live rectangle/ellipse size readout. -use crate::ui_text::{UiTextStyle, measure_text}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; const FONT_SIZE: f64 = 12.0; const PADDING_X: f64 = 8.0; @@ -28,6 +28,7 @@ pub(crate) fn shape_measure_badge_text_style() -> UiTextStyle<'static> { /// edges. The displayed dimensions are logical canvas pixels, independent of /// output scale and the current zoom transform. pub(crate) fn measure_shape_badge( + engine: &UiTextEngine, enabled: bool, size: (u32, u32), pointer: (f64, f64), @@ -38,7 +39,7 @@ pub(crate) fn measure_shape_badge( return None; } let text = format!("{} × {}", size.0, size.1); - let extents = measure_text(shape_measure_badge_text_style(), &text, None)?; + let extents = engine.measure(shape_measure_badge_text_style(), &text, None)?; let width = (extents.width() + PADDING_X * 2.0) .min((screen_width as f64 - SCREEN_MARGIN * 2.0).max(0.0)); let height = BADGE_HEIGHT.min((screen_height as f64 - SCREEN_MARGIN * 2.0).max(0.0)); @@ -75,10 +76,24 @@ mod tests { #[test] fn badge_text_uses_logical_width_and_height() { - let badge = measure_shape_badge(true, (120, 80), (100.0, 100.0), 1920, 1080) - .expect("text measurement"); - let zero = measure_shape_badge(true, (0, 0), (100.0, 100.0), 1920, 1080) - .expect("text measurement"); + let badge = measure_shape_badge( + &UiTextEngine::default(), + true, + (120, 80), + (100.0, 100.0), + 1920, + 1080, + ) + .expect("text measurement"); + let zero = measure_shape_badge( + &UiTextEngine::default(), + true, + (0, 0), + (100.0, 100.0), + 1920, + 1080, + ) + .expect("text measurement"); assert_eq!(badge.text, "120 × 80"); assert_eq!(zero.text, "0 × 0"); @@ -86,13 +101,23 @@ mod tests { #[test] fn disabled_badge_has_no_visual() { - assert!(measure_shape_badge(false, (120, 80), (100.0, 100.0), 1920, 1080).is_none()); + assert!( + measure_shape_badge( + &UiTextEngine::default(), + false, + (120, 80), + (100.0, 100.0), + 1920, + 1080 + ) + .is_none() + ); } #[test] fn badge_handles_every_horizontal_and_vertical_flip_combination() { let layout = |pointer| { - measure_shape_badge(true, (120, 80), pointer, 400, 300) + measure_shape_badge(&UiTextEngine::default(), true, (120, 80), pointer, 400, 300) .expect("text measurement") .bounds }; @@ -111,12 +136,57 @@ mod tests { #[test] fn badge_clamps_to_a_tiny_surface() { - let (x, y, width, height) = measure_shape_badge(true, (3840, 2160), (2.0, 2.0), 80, 20) - .expect("text measurement") - .bounds; + let (x, y, width, height) = measure_shape_badge( + &UiTextEngine::default(), + true, + (3840, 2160), + (2.0, 2.0), + 80, + 20, + ) + .expect("text measurement") + .bounds; assert_eq!((x, y), (SCREEN_MARGIN, SCREEN_MARGIN)); assert_eq!(width, 68.0); assert_eq!(height, 8.0); } + + #[test] + fn retained_measurement_owner_keeps_geometry_after_scaled_label_paint() { + let engine = UiTextEngine::default(); + for size in [(120, 80), (3840, 2160), (120, 80)] { + let badge = measure_shape_badge(&engine, true, size, (100.0, 100.0), 800, 600).unwrap(); + for density in [1, 2, 1] { + let paint = |owner: &UiTextEngine| { + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + 800 * density, + 600 * density, + ) + .unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + owner + .layout(&ctx, shape_measure_badge_text_style(), &badge.text, None) + .show_at_baseline(&ctx, badge.baseline.0, badge.baseline.1); + } + surface.flush(); + surface.data().unwrap().to_vec() + }; + let retained = paint(&engine); + assert!( + retained == paint(&UiTextEngine::default()), + "size {size:?} differs at density {density}" + ); + assert!(retained.iter().any(|byte| *byte != 0)); + let repeated = + measure_shape_badge(&engine, true, size, (100.0, 100.0), 800, 600).unwrap(); + assert_eq!(repeated.bounds, badge.bounds); + assert_eq!(repeated.baseline, badge.baseline); + assert_eq!(repeated.text, badge.text); + } + } + } } diff --git a/src/ui/ocr_scan.rs b/src/ui/ocr_scan.rs index 936a01d32..3ae3d2c43 100644 --- a/src/ui/ocr_scan.rs +++ b/src/ui/ocr_scan.rs @@ -3,7 +3,7 @@ use crate::ui::theme::{self, Rgba, overlay}; use crate::util::Rect; use super::primitives::draw_rounded_rect; -use crate::ui_text::{UiTextStyle, measure_text, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; /// Tint held over the region for the whole sweep, so the scanned area stays /// identifiable even at the moment the band is off its top edge. @@ -54,12 +54,12 @@ fn detail_style() -> UiTextStyle<'static> { /// Card text block size, measured without a drawing context so damage /// geometry and the drawn card cannot disagree. Both go through the shared /// measurement cache. -fn card_text_size(outcome: OcrScanOutcome) -> Option<(f64, f64)> { - let headline = measure_text(headline_style(), outcome.headline(), None)?; +fn card_text_size(engine: &UiTextEngine, outcome: OcrScanOutcome) -> Option<(f64, f64)> { + let headline = engine.measure(headline_style(), outcome.headline(), None)?; let Some(detail) = outcome.detail() else { return Some((headline.width(), headline.height())); }; - let detail = measure_text(detail_style(), &detail, None)?; + let detail = engine.measure(detail_style(), &detail, None)?; Some(( headline.width().max(detail.width()), headline.height() + CARD_GAP + detail.height(), @@ -69,11 +69,12 @@ fn card_text_size(outcome: OcrScanOutcome) -> Option<(f64, f64)> { /// Place the outcome card just under the scanned region, flipping above it when /// there is no room and clamping onto the surface either way. pub(crate) fn ocr_scan_card( + engine: &UiTextEngine, region: Rect, outcome: OcrScanOutcome, screen: (u32, u32), ) -> Option { - let (text_width, text_height) = card_text_size(outcome)?; + let (text_width, text_height) = card_text_size(engine, outcome)?; let screen_width = f64::from(screen.0); let screen_height = f64::from(screen.1); let width = (text_width + CARD_PAD * 2.0).min((screen_width - CARD_MARGIN * 2.0).max(1.0)); @@ -102,6 +103,7 @@ pub(crate) fn ocr_scan_card( /// Union of everything the overlay paints, for targeted damage. pub(crate) fn ocr_scan_geometry( + engine: &UiTextEngine, region: Rect, outcome: Option, screen: (u32, u32), @@ -112,7 +114,8 @@ pub(crate) fn ocr_scan_geometry( f64::from(region.width) + FRAME_WIDTH * 2.0, f64::from(region.height) + FRAME_WIDTH * 2.0, ); - let Some(card) = outcome.and_then(|outcome| ocr_scan_card(region, outcome, screen)) else { + let Some(card) = outcome.and_then(|outcome| ocr_scan_card(engine, region, outcome, screen)) + else { return region_box; }; let left = region_box.0.min(card.x); @@ -198,6 +201,7 @@ fn draw_frame(ctx: &cairo::Context, x: f64, y: f64, width: f64, height: f64, alp /// The outcome card. `shown` is how long it has been up, which drives its fade. pub(crate) fn render_ocr_scan_result( + engine: &UiTextEngine, ctx: &cairo::Context, region: Rect, outcome: OcrScanOutcome, @@ -208,7 +212,7 @@ pub(crate) fn render_ocr_scan_result( if opacity <= 0.0 { return; } - let Some(card) = ocr_scan_card(region, outcome, screen) else { + let Some(card) = ocr_scan_card(engine, region, outcome, screen) else { return; }; let _ = ctx.save(); @@ -241,7 +245,7 @@ pub(crate) fn render_ocr_scan_result( let _ = ctx.save(); ctx.rectangle(card.x, card.y, card.width, card.height); ctx.clip(); - let headline = text_layout(ctx, headline_style(), outcome.headline(), None); + let headline = engine.layout(ctx, headline_style(), outcome.headline(), None); let headline_extents = headline.ink_extents(); theme::set_color(ctx, overlay::TEXT_PRIMARY); headline.show_at_baseline( @@ -250,7 +254,7 @@ pub(crate) fn render_ocr_scan_result( card.y + CARD_PAD - headline_extents.y_bearing(), ); if let Some(detail) = outcome.detail() { - let layout = text_layout(ctx, detail_style(), &detail, None); + let layout = engine.layout(ctx, detail_style(), &detail, None); let extents = layout.ink_extents(); theme::set_color(ctx, overlay::TEXT_TERTIARY); layout.show_at_baseline( @@ -334,14 +338,16 @@ mod tests { #[test] fn the_card_sits_under_the_region_and_flips_above_it_when_it_must() { - let below = ocr_scan_card(region(), copied(), (320, 400)).expect("a card"); + let below = ocr_scan_card(&UiTextEngine::default(), region(), copied(), (320, 400)) + .expect("a card"); assert!( below.y >= f64::from(region().y + region().height), "the usual place is under the scanned area" ); let low = Rect::new(60, 300, 200, 90).expect("a low region"); - let flipped = ocr_scan_card(low, copied(), (320, 400)).expect("a card"); + let flipped = + ocr_scan_card(&UiTextEngine::default(), low, copied(), (320, 400)).expect("a card"); assert!( flipped.y + flipped.height <= f64::from(low.y), "no room below, so it goes above" @@ -351,7 +357,8 @@ mod tests { #[test] fn a_card_with_nowhere_to_go_is_still_placed_on_the_surface() { let full = Rect::new(0, 0, 200, 200).expect("a full-surface region"); - let card = ocr_scan_card(full, copied(), (200, 200)).expect("a card"); + let card = + ocr_scan_card(&UiTextEngine::default(), full, copied(), (200, 200)).expect("a card"); assert!(card.x >= 0.0 && card.y >= 0.0); assert!(card.x + card.width <= 200.0); assert!(card.y + card.height <= 200.0); @@ -359,13 +366,19 @@ mod tests { #[test] fn damage_covers_the_region_alone_while_scanning_and_the_card_once_settled() { - let scanning = ocr_scan_geometry(region(), None, (320, 400)); + let scanning = ocr_scan_geometry(&UiTextEngine::default(), region(), None, (320, 400)); assert!(scanning.0 <= f64::from(region().x)); assert!(scanning.1 <= f64::from(region().y)); assert!(scanning.0 + scanning.2 >= f64::from(region().x + region().width)); - let settled = ocr_scan_geometry(region(), Some(copied()), (320, 400)); - let card = ocr_scan_card(region(), copied(), (320, 400)).expect("a card"); + let settled = ocr_scan_geometry( + &UiTextEngine::default(), + region(), + Some(copied()), + (320, 400), + ); + let card = ocr_scan_card(&UiTextEngine::default(), region(), copied(), (320, 400)) + .expect("a card"); assert!( settled.1 + settled.3 >= card.y + card.height, "the union has to reach the card or its pixels are never cleared" @@ -378,11 +391,19 @@ mod tests { let render = |shown: Duration| { let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 320, 400).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); - render_ocr_scan_result(&ctx, region(), copied(), shown, (320, 400)); + render_ocr_scan_result( + &UiTextEngine::default(), + &ctx, + region(), + copied(), + shown, + (320, 400), + ); drop(ctx); surface }; - let card = ocr_scan_card(region(), copied(), (320, 400)).expect("a card"); + let card = ocr_scan_card(&UiTextEngine::default(), region(), copied(), (320, 400)) + .expect("a card"); let probe = ( (card.x + card.width / 2.0) as usize, (card.y + card.height / 2.0) as usize, @@ -398,4 +419,58 @@ mod tests { "an expired card paints nothing at all, whatever the motion setting" ); } + + #[test] + fn retained_ocr_owner_preserves_card_geometry_and_pixels_across_targets() { + let engine = UiTextEngine::default(); + for outcome in [ + copied(), + OcrScanOutcome::NoTextFound, + OcrScanOutcome::Failed, + OcrScanOutcome::Copied { + character_count: 1024, + replaced_invalid_utf8: true, + }, + ] { + let expected = ocr_scan_geometry(&engine, region(), Some(outcome), (320, 400)); + let card = ocr_scan_card(&engine, region(), outcome, (320, 400)).unwrap(); + assert!(expected.0 <= card.x && expected.1 <= card.y); + assert!(expected.0 + expected.2 >= card.x + card.width); + assert!(expected.1 + expected.3 >= card.y + card.height); + for density in [1, 2, 1] { + let paint = |owner: &UiTextEngine| { + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + 320 * density, + 400 * density, + ) + .unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + render_ocr_scan_result( + owner, + &ctx, + region(), + outcome, + Duration::ZERO, + (320, 400), + ); + } + surface.flush(); + surface.data().unwrap().to_vec() + }; + let retained = paint(&engine); + assert!( + retained == paint(&UiTextEngine::default()), + "OCR pixels differ for {outcome:?} at density {density}" + ); + assert!(retained.iter().any(|byte| *byte != 0)); + assert_eq!( + ocr_scan_geometry(&engine, region(), Some(outcome), (320, 400)), + expected + ); + } + } + } } diff --git a/src/ui/primitives.rs b/src/ui/primitives.rs index 8cb366aea..e0735996b 100644 --- a/src/ui/primitives.rs +++ b/src/ui/primitives.rs @@ -424,7 +424,7 @@ pub(crate) const BADGE_RADIUS: f64 = 8.0; /// Vertical gap between stacked floating badges. pub(crate) const BADGE_STACK_GAP: f64 = 8.0; -/// Horizontal anchoring for [`draw_badge`]. +/// Horizontal anchoring for [`draw_badge_with_engine`]. pub(crate) enum BadgeAlign { /// `anchor_x` is the badge's left edge. Left, @@ -433,7 +433,7 @@ pub(crate) enum BadgeAlign { } /// Badge box `(width, height, text_inset)` from measured label/hint extents. -/// Shared by [`draw_badge`] and [`measure_badge_with_engine`] so layout and rendering can +/// Shared by [`draw_badge_with_engine`] and [`measure_badge_with_engine`] so layout and rendering can /// never disagree about badge geometry. fn badge_box( label_extents: &crate::ui_text::UiTextExtents, @@ -454,7 +454,7 @@ fn badge_box( } } -/// Measure the `(width, height)` [`draw_badge`] would occupy, without a +/// Measure the `(width, height)` [`draw_badge_with_engine`] would occupy, without a /// rendering context (used for HUD badge stacking and damage geometry). pub(crate) fn measure_badge_with_engine( engine: &UiTextEngine, @@ -492,32 +492,6 @@ pub(crate) fn measure_badge_with_engine( /// Draw a rounded, tinted status badge with a bold `label` and an optional /// dimmer `(text, font_size)` hint line below it. Returns the measured badge /// height so callers can stack badges without hardcoding heights. -#[allow(clippy::too_many_arguments)] -pub(crate) fn draw_badge( - ctx: &cairo::Context, - anchor_x: f64, - top_y: f64, - align: BadgeAlign, - label: &str, - label_font_size: f64, - hint: Option<(&str, f64)>, - tint: [f64; 4], -) -> f64 { - with_legacy_engine(|engine| { - draw_badge_with_engine( - engine, - ctx, - anchor_x, - top_y, - align, - label, - label_font_size, - hint, - tint, - ) - }) -} - #[allow(clippy::too_many_arguments)] pub(crate) fn draw_badge_with_engine( engine: &UiTextEngine, diff --git a/src/ui/status/badges.rs b/src/ui/status/badges.rs index 418104790..f1c871013 100644 --- a/src/ui/status/badges.rs +++ b/src/ui/status/badges.rs @@ -1,5 +1,8 @@ -use super::super::primitives::{BADGE_PADDING, BADGE_STACK_GAP, BadgeAlign, draw_badge}; +use super::super::primitives::{ + BADGE_PADDING, BADGE_STACK_GAP, BadgeAlign, draw_badge_with_engine, +}; use super::super::theme::overlay; +use crate::ui_text::UiTextEngine; /// Vertical inset of the floating page badge from the screen edge. const PAGE_BADGE_EDGE_PADDING: f64 = overlay::SPACING_SM; @@ -55,7 +58,17 @@ pub(crate) fn pan_badge_label(panned: bool) -> &'static str { /// Render a small badge indicating frozen mode (visible even when status bar is hidden). pub fn render_frozen_badge(ctx: &cairo::Context, screen_width: u32, _screen_height: u32) { - draw_badge( + render_frozen_badge_with_engine(&UiTextEngine::default(), ctx, screen_width, _screen_height) +} + +pub(crate) fn render_frozen_badge_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + screen_width: u32, + _screen_height: u32, +) { + draw_badge_with_engine( + engine, ctx, screen_width as f64 - BADGE_PADDING, BADGE_PADDING, @@ -77,9 +90,28 @@ pub fn render_zoom_badge( _screen_height: u32, zoom_scale: f64, locked: bool, +) -> f64 { + render_zoom_badge_with_engine( + &UiTextEngine::default(), + ctx, + screen_width, + _screen_height, + zoom_scale, + locked, + ) +} + +pub(crate) fn render_zoom_badge_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + screen_width: u32, + _screen_height: u32, + zoom_scale: f64, + locked: bool, ) -> f64 { let label = zoom_badge_label(zoom_scale, locked); - let height = draw_badge( + let height = draw_badge_with_engine( + engine, ctx, screen_width as f64 - BADGE_PADDING, BADGE_PADDING, @@ -102,11 +134,30 @@ pub fn render_pan_badge( _screen_height: u32, panned: bool, offset_y: f64, +) -> f64 { + render_pan_badge_with_engine( + &UiTextEngine::default(), + ctx, + screen_width, + _screen_height, + panned, + offset_y, + ) +} + +pub(crate) fn render_pan_badge_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + screen_width: u32, + _screen_height: u32, + panned: bool, + offset_y: f64, ) -> f64 { // Same label as the HUD-stacked pill, uppercased for the top corner // (historic form; keeps the rendering visually unchanged). let label = pan_badge_label(panned).to_uppercase(); - let height = draw_badge( + let height = draw_badge_with_engine( + engine, ctx, screen_width as f64 - BADGE_PADDING, BADGE_PADDING + offset_y, @@ -126,7 +177,24 @@ pub fn render_editing_badge( _screen_height: u32, offset_y: f64, ) { - draw_badge( + render_editing_badge_with_engine( + &UiTextEngine::default(), + ctx, + screen_width, + _screen_height, + offset_y, + ) +} + +pub(crate) fn render_editing_badge_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + screen_width: u32, + _screen_height: u32, + offset_y: f64, +) { + draw_badge_with_engine( + engine, ctx, screen_width as f64 - BADGE_PADDING, BADGE_PADDING + offset_y, @@ -149,6 +217,31 @@ pub fn render_page_badge( board_name: &str, page_index: usize, page_count: usize, +) { + render_page_badge_with_engine( + &UiTextEngine::default(), + ctx, + _screen_width, + _screen_height, + board_index, + board_count, + board_name, + page_index, + page_count, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_page_badge_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + _screen_width: u32, + _screen_height: u32, + board_index: usize, + board_count: usize, + board_name: &str, + page_index: usize, + page_count: usize, ) { let truncated_name = crate::util::truncate_with_ellipsis(board_name, 20); let board_label = if !truncated_name.trim().is_empty() { @@ -178,7 +271,8 @@ pub fn render_page_badge( (None, Some(page)) => page, (None, None) => return, }; - draw_badge( + draw_badge_with_engine( + engine, ctx, BADGE_PADDING, PAGE_BADGE_EDGE_PADDING, @@ -190,3 +284,6 @@ pub fn render_page_badge( [0.2, 0.32, 0.45, 0.92], ); } + +#[cfg(test)] +mod tests; diff --git a/src/ui/status/badges/tests.rs b/src/ui/status/badges/tests.rs new file mode 100644 index 000000000..b8bbc5874 --- /dev/null +++ b/src/ui/status/badges/tests.rs @@ -0,0 +1,85 @@ +use super::*; + +fn paint(engine: &UiTextEngine, kind: usize, density: i32, standalone: bool) -> (Vec, f64) { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 640 * density, 480 * density).unwrap(); + let height; + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + height = match (kind, standalone) { + (0, false) => { + render_frozen_badge_with_engine(engine, &ctx, 640, 480); + 0.0 + } + (0, true) => { + render_frozen_badge(&ctx, 640, 480); + 0.0 + } + (1, false) => render_zoom_badge_with_engine(engine, &ctx, 640, 480, 2.25, true), + (1, true) => render_zoom_badge(&ctx, 640, 480, 2.25, true), + (2, false) => render_pan_badge_with_engine(engine, &ctx, 640, 480, true, 35.0), + (2, true) => render_pan_badge(&ctx, 640, 480, true, 35.0), + (3, false) => { + render_editing_badge_with_engine(engine, &ctx, 640, 480, 70.0); + 0.0 + } + (3, true) => { + render_editing_badge(&ctx, 640, 480, 70.0); + 0.0 + } + (4, false) => { + render_page_badge_with_engine( + engine, + &ctx, + 640, + 480, + 1, + 3, + "日本語 long board name beyond cutoff", + 2, + 5, + ); + 0.0 + } + (4, true) => { + render_page_badge( + &ctx, + 640, + 480, + 1, + 3, + "日本語 long board name beyond cutoff", + 2, + 5, + ); + 0.0 + } + _ => unreachable!(), + }; + } + surface.flush(); + (surface.data().unwrap().to_vec(), height) +} + +#[test] +fn fallback_badges_reuse_one_owner_and_preserve_pixels_and_stacking() { + let engine = UiTextEngine::default(); + for density in [1, 2, 1] { + for kind in 0..5 { + let (retained, height) = paint(&engine, kind, density, false); + let (fresh, fresh_height) = paint(&UiTextEngine::default(), kind, density, false); + let (standalone, standalone_height) = paint(&engine, kind, density, true); + assert!( + retained == fresh && retained == standalone, + "badge {kind} differs at density {density}" + ); + assert!(retained.iter().any(|byte| *byte != 0)); + assert_eq!(height, fresh_height); + assert_eq!(height, standalone_height); + if matches!(kind, 1 | 2) { + assert!(height > BADGE_STACK_GAP); + } + } + } +} diff --git a/src/ui/status/mod.rs b/src/ui/status/mod.rs index 5d051cb2f..1e8f4ed12 100644 --- a/src/ui/status/mod.rs +++ b/src/ui/status/mod.rs @@ -18,3 +18,8 @@ pub(crate) use zoom_chip::{compute_zoom_chip_layout_with_engine, render_zoom_chi #[cfg(test)] mod tests; + +pub(crate) use badges::{ + render_editing_badge_with_engine, render_frozen_badge_with_engine, + render_page_badge_with_engine, render_pan_badge_with_engine, render_zoom_badge_with_engine, +}; diff --git a/src/ui/toasts.rs b/src/ui/toasts.rs index 3ca83bc98..63c201dc2 100644 --- a/src/ui/toasts.rs +++ b/src/ui/toasts.rs @@ -1,5 +1,7 @@ +mod layout; use crate::input::InputState; use crate::input::state::{PRESET_TOAST_DURATION_MS, PresetFeedbackKind, UiToastKind}; +use layout::{toast_box_geometry, ui_toast_layout}; use std::time::Instant; use super::anim; @@ -8,7 +10,7 @@ use super::constants::{ TOAST_INFO, TOAST_SUCCESS, TOAST_WARNING, }; use super::primitives::draw_rounded_rect; -use crate::ui_text::{UiTextStyle, measure_text, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; /// Border width for blocked action feedback edge flash. const BLOCKED_FEEDBACK_BORDER: f64 = 6.0; @@ -34,13 +36,6 @@ const TOAST_WARNING_TEXT: (f64, f64, f64) = (0.07, 0.09, 0.15); type ToastBounds = (f64, f64, f64, f64); -#[derive(Debug)] -struct UiToastLayout { - bounds: ToastBounds, - action_bounds: [Option; 2], - message: String, -} - /// Overall toast bounds plus the bounds of up to two action chips. pub type UiToastRenderGeometry = (ToastBounds, [Option; 2]); @@ -61,23 +56,6 @@ fn ui_toast_fade(elapsed_secs: f64, duration_secs: f64) -> f64 { anim::end_fade(elapsed_secs, duration_secs, UI_TOAST_FADE_SECONDS) } -/// Box geometry for a toast label centered horizontally at a screen-height ratio. -fn toast_box_geometry( - label: &str, - font_size: f64, - screen_width: u32, - screen_height: u32, - y_ratio: f64, -) -> Option<(f64, f64, f64, f64)> { - let extents = measure_text(toast_text_style(font_size), label, None)?; - let width = extents.width() + TOAST_PADDING_X * 2.0; - let height = extents.height() + TOAST_PADDING_Y * 2.0; - let x = (screen_width as f64 - width) / 2.0; - let center_y = screen_height as f64 * y_ratio; - let y = center_y - height / 2.0; - Some((x, y, width, height)) -} - /// The most recent, still-animating preset feedback entry: (slot, kind, progress). fn latest_preset_feedback( input_state: &InputState, @@ -114,102 +92,29 @@ fn preset_feedback_label(slot: usize, kind: PresetFeedbackKind) -> String { } } -fn measured_width(text: &str) -> Option { - Some(measure_text(toast_text_style(UI_TOAST_FONT_SIZE), text, None)?.width()) -} - -fn ellipsize_to_width(text: &str, max_width: f64) -> Option { - if measured_width(text)? <= max_width { - return Some(text.to_string()); - } - const ELLIPSIS: &str = "…"; - if measured_width(ELLIPSIS)? > max_width { - return Some(String::new()); - } - for (end, _) in text.char_indices().rev() { - let candidate = format!("{}{}", text[..end].trim_end(), ELLIPSIS); - if measured_width(&candidate)? <= max_width { - return Some(candidate); - } - } - Some(ELLIPSIS.to_string()) -} - -fn ui_toast_layout( +/// On-screen bounds (x, y, width, height) the active UI toast occupies, without +/// rendering it. Used for damage tracking; measurement goes through the same +/// layout cache as rendering, so the two always agree. +pub fn ui_toast_geometry( input_state: &InputState, screen_width: u32, screen_height: u32, -) -> Option { - let toast = input_state.active_toast()?; - let actions = [toast.action.as_ref(), toast.secondary_action.as_ref()]; - let action_sizes = actions.map(|action| { - action.and_then(|action| { - let extents = measure_text(toast_text_style(UI_TOAST_FONT_SIZE), &action.label, None)?; - Some(( - extents.width() + TOAST_ACTION_PADDING_X * 2.0, - extents.height() + TOAST_ACTION_PADDING_Y * 2.0, - )) - }) - }); - let action_count = action_sizes.iter().flatten().count(); - let action_width = action_sizes - .iter() - .flatten() - .map(|size| size.0) - .sum::() - + TOAST_ACTION_GAP * action_count.saturating_sub(1) as f64; - let message_action_gap = if action_count > 0 { - TOAST_ACTION_GAP - } else { - 0.0 - }; - let max_box_width = (screen_width as f64 - TOAST_SCREEN_MARGIN * 2.0).max(1.0); - let max_message_width = - (max_box_width - TOAST_PADDING_X * 2.0 - action_width - message_action_gap).max(0.0); - let message = ellipsize_to_width(&toast.message, max_message_width)?; - let message_extents = measure_text(toast_text_style(UI_TOAST_FONT_SIZE), &message, None)?; - let content_width = message_extents.width() + message_action_gap + action_width; - let content_height = action_sizes - .iter() - .flatten() - .map(|size| size.1) - .fold(message_extents.height(), f64::max); - let width = (content_width + TOAST_PADDING_X * 2.0).min(max_box_width); - let height = content_height + TOAST_PADDING_Y * 2.0; - let x = (screen_width as f64 - width) / 2.0; - let y = screen_height as f64 * UI_TOAST_Y_RATIO - height / 2.0; - - let mut action_x = x + TOAST_PADDING_X + message_extents.width() + message_action_gap; - let mut action_bounds = [None, None]; - for (index, size) in action_sizes.into_iter().enumerate() { - let Some((action_width, action_height)) = size else { - continue; - }; - action_bounds[index] = Some(( - action_x, - y + (height - action_height) / 2.0, - action_width, - action_height, - )); - action_x += action_width + TOAST_ACTION_GAP; - } - - Some(UiToastLayout { - bounds: (x, y, width, height), - action_bounds, - message, - }) +) -> Option<(f64, f64, f64, f64)> { + ui_toast_geometry_with_engine( + &UiTextEngine::default(), + input_state, + screen_width, + screen_height, + ) } -/// On-screen bounds (x, y, width, height) the active UI toast occupies, without -/// rendering it. Used for damage tracking; measurement goes through the same -/// layout cache as rendering, so the two always agree. -pub fn ui_toast_geometry( +pub(crate) fn ui_toast_geometry_with_engine( + engine: &UiTextEngine, input_state: &InputState, screen_width: u32, screen_height: u32, ) -> Option<(f64, f64, f64, f64)> { - Some(ui_toast_layout(input_state, screen_width, screen_height)?.bounds) + Some(ui_toast_layout(engine, input_state, screen_width, screen_height)?.bounds) } /// On-screen bounds (x, y, width, height) of the active preset toast, without @@ -218,6 +123,20 @@ pub fn preset_toast_geometry( input_state: &InputState, screen_width: u32, screen_height: u32, +) -> Option<(f64, f64, f64, f64)> { + preset_toast_geometry_with_engine( + &UiTextEngine::default(), + input_state, + screen_width, + screen_height, + ) +} + +pub(crate) fn preset_toast_geometry_with_engine( + engine: &UiTextEngine, + input_state: &InputState, + screen_width: u32, + screen_height: u32, ) -> Option<(f64, f64, f64, f64)> { if !input_state.ui_visibility.show_preset_toasts { return None; @@ -225,6 +144,7 @@ pub fn preset_toast_geometry( let (slot, kind, _progress) = latest_preset_feedback(input_state, Instant::now())?; let label = preset_feedback_label(slot, kind); toast_box_geometry( + engine, &label, PRESET_TOAST_FONT_SIZE, screen_width, @@ -252,6 +172,22 @@ pub fn render_preset_toast( input_state: &InputState, screen_width: u32, screen_height: u32, +) { + render_preset_toast_with_engine( + &UiTextEngine::default(), + ctx, + input_state, + screen_width, + screen_height, + ) +} + +pub(crate) fn render_preset_toast_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + input_state: &InputState, + screen_width: u32, + screen_height: u32, ) { if !input_state.ui_visibility.show_preset_toasts { return; @@ -265,9 +201,10 @@ pub fn render_preset_toast( let radius = RADIUS_LG; let text_style = toast_text_style(PRESET_TOAST_FONT_SIZE); - let layout = text_layout(ctx, text_style, &label, None); + let layout = engine.layout(ctx, text_style, &label, None); let extents = layout.ink_extents(); let Some((x, y, width, height)) = toast_box_geometry( + engine, &label, PRESET_TOAST_FONT_SIZE, screen_width, @@ -301,6 +238,22 @@ pub fn render_ui_toast( input_state: &InputState, screen_width: u32, screen_height: u32, +) -> Option { + render_ui_toast_with_engine( + &UiTextEngine::default(), + ctx, + input_state, + screen_width, + screen_height, + ) +} + +pub(crate) fn render_ui_toast_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + input_state: &InputState, + screen_width: u32, + screen_height: u32, ) -> Option { let toast = input_state.active_toast()?; @@ -314,7 +267,7 @@ pub fn render_ui_toast( let padding_x = TOAST_PADDING_X; let radius = RADIUS_LG; - let layout = ui_toast_layout(input_state, screen_width, screen_height)?; + let layout = ui_toast_layout(engine, input_state, screen_width, screen_height)?; let (x, y, width, height) = layout.bounds; let text_style = toast_text_style(UI_TOAST_FONT_SIZE); @@ -398,7 +351,7 @@ pub fn render_ui_toast( // Draw the message. Action labels are measured independently so the // message can ellipsize while every chip remains whole and clickable. - let label_layout = text_layout(ctx, text_style, &layout.message, None); + let label_layout = engine.layout(ctx, text_style, &layout.message, None); let label_extents = label_layout.ink_extents(); let text_x = x + TOAST_PADDING_X - label_extents.x_bearing(); let text_y = y + (height - label_extents.height()) / 2.0 - label_extents.y_bearing(); @@ -422,7 +375,7 @@ pub fn render_ui_toast( draw_rounded_rect(ctx, btn_x, btn_y, btn_w, btn_h, RADIUS_SM); let _ = ctx.fill(); - let action_layout = text_layout(ctx, text_style, &action.label, None); + let action_layout = engine.layout(ctx, text_style, &action.label, None); let action_extents = action_layout.ink_extents(); let action_x = btn_x + (btn_w - action_extents.width()) / 2.0 - action_extents.x_bearing(); let action_y = btn_y + (btn_h - action_extents.height()) / 2.0 - action_extents.y_bearing(); @@ -494,7 +447,8 @@ mod tests { .secondary_action("Tip settings…", Action::OpenConfiguratorOnboardingHints), ); - let layout = ui_toast_layout(&state, 360, 720).expect("toast layout"); + let layout = + ui_toast_layout(&UiTextEngine::default(), &state, 360, 720).expect("toast layout"); assert!(layout.message.ends_with('…')); let first = layout.action_bounds[0].expect("first chip"); let second = layout.action_bounds[1].expect("second chip"); @@ -503,4 +457,5 @@ mod tests { assert!(layout.bounds.0 >= TOAST_SCREEN_MARGIN); assert!(layout.bounds.0 + layout.bounds.2 <= 360.0 - TOAST_SCREEN_MARGIN); } + mod engine; } diff --git a/src/ui/toasts/layout.rs b/src/ui/toasts/layout.rs new file mode 100644 index 000000000..7e78b6c64 --- /dev/null +++ b/src/ui/toasts/layout.rs @@ -0,0 +1,120 @@ +//! Toast text measurement, ellipsis and action geometry. + +use super::*; + +#[derive(Debug)] +pub(super) struct UiToastLayout { + pub(super) bounds: ToastBounds, + pub(super) action_bounds: [Option; 2], + pub(super) message: String, +} + +pub(super) fn toast_box_geometry( + engine: &UiTextEngine, + label: &str, + font_size: f64, + screen_width: u32, + screen_height: u32, + y_ratio: f64, +) -> Option<(f64, f64, f64, f64)> { + let extents = engine.measure(toast_text_style(font_size), label, None)?; + let width = extents.width() + TOAST_PADDING_X * 2.0; + let height = extents.height() + TOAST_PADDING_Y * 2.0; + let x = (screen_width as f64 - width) / 2.0; + let center_y = screen_height as f64 * y_ratio; + let y = center_y - height / 2.0; + Some((x, y, width, height)) +} + +fn measured_width(engine: &UiTextEngine, text: &str) -> Option { + Some( + engine + .measure(toast_text_style(UI_TOAST_FONT_SIZE), text, None)? + .width(), + ) +} + +fn ellipsize_to_width(engine: &UiTextEngine, text: &str, max_width: f64) -> Option { + if measured_width(engine, text)? <= max_width { + return Some(text.to_string()); + } + const ELLIPSIS: &str = "…"; + if measured_width(engine, ELLIPSIS)? > max_width { + return Some(String::new()); + } + for (end, _) in text.char_indices().rev() { + let candidate = format!("{}{}", text[..end].trim_end(), ELLIPSIS); + if measured_width(engine, &candidate)? <= max_width { + return Some(candidate); + } + } + Some(ELLIPSIS.to_string()) +} + +pub(super) fn ui_toast_layout( + engine: &UiTextEngine, + input_state: &InputState, + screen_width: u32, + screen_height: u32, +) -> Option { + let toast = input_state.active_toast()?; + let actions = [toast.action.as_ref(), toast.secondary_action.as_ref()]; + let action_sizes = actions.map(|action| { + action.and_then(|action| { + let extents = + engine.measure(toast_text_style(UI_TOAST_FONT_SIZE), &action.label, None)?; + Some(( + extents.width() + TOAST_ACTION_PADDING_X * 2.0, + extents.height() + TOAST_ACTION_PADDING_Y * 2.0, + )) + }) + }); + let action_count = action_sizes.iter().flatten().count(); + let action_width = action_sizes + .iter() + .flatten() + .map(|size| size.0) + .sum::() + + TOAST_ACTION_GAP * action_count.saturating_sub(1) as f64; + let message_action_gap = if action_count > 0 { + TOAST_ACTION_GAP + } else { + 0.0 + }; + let max_box_width = (screen_width as f64 - TOAST_SCREEN_MARGIN * 2.0).max(1.0); + let max_message_width = + (max_box_width - TOAST_PADDING_X * 2.0 - action_width - message_action_gap).max(0.0); + let message = ellipsize_to_width(engine, &toast.message, max_message_width)?; + let message_extents = engine.measure(toast_text_style(UI_TOAST_FONT_SIZE), &message, None)?; + let content_width = message_extents.width() + message_action_gap + action_width; + let content_height = action_sizes + .iter() + .flatten() + .map(|size| size.1) + .fold(message_extents.height(), f64::max); + let width = (content_width + TOAST_PADDING_X * 2.0).min(max_box_width); + let height = content_height + TOAST_PADDING_Y * 2.0; + let x = (screen_width as f64 - width) / 2.0; + let y = screen_height as f64 * UI_TOAST_Y_RATIO - height / 2.0; + + let mut action_x = x + TOAST_PADDING_X + message_extents.width() + message_action_gap; + let mut action_bounds = [None, None]; + for (index, size) in action_sizes.into_iter().enumerate() { + let Some((action_width, action_height)) = size else { + continue; + }; + action_bounds[index] = Some(( + action_x, + y + (height - action_height) / 2.0, + action_width, + action_height, + )); + action_x += action_width + TOAST_ACTION_GAP; + } + + Some(UiToastLayout { + bounds: (x, y, width, height), + action_bounds, + message, + }) +} diff --git a/src/ui/toasts/tests/engine.rs b/src/ui/toasts/tests/engine.rs new file mode 100644 index 000000000..e2ce41cbb --- /dev/null +++ b/src/ui/toasts/tests/engine.rs @@ -0,0 +1,94 @@ +use super::*; + +fn pixels(density: i32, paint: impl FnOnce(&cairo::Context)) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 640 * density, 480 * density).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + paint(&ctx); + } + surface.flush(); + surface.data().unwrap().to_vec() +} + +#[test] +fn retained_toast_owner_matches_standalone_across_targets() { + let engine = UiTextEngine::default(); + let mut state = crate::input::state::test_support::make_test_input_state(); + let mut toast = Toast::info("Saved — Καλημέρα 你好"); + toast.duration_ms = 60_000; + state.push_toast(ToastPriority::Hint, "paint", toast); + // The preset clock is held ahead of now so the countdown stays at zero. + state.preset_slots.set_feedback( + 1, + PresetFeedbackKind::Save, + Instant::now() + std::time::Duration::from_secs(60), + ); + let bounds = ui_toast_geometry_with_engine(&engine, &state, 640, 480).unwrap(); + let preset = preset_toast_geometry_with_engine(&engine, &state, 640, 480).unwrap(); + for density in [1, 2, 1] { + let retained = pixels(density, |ctx| { + let geometry = render_ui_toast_with_engine(&engine, ctx, &state, 640, 480).unwrap(); + assert_eq!(geometry.0, bounds); + render_preset_toast_with_engine(&engine, ctx, &state, 640, 480); + }); + let standalone = pixels(density, |ctx| { + render_ui_toast(ctx, &state, 640, 480).unwrap(); + render_preset_toast(ctx, &state, 640, 480); + }); + assert!( + retained == standalone, + "toast pixels differ at density {density}" + ); + assert!(retained.iter().any(|b| *b != 0)); + assert_eq!( + ui_toast_geometry_with_engine(&engine, &state, 640, 480), + Some(bounds) + ); + assert_eq!( + preset_toast_geometry_with_engine(&engine, &state, 640, 480), + Some(preset) + ); + } + state.ui_visibility.show_preset_toasts = false; + assert!(preset_toast_geometry_with_engine(&engine, &state, 640, 480).is_none()); +} + +#[test] +fn explicit_toast_action_layout_handoff_preserves_release_target() { + use crate::input::state::ToastCommand; + let engine = UiTextEngine::default(); + let mut state = crate::input::state::test_support::make_test_input_state(); + state.push_toast( + ToastPriority::Hint, + "actions", + Toast::info("A long message with Unicode words Καλημέρα that must fit beside both buttons") + .action("Open", Action::OpenCaptureFolder) + .secondary_action("Help", Action::ToggleHelp), + ); + let layout = ui_toast_layout(&engine, &state, 360, 480).unwrap(); + assert!(layout.message.ends_with('…')); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 360, 480).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + let (bounds, [first, second]) = + render_ui_toast_with_engine(&engine, &ctx, &state, 360, 480).unwrap(); + assert_eq!(bounds, layout.bounds); + assert_eq!([first, second], layout.action_bounds); + state.set_toast_geometry(Some(bounds), [first, second]); + let center = |rect: (f64, f64, f64, f64)| { + ( + (rect.0 + rect.2 / 2.0) as i32, + (rect.1 + rect.3 / 2.0) as i32, + ) + }; + let a = center(first.unwrap()); + let b = center(second.unwrap()); + let pressed = state.toast_press_at(a.0, a.1).unwrap(); + assert_eq!(state.resolve_toast_release(pressed, b.0, b.1).1, None); + let pressed = state.toast_press_at(b.0, b.1).unwrap(); + assert_eq!( + state.resolve_toast_release(pressed, b.0, b.1), + (true, Some(ToastCommand::Dispatch(Action::ToggleHelp))) + ); +} From d704429a19568d83143ac22f1fc0691428efc8f9 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:26:20 +0200 Subject: [PATCH 15/42] refactor(radial): reuse text resources for baked and dynamic labels --- src/backend/wayland/state/render/runtime.rs | 8 +- src/backend/wayland/state/render/ui.rs | 3 +- src/ui/radial_menu/cache.rs | 53 +++++- src/ui/radial_menu/cache/owner_tests.rs | 191 ++++++++++++++++++-- src/ui/radial_menu/mod.rs | 134 ++++---------- src/ui/radial_menu/text.rs | 120 ++++++++++++ src/ui/tests/theme_compatibility.rs | 3 +- 7 files changed, 381 insertions(+), 131 deletions(-) create mode 100644 src/ui/radial_menu/text.rs diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index dcc678c7f..e663a0c16 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -157,12 +157,6 @@ impl RenderRuntime { &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 ui_parts_with_text_mut( &mut self, ) -> ( @@ -222,7 +216,7 @@ mod tests { ); 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(); + let (theme, _caches, _engine) = dark.ui_parts_with_text_mut(); assert_eq!(theme, &crate::ui::theme::Theme::dark()); assert_ne!(dark.theme(), light.theme()); } diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 5030ec5de..84239b56c 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -289,13 +289,14 @@ impl WaylandState { .input_state .radial_menu_mark_painted_if_due(std::time::Instant::now()) { - let (theme, caches) = self.render.ui_parts_mut(); + let (theme, caches, engine) = self.render.ui_parts_with_text_mut(); let mut render = crate::ui::UiRenderCtx { cairo: ctx, theme, caches, }; crate::ui::render_radial_menu_with_context( + engine, &mut render, &self.input_state, width, diff --git a/src/ui/radial_menu/cache.rs b/src/ui/radial_menu/cache.rs index 84194042d..fd8758429 100644 --- a/src/ui/radial_menu/cache.rs +++ b/src/ui/radial_menu/cache.rs @@ -9,6 +9,7 @@ //! tool/color state — so any change invalidates the surface. The entry also //! retains the theme value so changing themes replaces the baked pixels. +use crate::ui_text::UiTextEngine; use cairo::{Context, Format, ImageSurface}; use crate::input::state::{ @@ -58,14 +59,31 @@ impl RadialBaseCache { /// be created. pub(super) fn paint_base( &mut self, + engine: &UiTextEngine, ctx: &cairo::Context, input_state: &InputState, layout: &RadialMenuLayout, theme: &theme::Theme, swatches: &[RadialRingSwatch], + ) { + self.paint_base_with_allocator(engine, ctx, input_state, layout, theme, swatches, |px| { + ImageSurface::create(Format::ARgb32, px, px).ok() + }); + } + + #[allow(clippy::too_many_arguments)] + fn paint_base_with_allocator( + &mut self, + engine: &UiTextEngine, + ctx: &Context, + input_state: &InputState, + layout: &RadialMenuLayout, + theme: &theme::Theme, + swatches: &[RadialRingSwatch], + allocate: impl FnOnce(i32) -> Option, ) { let extent = base_extent(layout); - let surface = self.surface_for(ctx, input_state, layout, theme, swatches); + let surface = self.surface_for(engine, ctx, input_state, layout, theme, swatches, allocate); match surface { Some(surface) => { @@ -79,6 +97,7 @@ impl RadialBaseCache { let _ = ctx.restore(); } None => super::draw_static_base( + engine, ctx, input_state, theme, @@ -90,13 +109,16 @@ impl RadialBaseCache { } } + #[allow(clippy::too_many_arguments)] fn surface_for( &mut self, + engine: &UiTextEngine, ctx: &Context, input_state: &InputState, layout: &RadialMenuLayout, theme: &theme::Theme, swatches: &[RadialRingSwatch], + allocate: impl FnOnce(i32) -> Option, ) -> Option { let extent = base_extent(layout); let scale = base_scale(ctx); @@ -108,7 +130,16 @@ impl RadialBaseCache { { return Some(cached.surface.clone()); } - let surface = render_base_surface(input_state, layout, theme, swatches, extent, scale)?; + let surface = render_base_surface( + engine, + input_state, + layout, + theme, + swatches, + extent, + scale, + allocate, + )?; self.cached = Some(CachedBase { key, theme: theme.clone(), @@ -189,20 +220,32 @@ pub(super) fn base_cache_key( } /// Render the static base into a fresh offscreen surface, centered. +#[allow(clippy::too_many_arguments)] fn render_base_surface( + engine: &UiTextEngine, input_state: &InputState, layout: &RadialMenuLayout, theme: &theme::Theme, swatches: &[RadialRingSwatch], extent: f64, scale: f64, + allocate: impl FnOnce(i32) -> Option, ) -> Option { let px = physical_size(extent, scale); - let surface = ImageSurface::create(Format::ARgb32, px, px).ok()?; + let surface = allocate(px)?; surface.set_device_scale(scale, scale); { let ctx = Context::new(&surface).ok()?; - super::draw_static_base(&ctx, input_state, theme, extent, extent, layout, swatches); + super::draw_static_base( + engine, + &ctx, + input_state, + theme, + extent, + extent, + layout, + swatches, + ); } Some(surface) } @@ -319,12 +362,14 @@ mod tests { let layout = state.radial_menu.layout().expect("radial layout"); let surface = render_base_surface( + &UiTextEngine::default(), &state, &layout, &theme::Theme::dark(), &state.radial_ring_swatches(), EXTENT, 1.0, + |px| ImageSurface::create(Format::ARgb32, px, px).ok(), ) .expect("base surface"); diff --git a/src/ui/radial_menu/cache/owner_tests.rs b/src/ui/radial_menu/cache/owner_tests.rs index 54a547fe1..deb78347d 100644 --- a/src/ui/radial_menu/cache/owner_tests.rs +++ b/src/ui/radial_menu/cache/owner_tests.rs @@ -11,6 +11,7 @@ fn state() -> InputState { } fn paint( + engine: &UiTextEngine, cache: &mut RadialBaseCache, state: &InputState, theme: &theme::Theme, @@ -26,6 +27,7 @@ fn paint( let ctx = Context::new(&output).unwrap(); ctx.scale(scale, scale); cache.paint_base( + engine, &ctx, state, &state.radial_menu.layout().unwrap(), @@ -37,13 +39,14 @@ fn paint( } fn assert_replacement( + engine: &UiTextEngine, 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); + let actual = paint(engine, cache, state, theme, scale); if let Some(prior) = prior { assert_ne!( prior.to_raw_none(), @@ -51,15 +54,16 @@ fn assert_replacement( "changed input must replace the surface" ); } - assert_eq!( - actual, - paint(&mut RadialBaseCache::default(), state, theme, scale) + assert!( + actual == paint(engine, &mut RadialBaseCache::default(), state, theme, scale), + "fresh base pixels differ" ); actual } #[test] fn theme_and_density_replacement_matches_fresh_pixels_and_hits_reuse_surfaces() { + let engine = UiTextEngine::default(); let state = state(); let mut cache = RadialBaseCache::default(); for scale in [1.0, 2.0] { @@ -69,14 +73,14 @@ fn theme_and_density_replacement_matches_fresh_pixels_and_hits_reuse_surfaces() theme::Theme::light(), theme::Theme::dark(), ] { - let actual = assert_replacement(&mut cache, &state, &theme, scale); + let actual = assert_replacement(&engine, &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"); + assert!(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!(paint(&engine, &mut cache, &state, &theme, scale) == actual); assert_eq!( surface.to_raw_none(), cache.cached.as_ref().unwrap().surface.to_raw_none(), @@ -89,19 +93,23 @@ fn theme_and_density_replacement_matches_fresh_pixels_and_hits_reuse_surfaces() #[test] fn independent_owners_keep_their_own_surfaces() { + let engine = UiTextEngine::default(); + let second_engine = UiTextEngine::default(); 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 pixels = paint(&engine, &mut first, &state, &dark, 1.0); let surface = first.cached.as_ref().unwrap().surface.clone(); - paint(&mut second, &state, &light, 1.0); + paint(&second_engine, &mut second, &state, &light, 1.0); + paint(&second_engine, &mut second, &state, &dark, 2.0); + paint(&second_engine, &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!(paint(&engine, &mut first, &state, &dark, 1.0) == pixels); assert_eq!( surface.to_raw_none(), first.cached.as_ref().unwrap().surface.to_raw_none() @@ -110,29 +118,31 @@ fn independent_owners_keep_their_own_surfaces() { #[test] fn palette_recents_and_active_changes_replace_rendered_base() { + let engine = UiTextEngine::default(); let mut state = state(); let theme = theme::Theme::dark(); let mut cache = RadialBaseCache::default(); - paint(&mut cache, &state, &theme, 1.0); + paint(&engine, &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); + assert_replacement(&engine, &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_replacement(&engine, &mut cache, &state, &theme, 1.0); assert!(state.set_tool_override(Some(Tool::Eraser))); - assert_replacement(&mut cache, &state, &theme, 1.0); + assert_replacement(&engine, &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); + assert_replacement(&engine, &mut cache, &state, &theme, 1.0); } #[test] fn bindings_replace_rendered_base() { + let engine = UiTextEngine::default(); use crate::config::{Action, Shortcut}; use crate::input::state::test_support::make_test_input_state_with_action_bindings; let state = state(); @@ -145,13 +155,14 @@ fn bindings_replace_rendered_base() { 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); + let before = paint(&engine, &mut cache, &state, &theme, 1.0); + let after = assert_replacement(&engine, &mut cache, &rebound, &theme, 1.0); + assert!(before != after); } #[test] fn transparent_swatches_keep_checkerboard_across_themes_and_densities() { + let engine = UiTextEngine::default(); let mut state = state(); state.set_quick_colors(crate::config::QuickColorPalette::from_entries( (0..8) @@ -166,7 +177,7 @@ fn transparent_swatches_keep_checkerboard_across_themes_and_densities() { 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 pixels = paint(&engine, &mut cache, &state, &theme, scale); let stride = (500.0 * scale) as usize * 4; let covered = (0..64) .filter(|index| { @@ -202,3 +213,145 @@ fn density_comes_from_matrix_x_scale_including_reflection() { "preserve absolute xx even for nonuniform transforms" ); } + +#[test] +fn failed_allocation_paints_directly_without_replacing_cache_and_can_retry() { + let input = state(); + let engine = UiTextEngine::default(); + let mut cache = RadialBaseCache::default(); + paint(&engine, &mut cache, &input, &theme::Theme::dark(), 1.0); + let retained = cache.cached.as_ref().unwrap().surface.clone(); + let theme = theme::Theme::light(); + let layout = input.radial_menu.layout().unwrap(); + let swatches = input.radial_ring_swatches(); + let mut failed = ImageSurface::create(Format::ARgb32, 500, 500).unwrap(); + let mut direct = ImageSurface::create(Format::ARgb32, 500, 500).unwrap(); + { + let ctx = Context::new(&failed).unwrap(); + cache + .paint_base_with_allocator(&engine, &ctx, &input, &layout, &theme, &swatches, |_| None); + let ctx = Context::new(&direct).unwrap(); + super::super::draw_static_base( + &engine, + &ctx, + &input, + &theme, + layout.center_x, + layout.center_y, + &layout, + &swatches, + ); + } + let actual = failed.data().unwrap().to_vec(); + assert!(actual.iter().any(|&byte| byte != 0)); + assert!( + actual == direct.data().unwrap().to_vec(), + "allocation fallback must run the direct static painter" + ); + assert_eq!( + cache.cached.as_ref().unwrap().surface.to_raw_none(), + retained.to_raw_none() + ); + assert_replacement(&engine, &mut cache, &input, &theme, 1.0); +} + +fn paint_full( + engine: &UiTextEngine, + caches: &mut crate::ui::UiRenderCaches, + input: &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); + super::super::render_radial_menu_with_context( + engine, + &mut crate::ui::UiRenderCtx { + cairo: &ctx, + theme, + caches, + }, + input, + 500, + 500, + ); + } + output.data().unwrap().to_vec() +} + +#[test] +fn dynamic_hover_sub_ring_and_center_labels_match_fresh_owners() { + let engine = UiTextEngine::default(); + let mut caches = crate::ui::UiRenderCaches::default(); + let mut input = state(); + let layout = input.radial_menu.layout().unwrap(); + let parent = crate::input::state::RADIAL_COMPASS_SLICES + .iter() + .position(|slice| matches!(slice.kind, crate::input::state::RadialSliceKind::Parent(_))) + .unwrap(); + let (segment, offset, _) = super::super::compass_geometry(&layout); + let (x, y) = super::super::compass_wedge_midpoint( + &layout, + layout.center_x, + layout.center_y, + offset + parent as f64 * segment, + segment, + ); + let dark = theme::Theme::dark(); + let collapsed = paint_full(&engine, &mut caches, &input, &dark, 1.0); + input.update_radial_menu_hover(x, y); + let expanded = paint_full(&engine, &mut caches, &input, &dark, 1.0); + assert!(expanded.iter().any(|&byte| byte != 0)); + assert!( + expanded != collapsed, + "expanded hover must paint its dynamic content" + ); + assert!(matches!( + input.radial_menu.state(), + crate::input::state::RadialMenuState::Open { + expanded_sub_ring: Some(_), + .. + } + )); + for scale in [1.0, 2.0, 1.0] { + for theme in [theme::Theme::dark(), theme::Theme::light()] { + let actual = paint_full(&engine, &mut caches, &input, &theme, scale); + let expected = paint_full( + &UiTextEngine::default(), + &mut crate::ui::UiRenderCaches::default(), + &input, + &theme, + scale, + ); + assert!( + actual == expected, + "dynamic frame differs at byte {:?}", + actual.iter().zip(&expected).position(|(a, b)| a != b) + ); + } + } + input.update_radial_menu_hover(layout.center_x, layout.center_y); + let theme = theme::Theme::dark(); + let centered = paint_full(&engine, &mut caches, &input, &theme, 1.0); + assert!( + centered != expanded, + "center hover must replace expanded content" + ); + assert!( + paint_full(&engine, &mut caches, &input, &theme, 1.0) + == paint_full( + &UiTextEngine::default(), + &mut crate::ui::UiRenderCaches::default(), + &input, + &theme, + 1.0 + ) + ); +} diff --git a/src/ui/radial_menu/mod.rs b/src/ui/radial_menu/mod.rs index 31fd4d399..6a1140006 100644 --- a/src/ui/radial_menu/mod.rs +++ b/src/ui/radial_menu/mod.rs @@ -2,7 +2,9 @@ //! with dynamic overlays (hover, sub-ring, size arc, center well) on top. mod cache; +mod text; pub(in crate::ui) use cache::RadialBaseCache; +use text::{draw_centered_label, draw_wedge_content}; use std::f64::consts::PI; @@ -18,9 +20,9 @@ use crate::toolbar_icons::{ MicroChipStyle, ToolbarIconPainter, draw_icon_note, draw_icon_shape_picker, draw_micro_chip, top_toolbar_icon_painter, }; -use crate::ui::primitives::{draw_keycap, keycap_size}; +use crate::ui::primitives::{draw_keycap_with_engine, keycap_size_with_engine}; use crate::ui::theme::{self, Rgba, overlay, toolbar}; -use crate::ui_text::{UiTextStyle, text_layout}; +use crate::ui_text::UiTextEngine; // ── File-local style values without a matching token in ui/theme.rs ── @@ -37,13 +39,6 @@ const WEDGE_GAP_PX: f64 = 2.0; const TOOL_LABEL_SIZE: f64 = 12.0; /// Sub-ring wedge label font size. const SUB_LABEL_SIZE: f64 = 11.0; -/// Vertical lift of the wedge label when it has no glyph but a keycap hint -/// is shown below it. -const HINT_LABEL_LIFT: f64 = 6.0; -/// Drop of the keycap hint's top edge below the wedge midpoint when the -/// wedge has no glyph. -const HINT_LABEL_DROP: f64 = 8.0; - /// 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) { @@ -54,8 +49,10 @@ pub fn render_radial_menu(ctx: &cairo::Context, input_state: &InputState, width: { return; } + let engine = UiTextEngine::default(); let mut caches = crate::ui::UiRenderCaches::default(); render_radial_menu_with_context( + &engine, &mut crate::ui::UiRenderCtx { cairo: ctx, theme: theme::current(), @@ -68,6 +65,7 @@ pub fn render_radial_menu(ctx: &cairo::Context, input_state: &InputState, width: } pub(crate) fn render_radial_menu_with_context( + engine: &UiTextEngine, render: &mut crate::ui::UiRenderCtx<'_, '_, '_>, input_state: &InputState, width: u32, @@ -105,12 +103,12 @@ pub(crate) fn render_radial_menu_with_context( render .caches .radial_mut() - .paint_base(ctx, input_state, &layout, theme, &swatches); + .paint_base(engine, ctx, input_state, &layout, theme, &swatches); // ── Dynamic overlays ── match hover { Some(RadialSegmentId::Tool(idx)) => { - draw_compass_hover(ctx, input_state, theme, cx, cy, &layout, idx); + draw_compass_hover(engine, ctx, input_state, theme, cx, cy, &layout, idx); } Some(RadialSegmentId::Color(idx)) => { draw_color_hover(ctx, cx, cy, &layout, &swatches, idx); @@ -119,7 +117,17 @@ pub(crate) fn render_radial_menu_with_context( } if let Some(parent_idx) = expanded_sub_ring { - draw_sub_ring(ctx, input_state, theme, cx, cy, &layout, parent_idx, hover); + draw_sub_ring( + engine, + ctx, + input_state, + theme, + cx, + cy, + &layout, + parent_idx, + hover, + ); } draw_size_value( @@ -132,7 +140,7 @@ pub(crate) fn render_radial_menu_with_context( hover == Some(RadialSegmentId::SizeRing) || size_dragging, ); - draw_center_well(ctx, input_state, cx, cy, &layout, hover); + draw_center_well(engine, ctx, input_state, cx, cy, &layout, hover); let _ = ctx.restore(); } @@ -143,7 +151,9 @@ pub(crate) fn render_radial_menu_with_context( /// compass wedge (active state included — it is part of the cache key), and /// the size-ring track. Hover, sub-ring, size value arc, and the center well /// are dynamic and drawn on top by the caller. +#[allow(clippy::too_many_arguments)] fn draw_static_base( + engine: &UiTextEngine, ctx: &cairo::Context, input_state: &InputState, theme: &theme::Theme, @@ -219,6 +229,7 @@ fn draw_static_base( let hint = slice_action(slice).and_then(|action| input_state.action_binding_primary_label(action)); draw_wedge_content( + engine, ctx, lx, ly, @@ -254,7 +265,9 @@ fn draw_static_base( /// Hover overlay for a compass wedge: the state-ladder wash over the base /// wedge, then its glyph + label repainted in the primary content color (the /// keycap hint is color-independent, so the base copy stays). +#[allow(clippy::too_many_arguments)] fn draw_compass_hover( + engine: &UiTextEngine, ctx: &cairo::Context, input_state: &InputState, theme: &theme::Theme, @@ -285,6 +298,7 @@ fn draw_compass_hover( let hint = slice_action(slice).and_then(|action| input_state.action_binding_primary_label(action)); draw_wedge_content( + engine, ctx, lx, ly, @@ -320,6 +334,7 @@ fn draw_color_hover( /// Expanded sub-ring (hover-dependent, drawn fully dynamically). #[allow(clippy::too_many_arguments)] fn draw_sub_ring( + engine: &UiTextEngine, ctx: &cairo::Context, input_state: &InputState, theme: &theme::Theme, @@ -372,6 +387,7 @@ fn draw_sub_ring( Some(_) if show_labels => { let hint = input_state.action_binding_primary_label(*action); draw_wedge_content( + engine, ctx, lx, ly, @@ -388,7 +404,7 @@ fn draw_sub_ring( theme::set_color(ctx, color); paint(ctx, lx - size / 2.0, ly - size / 2.0, size); } - None => draw_centered_label(ctx, lx, ly, label, SUB_LABEL_SIZE, color), + None => draw_centered_label(engine, ctx, lx, ly, label, SUB_LABEL_SIZE, color), } } } @@ -449,6 +465,7 @@ fn draw_size_value( /// Center well: HUD micro-chip echo + thickness numeral keycap. fn draw_center_well( + engine: &UiTextEngine, ctx: &cairo::Context, input_state: &InputState, cx: f64, @@ -483,8 +500,10 @@ fn draw_center_well( }, ); let numeral = format!("{size:.0}px"); - let (numeral_w, numeral_h) = keycap_size(ctx, &numeral, toolbar::FONT_SIZE_SWATCH_KEY); - draw_keycap( + let (numeral_w, numeral_h) = + keycap_size_with_engine(engine, ctx, &numeral, toolbar::FONT_SIZE_SWATCH_KEY); + draw_keycap_with_engine( + engine, ctx, cx - numeral_w / 2.0, cy + overlay::RADIAL_CENTER_NUMERAL_DROP - numeral_h / 2.0, @@ -613,73 +632,6 @@ fn wedge_content_color(theme: &theme::Theme, is_hovered: bool, is_active: bool) } } -/// Draw a wedge's content stack centered on the wedge midpoint: glyph above -/// a short label with the primary bound shortcut as a keycap below, falling -/// back to label-only layouts when the glyph or hint is missing. With -/// `paint_hint` false the keycap is left to the layer below (hover repaints -/// only the color-dependent glyph/label); the hint still shapes the layout -/// so both layers agree on positions. -#[allow(clippy::too_many_arguments)] -fn draw_wedge_content( - ctx: &cairo::Context, - x: f64, - y: f64, - label: &str, - icon: Option, - hint: Option<&str>, - color: Rgba, - label_size: f64, - paint_hint: bool, -) { - match icon { - Some(paint) => { - let size = overlay::RADIAL_WEDGE_ICON_SIZE; - theme::set_color(ctx, color); - paint( - ctx, - x - size / 2.0, - y - overlay::RADIAL_WEDGE_ICON_LIFT - size / 2.0, - size, - ); - draw_centered_label( - ctx, - x, - y + overlay::RADIAL_WEDGE_LABEL_DROP, - label, - label_size, - color, - ); - if paint_hint && let Some(hint) = hint { - draw_hint_keycap(ctx, x, y + overlay::RADIAL_WEDGE_HINT_DROP, hint); - } - } - None => match hint { - Some(hint) => { - draw_centered_label(ctx, x, y - HINT_LABEL_LIFT, label, label_size, color); - if paint_hint { - draw_hint_keycap(ctx, x, y + HINT_LABEL_DROP, hint); - } - } - None => draw_centered_label(ctx, x, y, label, label_size, color), - }, - } -} - -/// Draw a keycap hint horizontally centered on `center_x` with its top edge -/// at `top_y`, in the shared keycap language. -fn draw_hint_keycap(ctx: &cairo::Context, center_x: f64, top_y: f64, label: &str) { - let (width, _height) = keycap_size(ctx, label, toolbar::FONT_SIZE_SWATCH_KEY); - draw_keycap( - ctx, - center_x - width / 2.0, - top_y, - label, - toolbar::FONT_SIZE_SWATCH_KEY, - toolbar::COLOR_BADGE_BACKGROUND, - toolbar::COLOR_BADGE_TEXT, - ); -} - /// Draw an annular (ring) sector path. fn draw_annular_sector( ctx: &cairo::Context, @@ -696,22 +648,6 @@ fn draw_annular_sector( ctx.close_path(); } -/// Draw a centered text label at the given position. -fn draw_centered_label(ctx: &cairo::Context, x: f64, y: f64, text: &str, size: f64, color: Rgba) { - let style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size, - }; - let layout = text_layout(ctx, style, text, None); - let extents = layout.ink_extents(); - let tx = x - extents.width() / 2.0 - extents.x_bearing(); - let ty = y - extents.height() / 2.0 - extents.y_bearing(); - theme::set_color(ctx, color); - layout.show_at_baseline(ctx, tx, ty); -} - // ── Slice content resolution (ActionMeta registry) ── /// Short label of a compass slice, resolved through the ActionMeta registry diff --git a/src/ui/radial_menu/text.rs b/src/ui/radial_menu/text.rs new file mode 100644 index 000000000..e952572c8 --- /dev/null +++ b/src/ui/radial_menu/text.rs @@ -0,0 +1,120 @@ +use crate::toolbar_icons::ToolbarIconPainter; +use crate::ui::primitives::{draw_keycap_with_engine, keycap_size_with_engine}; +use crate::ui::theme::{self, Rgba, overlay, toolbar}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; + +/// Vertical lift of the wedge label when it has no glyph but a keycap hint +/// is shown below it. +const HINT_LABEL_LIFT: f64 = 6.0; +/// Drop of the keycap hint's top edge below the wedge midpoint when the +/// wedge has no glyph. +const HINT_LABEL_DROP: f64 = 8.0; + +/// Draw a wedge's content stack centered on the wedge midpoint: glyph above +/// a short label with the primary bound shortcut as a keycap below, falling +/// back to label-only layouts when the glyph or hint is missing. With +/// `paint_hint` false the keycap is left to the layer below (hover repaints +/// only the color-dependent glyph/label); the hint still shapes the layout +/// so both layers agree on positions. +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_wedge_content( + engine: &UiTextEngine, + ctx: &cairo::Context, + x: f64, + y: f64, + label: &str, + icon: Option, + hint: Option<&str>, + color: Rgba, + label_size: f64, + paint_hint: bool, +) { + match icon { + Some(paint) => { + let size = overlay::RADIAL_WEDGE_ICON_SIZE; + theme::set_color(ctx, color); + paint( + ctx, + x - size / 2.0, + y - overlay::RADIAL_WEDGE_ICON_LIFT - size / 2.0, + size, + ); + draw_centered_label( + engine, + ctx, + x, + y + overlay::RADIAL_WEDGE_LABEL_DROP, + label, + label_size, + color, + ); + if paint_hint && let Some(hint) = hint { + draw_hint_keycap(engine, ctx, x, y + overlay::RADIAL_WEDGE_HINT_DROP, hint); + } + } + None => match hint { + Some(hint) => { + draw_centered_label( + engine, + ctx, + x, + y - HINT_LABEL_LIFT, + label, + label_size, + color, + ); + if paint_hint { + draw_hint_keycap(engine, ctx, x, y + HINT_LABEL_DROP, hint); + } + } + None => draw_centered_label(engine, ctx, x, y, label, label_size, color), + }, + } +} + +/// Draw a keycap hint horizontally centered on `center_x` with its top edge +/// at `top_y`, in the shared keycap language. +fn draw_hint_keycap( + engine: &UiTextEngine, + ctx: &cairo::Context, + center_x: f64, + top_y: f64, + label: &str, +) { + let (width, _height) = + keycap_size_with_engine(engine, ctx, label, toolbar::FONT_SIZE_SWATCH_KEY); + draw_keycap_with_engine( + engine, + ctx, + center_x - width / 2.0, + top_y, + label, + toolbar::FONT_SIZE_SWATCH_KEY, + toolbar::COLOR_BADGE_BACKGROUND, + toolbar::COLOR_BADGE_TEXT, + ); +} + +/// Draw a centered text label at the given position. +pub(super) fn draw_centered_label( + engine: &UiTextEngine, + ctx: &cairo::Context, + x: f64, + y: f64, + text: &str, + size: f64, + color: Rgba, +) { + let style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size, + }; + let layout = engine.layout(ctx, style, text, None); + let extents = layout.ink_extents(); + let tx = x - extents.width() / 2.0 - extents.x_bearing(); + let ty = y - extents.height() / 2.0 - extents.y_bearing(); + theme::set_color(ctx, color); + layout.show_at_baseline(ctx, tx, ty); +} diff --git a/src/ui/tests/theme_compatibility.rs b/src/ui/tests/theme_compatibility.rs index 0316b4b03..7b52bc03a 100644 --- a/src/ui/tests/theme_compatibility.rs +++ b/src/ui/tests/theme_compatibility.rs @@ -123,6 +123,7 @@ fn legacy_light_wrappers_match_explicit_light_in_isolated_process() { 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 engine = crate::ui_text::UiTextEngine::default(); let explicit = |theme: &Theme| { pixels(|ctx| { let mut caches = UiRenderCaches::default(); @@ -131,7 +132,7 @@ fn legacy_light_wrappers_match_explicit_light_in_isolated_process() { theme, caches: &mut caches, }; - render_radial_menu_with_context(&mut render, &state, WIDTH, HEIGHT); + render_radial_menu_with_context(&engine, &mut render, &state, WIDTH, HEIGHT); }) }; equal_pixels(&legacy, &explicit(&light), "legacy radial"); From db67a003eda296f907c62b8cef6bdd1d1e8dd9cd Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:26:27 +0200 Subject: [PATCH 16/42] refactor(input): share measurement across history and property changes --- src/input/state/actions/action_selection.rs | 46 +++--- src/input/state/core/captured_image.rs | 14 +- src/input/state/core/history.rs | 37 +++-- src/input/state/core/menus/commands.rs | 66 ++++---- src/input/state/core/menus/lifecycle.rs | 11 +- src/input/state/core/properties/apply.rs | 92 ++++++++--- .../apply_selection/actions/arrow.rs | 50 ++++-- .../apply_selection/actions/color.rs | 21 ++- .../apply_selection/actions/fill.rs | 8 +- .../apply_selection/actions/spotlight.rs | 5 +- .../apply_selection/actions/stroke.rs | 5 +- .../apply_selection/actions/text.rs | 17 +- .../properties/apply_selection/helpers.rs | 23 ++- src/input/state/core/properties/panel.rs | 21 ++- src/input/state/core/selection.rs | 4 - .../state/core/selection_actions/clipboard.rs | 38 +++-- .../selection_actions/clipboard/duplicate.rs | 9 +- .../clipboard/image_paste.rs | 16 +- .../selection_actions/measurement_tests.rs | 2 + .../measurement_tests/mutations.rs | 151 ++++++++++++++++++ .../state/core/selection_actions/reorder.rs | 19 ++- src/input/state/spotlight.rs | 24 +-- src/input/state/tests/selection/duplicate.rs | 11 +- 23 files changed, 519 insertions(+), 171 deletions(-) create mode 100644 src/input/state/core/selection_actions/measurement_tests/mutations.rs diff --git a/src/input/state/actions/action_selection.rs b/src/input/state/actions/action_selection.rs index 5d5a9b539..439babda3 100644 --- a/src/input/state/actions/action_selection.rs +++ b/src/input/state/actions/action_selection.rs @@ -97,33 +97,37 @@ impl InputState { } } Action::SelectAll => { - let previous_bounds = self.selection_bounding_box(self.selected_shape_ids()); - let ids: Vec<_> = self - .boards - .active_frame() - .shapes - .iter() - .map(|shape| shape.id) - .collect(); - if ids.is_empty() { - self.push_toast( - ToastPriority::Info, - "selection", - Toast::warning("No shapes to select."), - ); - } else { - self.set_selection(ids); - self.mark_selection_dirty_region(previous_bounds); - let new_bounds = self.selection_bounding_box(self.selected_shape_ids()); - self.mark_selection_dirty_region(new_bounds); - self.needs_redraw = true; - } + crate::draw::with_legacy_measurer(|measurer| self.select_all_shapes_with(measurer)); } _ => unreachable!("selection content dispatcher called with {action:?}"), } true } + fn select_all_shapes_with(&mut self, measurer: &crate::draw::TextMeasurer) { + let previous_bounds = self.selection_bounding_box_with(measurer, self.selected_shape_ids()); + let ids: Vec<_> = self + .boards + .active_frame() + .shapes + .iter() + .map(|shape| shape.id) + .collect(); + if ids.is_empty() { + self.push_toast( + ToastPriority::Info, + "selection", + Toast::warning("No shapes to select."), + ); + } else { + self.set_selection(ids); + self.mark_selection_dirty_region(previous_bounds); + let new_bounds = self.selection_bounding_box_with(measurer, self.selected_shape_ids()); + self.mark_selection_dirty_region(new_bounds); + self.needs_redraw = true; + } + } + fn handle_selection_nudge_action(&mut self, action: Action) -> bool { let shifted_step = if self.modifiers.shift { KEYBOARD_NUDGE_LARGE diff --git a/src/input/state/core/captured_image.rs b/src/input/state/core/captured_image.rs index 841333466..1289aab35 100644 --- a/src/input/state/core/captured_image.rs +++ b/src/input/state/core/captured_image.rs @@ -1,6 +1,7 @@ use super::InputState; use crate::draw::frame::UndoAction; use crate::draw::{EmbeddedImage, Shape}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::screen_pixels::EmbeddedImageLimits; use crate::util::Rect; @@ -17,6 +18,15 @@ impl InputState { &mut self, image: EmbeddedImage, target: &BoardPasteTarget, + ) -> bool { + with_legacy_measurer(|measurer| self.insert_captured_image_with(measurer, image, target)) + } + + pub(crate) fn insert_captured_image_with( + &mut self, + measurer: &TextMeasurer, + image: EmbeddedImage, + target: &BoardPasteTarget, ) -> bool { let limits = EmbeddedImageLimits::default(); if !limits.allows_bytes(image.bytes.len()) { @@ -76,7 +86,7 @@ impl InputState { else { return false; }; - let bounds = stored.bounding_box(); + let bounds = stored.bounding_box_with(measurer); frame.push_undo_action( UndoAction::Create { shapes: vec![(index, stored)], @@ -86,7 +96,7 @@ impl InputState { self.mark_session_dirty(); if target_active { self.mark_selection_dirty_region(bounds); - self.invalidate_hit_cache_for(id); + self.invalidate_hit_cache_for_with(measurer, id); self.set_selection(vec![id]); } self.needs_redraw = true; diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs index f79744cba..8178786b0 100644 --- a/src/input/state/core/history.rs +++ b/src/input/state/core/history.rs @@ -1,24 +1,29 @@ use super::base::InputState; use crate::draw::frame::UndoAction; +use crate::draw::{TextMeasurer, with_legacy_measurer}; impl InputState { /// Applies side effects after an undoable action mutates the frame. pub fn apply_action_side_effects(&mut self, action: &UndoAction) { - self.invalidate_hit_cache_from_action(action); - self.mark_dirty_from_action(action); + with_legacy_measurer(|measurer| self.apply_action_side_effects_with(measurer, action)); + } + + pub fn apply_action_side_effects_with(&mut self, measurer: &TextMeasurer, action: &UndoAction) { + self.invalidate_hit_cache_from_action(measurer, action); + self.mark_dirty_from_action(measurer, action); self.clear_selection(); self.needs_redraw = true; self.mark_session_dirty(); } - fn mark_dirty_from_action(&mut self, action: &UndoAction) { + fn mark_dirty_from_action(&mut self, measurer: &TextMeasurer, action: &UndoAction) { if self.is_properties_panel_open() { self.properties.mark_needs_refresh(); } match action { UndoAction::Create { shapes } | UndoAction::Delete { shapes } => { for (_, shape) in shapes { - self.dirty_tracker.mark_shape(&shape.shape); + self.dirty_tracker.mark_shape_with(&shape.shape, measurer); } } UndoAction::Modify { @@ -27,9 +32,9 @@ impl InputState { shape_id, .. } => { - self.dirty_tracker.mark_shape(&before.shape); - self.dirty_tracker.mark_shape(&after.shape); - self.invalidate_hit_cache_for(*shape_id); + self.dirty_tracker.mark_shape_with(&before.shape, measurer); + self.dirty_tracker.mark_shape_with(&after.shape, measurer); + self.invalidate_hit_cache_for_with(measurer, *shape_id); } UndoAction::ModifyImageBounds { shape_id, @@ -38,39 +43,39 @@ impl InputState { } => { self.dirty_tracker.mark_optional_rect(before.bounding_box()); self.dirty_tracker.mark_optional_rect(after.bounding_box()); - self.invalidate_hit_cache_for(*shape_id); + self.invalidate_hit_cache_for_with(measurer, *shape_id); } UndoAction::Reorder { shape_id, .. } => { if let Some(shape) = self.boards.active_frame().shape(*shape_id) { - self.dirty_tracker.mark_shape(&shape.shape); - self.invalidate_hit_cache_for(*shape_id); + self.dirty_tracker.mark_shape_with(&shape.shape, measurer); + self.invalidate_hit_cache_for_with(measurer, *shape_id); } } UndoAction::Compound { actions } => { for action in actions { - self.mark_dirty_from_action(action); + self.mark_dirty_from_action(measurer, action); } } } } - fn invalidate_hit_cache_from_action(&mut self, action: &UndoAction) { + fn invalidate_hit_cache_from_action(&mut self, measurer: &TextMeasurer, action: &UndoAction) { match action { UndoAction::Create { shapes } | UndoAction::Delete { shapes } => { for (_, shape) in shapes { - self.invalidate_hit_cache_for(shape.id); + self.invalidate_hit_cache_for_with(measurer, shape.id); } } UndoAction::Modify { shape_id, .. } | UndoAction::ModifyImageBounds { shape_id, .. } => { - self.invalidate_hit_cache_for(*shape_id); + self.invalidate_hit_cache_for_with(measurer, *shape_id); } UndoAction::Reorder { shape_id, .. } => { - self.invalidate_hit_cache_for(*shape_id); + self.invalidate_hit_cache_for_with(measurer, *shape_id); } UndoAction::Compound { actions } => { for action in actions { - self.invalidate_hit_cache_from_action(action); + self.invalidate_hit_cache_from_action(measurer, action); } } } diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index 91e457b3a..c4353bd7e 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -45,6 +45,40 @@ impl InputState { } } + fn select_hovered_context_menu_shape_with(&mut self, measurer: &crate::draw::TextMeasurer) { + if let Some(hovered_shape) = self.hovered_context_menu_shape() { + let previous_ids = self.selected_shape_ids().to_vec(); + let previous_bounds = { + let frame = self.boards.active_frame(); + previous_ids + .iter() + .filter_map(|id| { + frame + .shape(*id) + .and_then(|shape| shape.bounding_box_with(measurer)) + }) + .collect::>() + }; + + self.set_selection(vec![hovered_shape]); + + for bounds in previous_bounds { + self.mark_selection_dirty_region(Some(bounds)); + } + let hovered_bounds = { + let frame = self.boards.active_frame(); + frame + .shape(hovered_shape) + .and_then(|shape| shape.bounding_box_with(measurer)) + }; + self.mark_selection_dirty_region(hovered_bounds); + + self.close_context_menu(); + } else { + self.close_context_menu(); + } + } + pub fn execute_menu_command(&mut self, command: MenuCommand) { match command { MenuCommand::Copy => { @@ -66,35 +100,9 @@ impl InputState { self.close_context_menu(); } MenuCommand::SelectHoveredShape => { - if let Some(hovered_shape) = self.hovered_context_menu_shape() { - let previous_ids = self.selected_shape_ids().to_vec(); - let previous_bounds = { - let frame = self.boards.active_frame(); - previous_ids - .iter() - .filter_map(|id| { - frame.shape(*id).and_then(|shape| shape.bounding_box()) - }) - .collect::>() - }; - - self.set_selection(vec![hovered_shape]); - - for bounds in previous_bounds { - self.mark_selection_dirty_region(Some(bounds)); - } - let hovered_bounds = { - let frame = self.boards.active_frame(); - frame - .shape(hovered_shape) - .and_then(|shape| shape.bounding_box()) - }; - self.mark_selection_dirty_region(hovered_bounds); - - self.close_context_menu(); - } else { - self.close_context_menu(); - } + crate::draw::with_legacy_measurer(|measurer| { + self.select_hovered_context_menu_shape_with(measurer); + }); } MenuCommand::MoveToFront => { self.move_selection_to_front(); diff --git a/src/input/state/core/menus/lifecycle.rs b/src/input/state/core/menus/lifecycle.rs index aa1a09ea5..7a21138d1 100644 --- a/src/input/state/core/menus/lifecycle.rs +++ b/src/input/state/core/menus/lifecycle.rs @@ -1,6 +1,7 @@ use super::super::base::InputState; use super::types::{ContextMenuKind, MenuCommand}; use crate::draw::ShapeId; +use crate::draw::{TextMeasurer, with_legacy_measurer}; impl InputState { /// Closes the currently open context menu. @@ -57,6 +58,10 @@ impl InputState { } pub fn toggle_context_menu_via_keyboard(&mut self) { + with_legacy_measurer(|measurer| self.toggle_context_menu_via_keyboard_with(measurer)) + } + + pub fn toggle_context_menu_via_keyboard_with(&mut self, measurer: &TextMeasurer) { if !self.context_menu.enabled { return; } @@ -86,7 +91,7 @@ impl InputState { ) }) .unwrap_or(false); - let anchor = self.keyboard_shape_menu_anchor(&selection); + let anchor = self.keyboard_shape_menu_anchor(measurer, &selection); self.update_pointer_position_synthetic(anchor.0, anchor.1); self.open_context_menu(anchor, selection, ContextMenuKind::Shape, None); self.pointer.clear_menu_hover_recalc(); @@ -110,8 +115,8 @@ impl InputState { (x, y) } - fn keyboard_shape_menu_anchor(&self, ids: &[ShapeId]) -> (i32, i32) { - if let Some(bounds) = self.selection_screen_bounding_box(ids) { + fn keyboard_shape_menu_anchor(&self, measurer: &TextMeasurer, ids: &[ShapeId]) -> (i32, i32) { + if let Some(bounds) = self.selection_screen_bounding_box_with(measurer, ids) { (bounds.x + bounds.width / 2, bounds.y + bounds.height / 2) } else { self.pointer.screen() diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index e134228bb..bb4d32bc4 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -1,22 +1,41 @@ use super::super::base::InputState; use super::types::SelectionPropertyKind; -use crate::draw::Shape; +use crate::draw::{Shape, TextMeasurer, with_legacy_measurer}; impl InputState { pub(crate) fn activate_properties_panel_entry(&mut self) -> bool { - self.adjust_properties_panel_entry(0) + with_legacy_measurer(|measurer| self.activate_properties_panel_entry_with(measurer)) + } + + pub(crate) fn activate_properties_panel_entry_with(&mut self, measurer: &TextMeasurer) -> bool { + self.adjust_properties_panel_entry_with(measurer, 0) } pub(crate) fn adjust_properties_panel_entry(&mut self, direction: i32) -> bool { + with_legacy_measurer(|measurer| { + self.adjust_properties_panel_entry_with(measurer, direction) + }) + } + + pub(crate) fn adjust_properties_panel_entry_with( + &mut self, + measurer: &TextMeasurer, + direction: i32, + ) -> bool { let index = self.current_properties_focus_or_hover(); let Some(index) = index else { return false; }; - self.apply_properties_entry(index, direction) + self.apply_properties_entry(measurer, index, direction) } - fn apply_properties_entry(&mut self, index: usize, direction: i32) -> bool { + fn apply_properties_entry( + &mut self, + measurer: &TextMeasurer, + index: usize, + direction: i32, + ) -> bool { let entry = { let Some(panel) = self.properties.panel.as_ref() else { return false; @@ -30,10 +49,10 @@ impl InputState { entry.clone() }; - let changed = self.dispatch_selection_property(entry.kind, direction); + let changed = self.dispatch_selection_property(measurer, entry.kind, direction); if changed { - self.refresh_properties_panel(); + self.refresh_properties_panel_with(measurer); } changed @@ -60,6 +79,17 @@ impl InputState { &mut self, kind: SelectionPropertyKind, direction: i32, + ) -> bool { + with_legacy_measurer(|measurer| { + self.adjust_selection_property_kind_with(measurer, kind, direction) + }) + } + + pub(crate) fn adjust_selection_property_kind_with( + &mut self, + measurer: &TextMeasurer, + kind: SelectionPropertyKind, + direction: i32, ) -> bool { let ids = self.selected_shape_ids(); if ids.is_empty() { @@ -73,10 +103,10 @@ impl InputState { return false; } - let changed = self.dispatch_selection_property(kind, direction); + let changed = self.dispatch_selection_property(measurer, kind, direction); if changed && self.is_properties_panel_open() { - self.refresh_properties_panel(); + self.refresh_properties_panel_with(measurer); } changed @@ -87,16 +117,29 @@ impl InputState { /// the action still reaches the shared apply reporter so it can explain why /// nothing changed. pub(crate) fn cycle_selected_arrow_style_from_action(&mut self) -> bool { - let changed = self.dispatch_selection_property(SelectionPropertyKind::ArrowStyle, 1); + with_legacy_measurer(|measurer| self.cycle_selected_arrow_style_from_action_with(measurer)) + } + + pub(crate) fn cycle_selected_arrow_style_from_action_with( + &mut self, + measurer: &TextMeasurer, + ) -> bool { + let changed = + self.dispatch_selection_property(measurer, SelectionPropertyKind::ArrowStyle, 1); if changed && self.is_properties_panel_open() { - self.refresh_properties_panel(); + self.refresh_properties_panel_with(measurer); } changed } - fn dispatch_selection_property(&mut self, kind: SelectionPropertyKind, direction: i32) -> bool { + fn dispatch_selection_property( + &mut self, + measurer: &TextMeasurer, + kind: SelectionPropertyKind, + direction: i32, + ) -> bool { // Every property route lands here — the keyboard action, the toolbar's // AdjustSelectionProperty, and the shape properties panel — so this is // the one place that has to end a live bend drag first. That drag holds @@ -108,28 +151,31 @@ impl InputState { // because leaving Curved hides the arc the drag is editing. self.finish_active_arrow_bend(); match kind { - SelectionPropertyKind::Color => self.apply_selection_color(direction), + SelectionPropertyKind::Color => self.apply_selection_color(measurer, direction), SelectionPropertyKind::Thickness => { - self.apply_selection_thickness(direction_or_default(direction)) + self.apply_selection_thickness(measurer, direction_or_default(direction)) } - SelectionPropertyKind::Fill => self.apply_selection_fill(direction), + SelectionPropertyKind::Fill => self.apply_selection_fill(measurer, direction), SelectionPropertyKind::FontSize => { - self.apply_selection_font_size(direction_or_default(direction)) + self.apply_selection_font_size(measurer, direction_or_default(direction)) + } + SelectionPropertyKind::ArrowHead => { + self.apply_selection_arrow_head(measurer, direction) + } + SelectionPropertyKind::ArrowStyle => { + self.apply_selection_arrow_style(measurer, direction) } - SelectionPropertyKind::ArrowHead => self.apply_selection_arrow_head(direction), - SelectionPropertyKind::ArrowStyle => self.apply_selection_arrow_style(direction), SelectionPropertyKind::ArrowLength => { - self.apply_selection_arrow_length(direction_or_default(direction)) + self.apply_selection_arrow_length(measurer, direction_or_default(direction)) } SelectionPropertyKind::ArrowAngle => { - self.apply_selection_arrow_angle(direction_or_default(direction)) + self.apply_selection_arrow_angle(measurer, direction_or_default(direction)) } SelectionPropertyKind::TextBackground => { - self.apply_selection_text_background(direction) - } - SelectionPropertyKind::SpotlightMagnification => { - self.apply_selection_spotlight_magnification(direction_or_default(direction)) + self.apply_selection_text_background(measurer, direction) } + SelectionPropertyKind::SpotlightMagnification => self + .apply_selection_spotlight_magnification(measurer, direction_or_default(direction)), } } } diff --git a/src/input/state/core/properties/apply_selection/actions/arrow.rs b/src/input/state/core/properties/apply_selection/actions/arrow.rs index 8cc03d9b8..56f6cb663 100644 --- a/src/input/state/core/properties/apply_selection/actions/arrow.rs +++ b/src/input/state/core/properties/apply_selection/actions/arrow.rs @@ -1,3 +1,4 @@ +use crate::draw::TextMeasurer; use crate::draw::{ArrowStyle, Shape}; use crate::input::state::core::base::InputState; use crate::input::state::core::properties::apply_selection::constants::{ @@ -24,6 +25,7 @@ enum ArrowStyleTarget { impl InputState { pub(in crate::input::state::core::properties) fn apply_selection_arrow_head( &mut self, + measurer: &TextMeasurer, direction: i32, ) -> bool { let target = if direction == 0 { @@ -44,7 +46,8 @@ impl InputState { return false; }; - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| matches!(shape, Shape::Arrow { .. }), |shape| match shape { Shape::Arrow { head_at_end, .. } if *head_at_end != target => { @@ -65,6 +68,7 @@ impl InputState { /// mixed selection is a normalization, not a jump nobody can predict. pub(in crate::input::state::core::properties) fn apply_selection_arrow_style( &mut self, + measurer: &TextMeasurer, direction: i32, ) -> bool { let target = match self.selection_arrow_style_target(direction) { @@ -85,7 +89,8 @@ impl InputState { ArrowStyleTarget::Style(style) => style, }; - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| matches!(shape, Shape::Arrow { .. }), |shape| match shape { Shape::Arrow { style, bend, .. } => { @@ -144,10 +149,12 @@ impl InputState { pub(in crate::input::state::core::properties) fn apply_selection_arrow_length( &mut self, + measurer: &TextMeasurer, direction: i32, ) -> bool { let delta = SELECTION_ARROW_LENGTH_STEP * direction as f64; - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| matches!(shape, Shape::Arrow { .. }), |shape| match shape { Shape::Arrow { arrow_length, .. } => { @@ -168,10 +175,12 @@ impl InputState { pub(in crate::input::state::core::properties) fn apply_selection_arrow_angle( &mut self, + measurer: &TextMeasurer, direction: i32, ) -> bool { let delta = SELECTION_ARROW_ANGLE_STEP * direction as f64; - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| matches!(shape, Shape::Arrow { .. }), |shape| match shape { Shape::Arrow { arrow_angle, .. } => { @@ -252,12 +261,13 @@ mod tests { #[test] fn restyling_several_arrows_steps_them_all_in_one_undo_entry() { + let measurer = TextMeasurer::default(); let mut state = make_state(); let first = add_arrow(&mut state, true, 30.0); let second = add_arrow(&mut state, false, 30.0); state.set_selection(vec![first, second]); - assert!(state.apply_selection_arrow_style(1)); + assert!(state.apply_selection_arrow_style(&measurer, 1)); assert_eq!(arrow_style(&state, first), ArrowStyle::Pointy); assert_eq!(arrow_style(&state, second), ArrowStyle::Pointy); @@ -275,6 +285,7 @@ mod tests { #[test] fn restyling_a_mixed_selection_makes_it_agree_before_it_steps() { + let measurer = TextMeasurer::default(); let mut state = make_state(); let standard = add_arrow(&mut state, true, 30.0); let curved = add_styled_arrow(&mut state, true, 30.0, ArrowStyle::Curved, 0.3); @@ -282,49 +293,52 @@ mod tests { // First press normalizes on the first editable style rather than // jumping both to somewhere neither of them was. - assert!(state.apply_selection_arrow_style(1)); + assert!(state.apply_selection_arrow_style(&measurer, 1)); assert_eq!(arrow_style(&state, standard), ArrowStyle::Standard); assert_eq!(arrow_style(&state, curved), ArrowStyle::Standard); // Second press steps the now-uniform selection. - assert!(state.apply_selection_arrow_style(1)); + assert!(state.apply_selection_arrow_style(&measurer, 1)); assert_eq!(arrow_style(&state, standard), ArrowStyle::Pointy); assert_eq!(arrow_style(&state, curved), ArrowStyle::Pointy); } #[test] fn restyling_backwards_walks_the_cycle_the_other_way() { + let measurer = TextMeasurer::default(); let mut state = make_state(); let arrow = add_arrow(&mut state, true, 30.0); state.set_selection(vec![arrow]); - assert!(state.apply_selection_arrow_style(-1)); + assert!(state.apply_selection_arrow_style(&measurer, -1)); assert_eq!(arrow_style(&state, arrow), ArrowStyle::Double); } #[test] fn restyling_to_curved_gives_a_flat_arrow_an_arc_to_show() { + let measurer = TextMeasurer::default(); // A curved arrow at bend zero draws exactly like the style it replaced, // so switching to it would look like nothing happened. let mut state = make_state(); let arrow = add_arrow(&mut state, true, 30.0); state.set_selection(vec![arrow]); - assert!(state.apply_selection_arrow_style(1)); // Pointy - assert!(state.apply_selection_arrow_style(1)); // Curved + assert!(state.apply_selection_arrow_style(&measurer, 1)); // Pointy + assert!(state.apply_selection_arrow_style(&measurer, 1)); // Curved assert_eq!(arrow_style(&state, arrow), ArrowStyle::Curved); assert_eq!(arrow_bend(&state, arrow), DEFAULT_ARROW_BEND); } #[test] fn restyling_away_from_curved_and_back_keeps_the_shaped_arc() { + let measurer = TextMeasurer::default(); let mut state = make_state(); let arrow = add_styled_arrow(&mut state, true, 30.0, ArrowStyle::Curved, 0.7); state.set_selection(vec![arrow]); // Curved -> Double -> Standard -> Pointy -> Curved. for _ in 0..4 { - assert!(state.apply_selection_arrow_style(1)); + assert!(state.apply_selection_arrow_style(&measurer, 1)); } assert_eq!(arrow_style(&state, arrow), ArrowStyle::Curved); assert_eq!( @@ -336,8 +350,9 @@ mod tests { #[test] fn restyling_reports_when_no_arrows_are_selected() { + let measurer = TextMeasurer::default(); let mut state = make_state(); - assert!(!state.apply_selection_arrow_style(1)); + assert!(!state.apply_selection_arrow_style(&measurer, 1)); assert_eq!( state.active_toast().map(|toast| toast.message.as_str()), Some("No arrows selected.") @@ -384,6 +399,7 @@ mod tests { #[test] fn restyling_a_partly_locked_selection_steps_only_the_unlocked_arrows() { + let measurer = TextMeasurer::default(); // The locked one must not vote on the target either — it is skipped by // the apply, so letting it into the "are they all the same style?" // check would strand the selection agreeing with a shape that cannot @@ -399,7 +415,7 @@ mod tests { .locked = true; state.set_selection(vec![locked, editable]); - assert!(state.apply_selection_arrow_style(1)); + assert!(state.apply_selection_arrow_style(&measurer, 1)); assert_eq!(arrow_style(&state, editable), ArrowStyle::Pointy); assert_eq!( arrow_style(&state, locked), @@ -410,12 +426,13 @@ mod tests { #[test] fn apply_selection_arrow_head_on_mixed_selection_sets_heads_to_end() { + let measurer = TextMeasurer::default(); let mut state = make_state(); let first = add_arrow(&mut state, true, 30.0); let second = add_arrow(&mut state, false, 30.0); state.set_selection(vec![first, second]); - assert!(state.apply_selection_arrow_head(0)); + assert!(state.apply_selection_arrow_head(&measurer, 0)); for id in [first, second] { match &state.boards.active_frame().shape(id).expect("arrow").shape { @@ -427,12 +444,13 @@ mod tests { #[test] fn apply_selection_arrow_angle_clamps_to_maximum() { + let measurer = TextMeasurer::default(); let mut state = make_state(); let arrow_id = add_arrow(&mut state, true, MAX_ARROW_ANGLE - 1.0); state.set_selection(vec![arrow_id]); - assert!(state.apply_selection_arrow_angle(1)); - assert!(!state.apply_selection_arrow_angle(1)); + assert!(state.apply_selection_arrow_angle(&measurer, 1)); + assert!(!state.apply_selection_arrow_angle(&measurer, 1)); match &state .boards diff --git a/src/input/state/core/properties/apply_selection/actions/color.rs b/src/input/state/core/properties/apply_selection/actions/color.rs index 13c478cd5..cbc3f6744 100644 --- a/src/input/state/core/properties/apply_selection/actions/color.rs +++ b/src/input/state/core/properties/apply_selection/actions/color.rs @@ -1,3 +1,4 @@ +use crate::draw::TextMeasurer; use crate::draw::{Color, RED, Shape}; use crate::input::state::core::base::InputState; use crate::input::state::core::properties::utils::{ @@ -6,7 +7,18 @@ use crate::input::state::core::properties::utils::{ impl InputState { pub(crate) fn apply_selection_color_value(&mut self, target: Color) -> bool { - let result = self.apply_selection_change( + crate::draw::with_legacy_measurer(|measurer| { + self.apply_selection_color_value_with(measurer, target) + }) + } + + pub(crate) fn apply_selection_color_value_with( + &mut self, + measurer: &TextMeasurer, + target: Color, + ) -> bool { + let result = self.apply_selection_change_with( + measurer, |shape| { matches!( shape, @@ -63,6 +75,7 @@ impl InputState { pub(in crate::input::state::core::properties) fn apply_selection_color( &mut self, + measurer: &TextMeasurer, direction: i32, ) -> bool { let base_color = self.selection_primary_color().unwrap_or(RED); @@ -71,7 +84,8 @@ impl InputState { let next = cycle_index(index, SELECTION_COLORS.len(), offset); let target = SELECTION_COLORS[next].1; - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| { matches!( shape, @@ -183,6 +197,7 @@ mod tests { #[test] fn apply_selection_color_wraps_palette_forward_from_black_to_red() { + let measurer = TextMeasurer::default(); let mut state = make_state(); let rect_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 0, @@ -195,7 +210,7 @@ mod tests { }); state.set_selection(vec![rect_id]); - assert!(state.apply_selection_color(0)); + assert!(state.apply_selection_color(&measurer, 0)); match &state .boards diff --git a/src/input/state/core/properties/apply_selection/actions/fill.rs b/src/input/state/core/properties/apply_selection/actions/fill.rs index fd1875f0e..f8ba5f22f 100644 --- a/src/input/state/core/properties/apply_selection/actions/fill.rs +++ b/src/input/state/core/properties/apply_selection/actions/fill.rs @@ -1,10 +1,12 @@ use crate::draw::Shape; +use crate::draw::TextMeasurer; use crate::input::state::core::base::InputState; use crate::input::state::{Toast, ToastPriority}; impl InputState { pub(in crate::input::state::core::properties) fn apply_selection_fill( &mut self, + measurer: &TextMeasurer, direction: i32, ) -> bool { let target = if direction == 0 { @@ -27,7 +29,8 @@ impl InputState { return false; }; - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| { matches!( shape, @@ -67,6 +70,7 @@ mod tests { #[test] fn apply_selection_fill_on_mixed_selection_turns_all_fills_on() { + let measurer = TextMeasurer::default(); let mut state = make_state(); let rect_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 0, @@ -88,7 +92,7 @@ mod tests { }); state.set_selection(vec![rect_id, ellipse_id]); - assert!(state.apply_selection_fill(0)); + assert!(state.apply_selection_fill(&measurer, 0)); match &state .boards diff --git a/src/input/state/core/properties/apply_selection/actions/spotlight.rs b/src/input/state/core/properties/apply_selection/actions/spotlight.rs index 8085f25b6..99e13d22c 100644 --- a/src/input/state/core/properties/apply_selection/actions/spotlight.rs +++ b/src/input/state/core/properties/apply_selection/actions/spotlight.rs @@ -1,14 +1,17 @@ +use crate::draw::TextMeasurer; use crate::draw::{SPOTLIGHT_MAGNIFICATION_STEP, Shape}; use crate::input::state::core::base::InputState; impl InputState { pub(in crate::input::state::core::properties) fn apply_selection_spotlight_magnification( &mut self, + measurer: &TextMeasurer, direction: i32, ) -> bool { let delta = SPOTLIGHT_MAGNIFICATION_STEP * f64::from(direction); let mut changed_to_magnified = false; - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| matches!(shape, Shape::Spotlight { .. }), |shape| match shape { Shape::Spotlight { magnification, .. } => { diff --git a/src/input/state/core/properties/apply_selection/actions/stroke.rs b/src/input/state/core/properties/apply_selection/actions/stroke.rs index 5c8cf6859..06d3f67d7 100644 --- a/src/input/state/core/properties/apply_selection/actions/stroke.rs +++ b/src/input/state/core/properties/apply_selection/actions/stroke.rs @@ -1,4 +1,5 @@ use crate::draw::Shape; +use crate::draw::TextMeasurer; use crate::input::state::PressureThicknessEditMode; use crate::input::state::core::base::{InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use crate::input::state::core::properties::apply_selection::constants::SELECTION_THICKNESS_STEP; @@ -6,6 +7,7 @@ use crate::input::state::core::properties::apply_selection::constants::SELECTION impl InputState { pub(in crate::input::state::core::properties) fn apply_selection_thickness( &mut self, + measurer: &TextMeasurer, direction: i32, ) -> bool { let delta = SELECTION_THICKNESS_STEP * direction as f64; @@ -15,7 +17,8 @@ impl InputState { PressureThicknessEditMode::Add | PressureThicknessEditMode::Scale ); let pressure_scale = 1.0 + (self.style.pressure_thickness_scale_step * direction as f64); - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| { matches!( shape, diff --git a/src/input/state/core/properties/apply_selection/actions/text.rs b/src/input/state/core/properties/apply_selection/actions/text.rs index acf0d402e..7dd462729 100644 --- a/src/input/state/core/properties/apply_selection/actions/text.rs +++ b/src/input/state/core/properties/apply_selection/actions/text.rs @@ -1,4 +1,5 @@ use crate::draw::Shape; +use crate::draw::TextMeasurer; use crate::input::state::core::base::InputState; use crate::input::state::core::properties::apply_selection::constants::{ MAX_FONT_SIZE, MIN_FONT_SIZE, SELECTION_FONT_SIZE_STEP, @@ -8,10 +9,12 @@ use crate::input::state::{Toast, ToastPriority}; impl InputState { pub(in crate::input::state::core::properties) fn apply_selection_font_size( &mut self, + measurer: &TextMeasurer, direction: i32, ) -> bool { let delta = SELECTION_FONT_SIZE_STEP * direction as f64; - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| matches!(shape, Shape::Text { .. }), |shape| match shape { Shape::Text { size, .. } => { @@ -32,6 +35,7 @@ impl InputState { pub(in crate::input::state::core::properties) fn apply_selection_text_background( &mut self, + measurer: &TextMeasurer, direction: i32, ) -> bool { let target = if direction == 0 { @@ -54,7 +58,8 @@ impl InputState { return false; }; - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| matches!(shape, Shape::Text { .. }), |shape| match shape { Shape::Text { @@ -87,6 +92,7 @@ mod tests { #[test] fn apply_selection_text_background_warns_when_no_text_shapes_are_selected() { + let measurer = TextMeasurer::default(); let mut state = make_state(); let rect_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 0, @@ -99,7 +105,7 @@ mod tests { }); state.set_selection(vec![rect_id]); - assert!(!state.apply_selection_text_background(0)); + assert!(!state.apply_selection_text_background(&measurer, 0)); assert_eq!( state.active_toast().map(|toast| toast.message.as_str()), Some("No text shapes selected.") @@ -108,6 +114,7 @@ mod tests { #[test] fn apply_selection_font_size_clamps_to_maximum() { + let measurer = TextMeasurer::default(); let mut state = make_state(); let text_id = state.boards.active_frame_mut().add_shape(Shape::Text { x: 10, @@ -121,8 +128,8 @@ mod tests { }); state.set_selection(vec![text_id]); - assert!(state.apply_selection_font_size(1)); - assert!(!state.apply_selection_font_size(1)); + assert!(state.apply_selection_font_size(&measurer, 1)); + assert!(!state.apply_selection_font_size(&measurer, 1)); match &state .boards diff --git a/src/input/state/core/properties/apply_selection/helpers.rs b/src/input/state/core/properties/apply_selection/helpers.rs index b779f709c..19dd72ff2 100644 --- a/src/input/state/core/properties/apply_selection/helpers.rs +++ b/src/input/state/core/properties/apply_selection/helpers.rs @@ -1,6 +1,6 @@ use super::super::super::base::InputState; use super::super::summary::shape_color; -use crate::draw::{Color, Shape}; +use crate::draw::{Color, Shape, TextMeasurer, with_legacy_measurer}; use crate::input::state::{Toast, ToastPriority}; #[derive(Default)] @@ -57,6 +57,21 @@ impl InputState { pub(in crate::input::state::core) fn apply_selection_change( &mut self, + applicable: A, + apply: F, + ) -> SelectionApplyResult + where + A: FnMut(&Shape) -> bool, + F: FnMut(&mut Shape) -> bool, + { + with_legacy_measurer(|measurer| { + self.apply_selection_change_with(measurer, applicable, apply) + }) + } + + pub(in crate::input::state::core) fn apply_selection_change_with( + &mut self, + measurer: &TextMeasurer, mut applicable: A, mut apply: F, ) -> SelectionApplyResult @@ -88,7 +103,7 @@ impl InputState { continue; } - let before_bounds = drawn.bounding_box(); + let before_bounds = drawn.bounding_box_with(measurer); let before_snapshot = crate::draw::frame::ShapeSnapshot { shape: drawn.shape.clone(), locked: drawn.locked, @@ -100,7 +115,7 @@ impl InputState { continue; } - let after_bounds = drawn.bounding_box(); + let after_bounds = drawn.bounding_box_with(measurer); let after_snapshot = crate::draw::frame::ShapeSnapshot { shape: drawn.shape.clone(), locked: drawn.locked, @@ -136,7 +151,7 @@ impl InputState { for (shape_id, before, after) in dirty_regions { self.mark_selection_dirty_region(before); self.mark_selection_dirty_region(after); - self.invalidate_hit_cache_for(shape_id); + self.invalidate_hit_cache_for_with(measurer, shape_id); } self.needs_redraw = true; diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs index 2a8607f74..b76328f50 100644 --- a/src/input/state/core/properties/panel.rs +++ b/src/input/state/core/properties/panel.rs @@ -2,6 +2,7 @@ use super::super::base::InputState; use super::panel_layout::selection_panel_anchor; use super::types::{PropertiesPanelLayout, SelectionPropertyEntry, ShapePropertiesPanel}; use super::utils::format_timestamp; +use crate::draw::{TextMeasurer, with_legacy_measurer}; impl InputState { pub fn properties_panel(&self) -> Option<&ShapePropertiesPanel> { @@ -43,6 +44,10 @@ impl InputState { } pub(crate) fn show_properties_panel(&mut self) -> bool { + with_legacy_measurer(|measurer| self.show_properties_panel_with(measurer)) + } + + pub(crate) fn show_properties_panel_with(&mut self, measurer: &TextMeasurer) -> bool { if self.selected_shape_ids().is_empty() { return false; } @@ -52,8 +57,8 @@ impl InputState { let panel = (|| { let ids = self.selected_shape_ids(); let frame = self.boards.active_frame(); - let canvas_bounds = self.selection_bounding_box(ids); - let anchor_rect = self.selection_screen_bounding_box(ids); + let canvas_bounds = self.selection_bounding_box_with(measurer, ids); + let anchor_rect = self.selection_screen_bounding_box_with(measurer, ids); let anchor = selection_panel_anchor(anchor_rect, self.pointer.screen()); let entries = self.build_selection_property_entries(ids); @@ -102,7 +107,7 @@ impl InputState { if let Some(timestamp) = format_timestamp(drawn.created_at) { lines.push(format!("Created: {timestamp}")); } - if let Some(bounds) = drawn.bounding_box() { + if let Some(bounds) = drawn.bounding_box_with(measurer) { lines.push(format!("Bounds: {}×{} px", bounds.width, bounds.height)); } @@ -126,6 +131,10 @@ impl InputState { } pub(super) fn refresh_properties_panel(&mut self) { + with_legacy_measurer(|measurer| self.refresh_properties_panel_with(measurer)) + } + + pub(super) fn refresh_properties_panel_with(&mut self, measurer: &TextMeasurer) { self.properties.begin_refresh(); let update = (|| { let ids = self.selected_shape_ids(); @@ -135,8 +144,8 @@ impl InputState { let entries = self.build_selection_property_entries(ids); let frame = self.boards.active_frame(); - let canvas_bounds = self.selection_bounding_box(ids); - let anchor_rect = self.selection_screen_bounding_box(ids); + let canvas_bounds = self.selection_bounding_box_with(measurer, ids); + let anchor_rect = self.selection_screen_bounding_box_with(measurer, ids); let anchor = selection_panel_anchor(anchor_rect, self.pointer.screen()); let (title, lines, multiple_selection) = if ids.len() > 1 { @@ -173,7 +182,7 @@ impl InputState { if let Some(timestamp) = format_timestamp(drawn.created_at) { lines.push(format!("Created: {timestamp}")); } - if let Some(bounds) = drawn.bounding_box() { + if let Some(bounds) = drawn.bounding_box_with(measurer) { lines.push(format!("Bounds: {}×{} px", bounds.width, bounds.height)); } ("Shape Properties".to_string(), lines, false) diff --git a/src/input/state/core/selection.rs b/src/input/state/core/selection.rs index 173deb1a1..86dbf86ce 100644 --- a/src/input/state/core/selection.rs +++ b/src/input/state/core/selection.rs @@ -209,10 +209,6 @@ impl InputState { } } - pub(crate) fn selection_screen_bounding_box(&self, ids: &[ShapeId]) -> Option { - with_legacy_measurer(|measurer| self.selection_screen_bounding_box_with(measurer, ids)) - } - pub(crate) fn selection_screen_bounding_box_with( &self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/clipboard.rs b/src/input/state/core/selection_actions/clipboard.rs index 8b986b071..857d1f022 100644 --- a/src/input/state/core/selection_actions/clipboard.rs +++ b/src/input/state/core/selection_actions/clipboard.rs @@ -2,6 +2,7 @@ use super::super::base::{ClipboardFingerprint, ClipboardPasteRequest, InputState use super::super::selection::LocalSelectionContext; use crate::draw::Shape; use crate::draw::frame::UndoAction; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::state::{Toast, ToastPriority}; use crate::util::Rect; @@ -40,7 +41,7 @@ impl InputState { count } - pub(crate) fn paste_selection(&mut self) -> usize { + pub(crate) fn paste_selection_with(&mut self, measurer: &TextMeasurer) -> usize { let Some(shapes) = self.selection_clipboard.shapes() else { return 0; }; @@ -49,7 +50,7 @@ impl InputState { } let total = shapes.len(); - let (dx, dy) = shape_paste_translation(&shapes, self.paste_anchor()); + let (dx, dy) = shape_paste_translation(measurer, &shapes, self.paste_anchor()); let mut created = Vec::new(); let mut new_ids = Vec::new(); let mut limit_hit = false; @@ -74,8 +75,8 @@ impl InputState { .find_index(new_id) .and_then(|idx| frame.shape(new_id).map(|s| (idx, s.clone()))) } { - self.mark_selection_dirty_region(stored.bounding_box()); - self.invalidate_hit_cache_for(new_id); + self.mark_selection_dirty_region(stored.bounding_box_with(measurer)); + self.invalidate_hit_cache_for_with(measurer, new_id); created.push((index, stored)); new_ids.push(new_id); } @@ -163,6 +164,17 @@ impl InputState { &mut self, request: &ClipboardPasteRequest, shapes: Vec, + ) -> usize { + with_legacy_measurer(|measurer| { + self.paste_clipboard_shapes_from_request_with(measurer, request, shapes) + }) + } + + pub(crate) fn paste_clipboard_shapes_from_request_with( + &mut self, + measurer: &TextMeasurer, + request: &ClipboardPasteRequest, + shapes: Vec, ) -> usize { if shapes.is_empty() { return 0; @@ -171,7 +183,7 @@ impl InputState { return 0; } - let (dx, dy) = shape_paste_translation(&shapes, request.anchor); + let (dx, dy) = shape_paste_translation(measurer, &shapes, request.anchor); let target_active = self.clipboard_request_targets_active_page(request); let mut created = Vec::new(); let mut new_ids = Vec::new(); @@ -209,7 +221,7 @@ impl InputState { if let Some(index) = frame.find_index(new_id) && let Some(stored) = frame.shape(new_id).cloned() { - dirty_bounds.push(stored.bounding_box()); + dirty_bounds.push(stored.bounding_box_with(measurer)); hit_ids.push(new_id); created.push((index, stored)); new_ids.push(new_id); @@ -235,7 +247,7 @@ impl InputState { self.mark_selection_dirty_region(bounds); } for shape_id in hit_ids { - self.invalidate_hit_cache_for(shape_id); + self.invalidate_hit_cache_for_with(measurer, shape_id); } self.set_selection(new_ids); self.needs_redraw = true; @@ -253,8 +265,12 @@ impl InputState { } } -fn shape_paste_translation(shapes: &[Shape], anchor: PasteAnchor) -> (i32, i32) { - let Some(bounds) = shapes_bounding_box(shapes) else { +fn shape_paste_translation( + measurer: &TextMeasurer, + shapes: &[Shape], + anchor: PasteAnchor, +) -> (i32, i32) { + let Some(bounds) = shapes_bounding_box(measurer, shapes) else { return (0, 0); }; let (anchor_x, anchor_y) = anchor.point(); @@ -266,7 +282,7 @@ fn shape_paste_translation(shapes: &[Shape], anchor: PasteAnchor) -> (i32, i32) ) } -fn shapes_bounding_box(shapes: &[Shape]) -> Option { +fn shapes_bounding_box(measurer: &TextMeasurer, shapes: &[Shape]) -> Option { let mut min_x = i32::MAX; let mut min_y = i32::MAX; let mut max_x = i32::MIN; @@ -274,7 +290,7 @@ fn shapes_bounding_box(shapes: &[Shape]) -> Option { let mut found = false; for shape in shapes { - if let Some(bounds) = shape.bounding_box() { + if let Some(bounds) = shape.bounding_box_with(measurer) { min_x = min_x.min(bounds.x); min_y = min_y.min(bounds.y); max_x = max_x.max(bounds.x + bounds.width); diff --git a/src/input/state/core/selection_actions/clipboard/duplicate.rs b/src/input/state/core/selection_actions/clipboard/duplicate.rs index 06b6cf03f..420603acd 100644 --- a/src/input/state/core/selection_actions/clipboard/duplicate.rs +++ b/src/input/state/core/selection_actions/clipboard/duplicate.rs @@ -1,11 +1,16 @@ use super::super::super::base::InputState; use crate::draw::frame::UndoAction; +use crate::draw::{TextMeasurer, with_legacy_measurer}; const DUPLICATE_OFFSET: i32 = 12; #[allow(dead_code)] impl InputState { pub(crate) fn duplicate_selection(&mut self) -> bool { + with_legacy_measurer(|measurer| self.duplicate_selection_with(measurer)) + } + + pub(crate) fn duplicate_selection_with(&mut self, measurer: &TextMeasurer) -> bool { let ids_len = self.selected_shape_ids().len(); if ids_len == 0 { return false; @@ -39,8 +44,8 @@ impl InputState { .find_index(new_id) .and_then(|idx| frame.shape(new_id).map(|s| (idx, s.clone()))) } { - self.mark_selection_dirty_region(stored.bounding_box()); - self.invalidate_hit_cache_for(new_id); + self.mark_selection_dirty_region(stored.bounding_box_with(measurer)); + self.invalidate_hit_cache_for_with(measurer, new_id); created.push((index, stored)); new_ids.push(new_id); } diff --git a/src/input/state/core/selection_actions/clipboard/image_paste.rs b/src/input/state/core/selection_actions/clipboard/image_paste.rs index 2077ffbed..0524dc618 100644 --- a/src/input/state/core/selection_actions/clipboard/image_paste.rs +++ b/src/input/state/core/selection_actions/clipboard/image_paste.rs @@ -1,6 +1,7 @@ use super::super::super::base::{ClipboardPasteRequest, InputState}; use crate::draw::frame::UndoAction; use crate::draw::{EmbeddedImage, Shape}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::state::{Toast, ToastPriority}; impl InputState { @@ -8,6 +9,17 @@ impl InputState { &mut self, request: &ClipboardPasteRequest, image: EmbeddedImage, + ) -> bool { + with_legacy_measurer(|measurer| { + self.paste_external_image_from_request_with(measurer, request, image) + }) + } + + pub(crate) fn paste_external_image_from_request_with( + &mut self, + measurer: &TextMeasurer, + request: &ClipboardPasteRequest, + image: EmbeddedImage, ) -> bool { let active_request_id = self.selection_clipboard.active_paste_request_id(); if active_request_id != Some(request.id) { @@ -81,7 +93,7 @@ impl InputState { else { return false; }; - let bounds = stored.bounding_box(); + let bounds = stored.bounding_box_with(measurer); frame.push_undo_action( UndoAction::Create { shapes: vec![(index, stored)], @@ -92,7 +104,7 @@ impl InputState { self.mark_session_dirty(); if target_active { self.mark_selection_dirty_region(bounds); - self.invalidate_hit_cache_for(new_id); + self.invalidate_hit_cache_for_with(measurer, new_id); self.set_selection(vec![new_id]); self.needs_redraw = true; } diff --git a/src/input/state/core/selection_actions/measurement_tests.rs b/src/input/state/core/selection_actions/measurement_tests.rs index 67d6a0e47..0918df6c3 100644 --- a/src/input/state/core/selection_actions/measurement_tests.rs +++ b/src/input/state/core/selection_actions/measurement_tests.rs @@ -355,3 +355,5 @@ fn explicit_arrow_and_spotlight_drag_chains_refresh_geometry_and_index() { ); assert!(state.canvas_content_generation() > generation); } + +mod mutations; diff --git a/src/input/state/core/selection_actions/measurement_tests/mutations.rs b/src/input/state/core/selection_actions/measurement_tests/mutations.rs new file mode 100644 index 000000000..837de20dc --- /dev/null +++ b/src/input/state/core/selection_actions/measurement_tests/mutations.rs @@ -0,0 +1,151 @@ +use super::*; +use crate::draw::frame::UndoAction; +use crate::input::state::core::properties::SelectionPropertyKind; + +#[test] +fn explicit_duplicate_and_nested_history_restore_decorated_hits_and_damage() { + for (shape, probe) in fixtures() { + let owner = TextMeasurer::default(); + let (mut state, original_id, locked_id) = state_with(shape); + let locked_bounds = bounds(&state, &owner, locked_id); + assert_eq!( + state.hit_test_at_with(&owner, probe.0, probe.1), + Some(original_id) + ); + assert!(state.duplicate_selection_with(&owner)); + let duplicated_id = state.selected_shape_ids()[0]; + assert_ne!(duplicated_id, original_id); + assert_eq!(state.selected_shape_ids().len(), 1); + assert_eq!(state.boards.active_frame().shapes.len(), 3); + assert_eq!(bounds(&state, &owner, locked_id), locked_bounds); + let duplicated_bounds = bounds(&state, &owner, duplicated_id); + assert_dirty_covers(&state.take_dirty_regions(), duplicated_bounds); + let shifted_probe = (probe.0 + 12, probe.1 + 12); + assert_eq!( + state.hit_test_at_with(&owner, shifted_probe.0, shifted_probe.1), + Some(duplicated_id) + ); + + let action = state + .boards + .active_frame_mut() + .undo_last() + .expect("duplicate undo"); + // Both recursive traversals must reach a grandchild action after frame mutation. + let nested = UndoAction::Compound { + actions: vec![UndoAction::Compound { + actions: vec![action], + }], + }; + state.apply_action_side_effects_with(&owner, &nested); + assert!(state.boards.active_frame().shape(duplicated_id).is_none()); + assert!(state.selected_shape_ids().is_empty()); + assert_dirty_covers(&state.take_dirty_regions(), duplicated_bounds); + assert_ne!( + state.hit_test_at_with(&owner, shifted_probe.0, shifted_probe.1), + Some(duplicated_id) + ); + + let action = state + .boards + .active_frame_mut() + .redo_last() + .expect("duplicate redo"); + state.apply_action_side_effects_with(&owner, &action); + assert_eq!(bounds(&state, &owner, duplicated_id), duplicated_bounds); + assert_dirty_covers(&state.take_dirty_regions(), duplicated_bounds); + assert_eq!( + state.hit_test_at_with(&owner, shifted_probe.0, shifted_probe.1), + Some(duplicated_id) + ); + } +} + +#[test] +fn explicit_font_property_updates_wrapped_bounds_and_undo_keeps_locked_text() { + let owner = TextMeasurer::default(); + let (shape, probe) = fixtures().remove(0); + let (mut state, id, locked_id) = state_with(shape); + let original = bounds(&state, &owner, id); + let locked = bounds(&state, &owner, locked_id); + state.hit_test_at_with(&owner, probe.0, probe.1); + let generation = state.canvas_content_generation(); + assert!(state.adjust_selection_property_kind_with(&owner, SelectionPropertyKind::FontSize, 1)); + let changed = bounds(&state, &owner, id); + assert_ne!(changed, original); + assert_eq!(bounds(&state, &owner, locked_id), locked); + assert!(state.canvas_content_generation() > generation); + let dirty = state.take_dirty_regions(); + assert_dirty_covers(&dirty, original); + assert_dirty_covers(&dirty, changed); + let action = state + .boards + .active_frame_mut() + .undo_last() + .expect("font undo"); + state.apply_action_side_effects_with(&owner, &action); + assert_eq!(bounds(&state, &owner, id), original); + assert_eq!(bounds(&state, &owner, locked_id), locked); + let dirty = state.take_dirty_regions(); + assert_dirty_covers(&dirty, original); + assert_dirty_covers(&dirty, changed); + assert_eq!(state.hit_test_at_with(&owner, probe.0, probe.1), Some(id)); +} + +#[test] +fn explicit_clipboard_paste_rejects_superseded_request_without_mutation() { + let owner = TextMeasurer::default(); + let (shape, _) = fixtures().remove(0); + let (mut state, id, _) = state_with(shape.clone()); + let stale = state.request_clipboard_paste(); + let current = state.request_clipboard_paste(); + let count = state.boards.active_frame().shapes.len(); + let generation = state.canvas_content_generation(); + assert_eq!( + state.paste_clipboard_shapes_from_request_with(&owner, &stale, vec![shape.clone()]), + 0 + ); + assert_eq!(state.boards.active_frame().shapes.len(), count); + assert_eq!(state.canvas_content_generation(), generation); + assert!(state.take_dirty_regions().is_empty()); + assert_eq!( + state.paste_clipboard_shapes_from_request_with(&owner, ¤t, vec![shape]), + 1 + ); + let pasted_id = state.selected_shape_ids()[0]; + assert_ne!(pasted_id, id); + assert_eq!(state.boards.active_frame().shapes.len(), count + 1); + assert_dirty_covers( + &state.take_dirty_regions(), + bounds(&state, &owner, pasted_id), + ); +} + +#[test] +fn explicit_keyboard_menu_anchor_projects_decorated_selection_with_pan_and_zoom() { + use crate::input::state::core::menus::ContextMenuState; + + let owner = TextMeasurer::default(); + let (shape, _) = fixtures().remove(0); + let (mut state, id, _) = state_with(shape); + state.set_selection(vec![id]); + let canvas = bounds(&state, &owner, id); + state.view.set_zoom_status(true, false, 2.0, (20.0, 10.0)); + state.toggle_context_menu_via_keyboard_with(&owner); + let ContextMenuState::Open { + anchor, shape_ids, .. + } = &state.context_menu.state + else { + panic!("keyboard menu should open for the selected text"); + }; + assert_eq!( + *anchor, + ( + (canvas.x - 20) * 2 + canvas.width, + (canvas.y - 10) * 2 + canvas.height + ) + ); + assert_eq!(shape_ids, &[id]); + state.toggle_context_menu_via_keyboard_with(&owner); + assert!(!state.is_context_menu_open()); +} diff --git a/src/input/state/core/selection_actions/reorder.rs b/src/input/state/core/selection_actions/reorder.rs index 521908b8d..c219516e9 100644 --- a/src/input/state/core/selection_actions/reorder.rs +++ b/src/input/state/core/selection_actions/reorder.rs @@ -1,16 +1,25 @@ use super::super::base::InputState; use crate::draw::frame::UndoAction; +use crate::draw::{TextMeasurer, with_legacy_measurer}; impl InputState { pub(crate) fn move_selection_to_front(&mut self) -> bool { - self.reorder_selection(true) + with_legacy_measurer(|measurer| self.move_selection_to_front_with(measurer)) + } + + pub(crate) fn move_selection_to_front_with(&mut self, measurer: &TextMeasurer) -> bool { + self.reorder_selection(measurer, true) } pub(crate) fn move_selection_to_back(&mut self) -> bool { - self.reorder_selection(false) + with_legacy_measurer(|measurer| self.move_selection_to_back_with(measurer)) + } + + pub(crate) fn move_selection_to_back_with(&mut self, measurer: &TextMeasurer) -> bool { + self.reorder_selection(measurer, false) } - fn reorder_selection(&mut self, to_front: bool) -> bool { + fn reorder_selection(&mut self, measurer: &TextMeasurer, to_front: bool) -> bool { let ids_len = self.selected_shape_ids().len(); if ids_len == 0 { return false; @@ -43,8 +52,8 @@ impl InputState { to: target, }); if let Some(shape) = self.boards.active_frame().shape(id) { - self.dirty_tracker.mark_shape(&shape.shape); - self.invalidate_hit_cache_for(id); + self.dirty_tracker.mark_shape_with(&shape.shape, measurer); + self.invalidate_hit_cache_for_with(measurer, id); } } } diff --git a/src/input/state/spotlight.rs b/src/input/state/spotlight.rs index 4973926c2..85d49a508 100644 --- a/src/input/state/spotlight.rs +++ b/src/input/state/spotlight.rs @@ -289,16 +289,6 @@ impl InputState { /// Undo granularity belongs to the gesture, not to each step: a wheel burst /// and a knob drag are each one user action, so their callers snapshot at /// the start and push a single entry at the end. - pub(crate) fn set_spotlight_shape_magnification( - &mut self, - shape_id: ShapeId, - magnification: f64, - ) -> bool { - with_legacy_measurer(|measurer| { - self.set_spotlight_shape_magnification_with(measurer, shape_id, magnification) - }) - } - pub(crate) fn set_spotlight_shape_magnification_with( &mut self, measurer: &TextMeasurer, @@ -340,6 +330,18 @@ impl InputState { x: i32, y: i32, steps: i32, + ) -> SpotlightWheelOutcome { + with_legacy_measurer(|measurer| { + self.nudge_spotlight_magnification_at_with(measurer, x, y, steps) + }) + } + + pub(crate) fn nudge_spotlight_magnification_at_with( + &mut self, + measurer: &TextMeasurer, + x: i32, + y: i32, + steps: i32, ) -> SpotlightWheelOutcome { let shape_id = match self.spotlight_wheel_target_at(x, y) { Some(SpotlightWheelTarget::Adjustable(shape_id)) => shape_id, @@ -379,7 +381,7 @@ impl InputState { .normalize_value( magnification + crate::draw::SPOTLIGHT_MAGNIFICATION_STEP * f64::from(steps), ); - if !self.set_spotlight_shape_magnification(shape_id, target) { + if !self.set_spotlight_shape_magnification_with(measurer, shape_id, target) { // An end of the range. The wheel still belongs to this loupe, so // the caller must not fall through to thickness: the pointer is // over a loupe and the user asked it to go further, not to resize diff --git a/src/input/state/tests/selection/duplicate.rs b/src/input/state/tests/selection/duplicate.rs index db589fd38..33c7289e1 100644 --- a/src/input/state/tests/selection/duplicate.rs +++ b/src/input/state/tests/selection/duplicate.rs @@ -694,6 +694,7 @@ fn copy_selection_of_only_locked_shapes_leaves_clipboard_empty() { #[test] fn repeated_paste_selection_uses_current_pointer_anchor() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -708,9 +709,9 @@ fn repeated_paste_selection_uses_current_pointer_anchor() { assert_eq!(state.copy_selection(), 1); state.update_pointer_positions(100, 120, 100, 120); - assert_eq!(state.paste_selection(), 1); + assert_eq!(state.paste_selection_with(&measurer), 1); state.update_pointer_positions(200, 220, 200, 220); - assert_eq!(state.paste_selection(), 1); + assert_eq!(state.paste_selection_with(&measurer), 1); let frame = state.boards.active_frame(); assert_eq!(frame.shapes.len(), 3); @@ -724,6 +725,7 @@ fn repeated_paste_selection_uses_current_pointer_anchor() { #[test] fn paste_selection_warns_when_shape_limit_prevents_any_paste() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -738,7 +740,7 @@ fn paste_selection_warns_when_shape_limit_prevents_any_paste() { assert_eq!(state.copy_selection(), 1); state.set_max_shapes_per_frame_for_test(1); - assert_eq!(state.paste_selection(), 0); + assert_eq!(state.paste_selection_with(&measurer), 0); assert_eq!( state.active_toast().map(|toast| toast.message.as_str()), Some("Shape limit reached; nothing pasted.") @@ -747,6 +749,7 @@ fn paste_selection_warns_when_shape_limit_prevents_any_paste() { #[test] fn paste_selection_warns_when_shape_limit_allows_only_partial_paste() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let first = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 0, @@ -770,7 +773,7 @@ fn paste_selection_warns_when_shape_limit_allows_only_partial_paste() { assert_eq!(state.copy_selection(), 2); state.set_max_shapes_per_frame_for_test(3); - assert_eq!(state.paste_selection(), 1); + assert_eq!(state.paste_selection_with(&measurer), 1); assert_eq!(state.boards.active_frame().shapes.len(), 3); assert_eq!(state.selected_shape_ids().len(), 1); assert_eq!( From 35fc892dbcb7ec83e177be4118a0208efe5f657f Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:40:53 +0200 Subject: [PATCH 17/42] refactor(input): share measurement across text editing and cancellation --- src/draw/render/text.rs | 11 +- src/draw/shape/mod.rs | 6 +- src/draw/shape/text.rs | 46 +--- src/draw/shape/text_cache.rs | 120 ----------- src/draw/shape/text_cache/cursor.rs | 23 ++ .../shape/text_cache/owner/tests/bounds.rs | 2 +- src/draw/shape/text_cache/tests.rs | 15 +- .../state/actions/key_press/caret_edit.rs | 121 +++++++---- .../state/actions/key_press/text_input.rs | 200 +++++++++++------- .../key_press/text_input/measurement_tests.rs | 169 +++++++++++++++ src/input/state/core/dirty.rs | 133 ++++++++---- .../properties/apply_selection/helpers.rs | 16 +- .../apply_selection/helpers/tests.rs | 3 +- .../measurement_tests/mutations.rs | 37 ++++ .../state/core/selection_actions/resize.rs | 6 - .../state/core/selection_actions/text/edit.rs | 27 ++- .../selection_actions/translation/restore.rs | 11 +- src/input/state/core/text_editing.rs | 32 +-- src/input/state/core/text_font.rs | 25 ++- src/input/state/core/utility/interaction.rs | 33 ++- src/input/state/mouse/press.rs | 40 +++- .../state/tests/text_edit/commit_cancel.rs | 16 +- src/input/state/tests/transform.rs | 2 +- src/input/tool/drawing.rs | 6 +- src/input/tool/tests.rs | 3 +- 25 files changed, 681 insertions(+), 422 deletions(-) create mode 100644 src/input/state/actions/key_press/text_input/measurement_tests.rs diff --git a/src/draw/render/text.rs b/src/draw/render/text.rs index 3ec1c71c4..6d46ef861 100644 --- a/src/draw/render/text.rs +++ b/src/draw/render/text.rs @@ -788,8 +788,15 @@ mod tests { } let (min_x, min_y, max_x, max_y) = painted_extents(&mut surface).expect("text paints something"); - let bounds = crate::draw::shape::bounding_box_for_text( - origin.0, origin.1, text, size, &font, background, None, + let bounds = crate::draw::shape::bounding_box_for_text_with( + &crate::draw::TextMeasurer::default(), + origin.0, + origin.1, + text, + size, + &font, + background, + None, ) .expect("non-empty text has damage bounds"); assert!( diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs index 6b93227a5..b1c545b8e 100644 --- a/src/draw/shape/mod.rs +++ b/src/draw/shape/mod.rs @@ -31,14 +31,12 @@ pub(crate) use step_marker::{ step_marker_outline_thickness, step_marker_radius, step_marker_radius_with, }; pub(crate) use text::{ - bounding_box_for_sticky_note_preview, bounding_box_for_text, sticky_note_layout, + bounding_box_for_sticky_note_preview_with, bounding_box_for_text_with, sticky_note_layout, sticky_note_layout_text, sticky_note_text_layout, }; pub(crate) use text_cache::{ CaretGeometry, LogicalBounds, TextMeasurement, VisualCaretDirection, VisualLineDirection, - VisualLineEdge, caret_at_visual_selection_edge, caret_geometry_text, - caret_on_adjacent_visual_line, caret_on_adjacent_visual_position, caret_on_visual_line_edge, - configured_layout, hit_test_text, measure_text_with_context, text_preview_geometry, + VisualLineEdge, caret_geometry_text, configured_layout, measure_text_with_context, }; #[cfg(test)] diff --git a/src/draw/shape/text.rs b/src/draw/shape/text.rs index 962c6afe9..a73e2497d 100644 --- a/src/draw/shape/text.rs +++ b/src/draw/shape/text.rs @@ -2,7 +2,7 @@ use crate::draw::font::FontDescriptor; use crate::util::Rect; use super::bounds::ensure_positive_rect_f64; -use super::text_cache::{TextContentExtents, TextMeasurement, TextMeasurer, with_legacy_measurer}; +use super::text_cache::{TextContentExtents, TextMeasurement, TextMeasurer}; pub(super) fn text_layout_metrics( measurer: &TextMeasurer, @@ -74,29 +74,6 @@ pub(super) fn text_bounds_from_metrics( ensure_positive_rect_f64(min_x, min_y, max_x, max_y) } -pub(crate) fn bounding_box_for_text( - x: i32, - y: i32, - text: &str, - size: f64, - font_descriptor: &FontDescriptor, - background_enabled: bool, - wrap_width: Option, -) -> Option { - with_legacy_measurer(|measurer| { - bounding_box_for_text_with( - measurer, - x, - y, - text, - size, - font_descriptor, - background_enabled, - wrap_width, - ) - }) -} - #[allow(clippy::too_many_arguments)] pub(crate) fn bounding_box_for_text_with( measurer: &TextMeasurer, @@ -247,27 +224,6 @@ pub(crate) fn bounding_box_for_sticky_note_with( bounding_box_for_sticky_note_layout(measurer, x, y, text, size, font_descriptor, wrap_width) } -pub(crate) fn bounding_box_for_sticky_note_preview( - x: i32, - y: i32, - text: &str, - size: f64, - font_descriptor: &FontDescriptor, - wrap_width: Option, -) -> Option { - with_legacy_measurer(|measurer| { - bounding_box_for_sticky_note_preview_with( - measurer, - x, - y, - text, - size, - font_descriptor, - wrap_width, - ) - }) -} - pub(crate) fn bounding_box_for_sticky_note_preview_with( measurer: &TextMeasurer, x: i32, diff --git a/src/draw/shape/text_cache.rs b/src/draw/shape/text_cache.rs index fa54684c9..b2e5ab4ed 100644 --- a/src/draw/shape/text_cache.rs +++ b/src/draw/shape/text_cache.rs @@ -117,31 +117,6 @@ pub(crate) fn measure_text_with_context( measure_text_cached(text, font_desc_str, size, wrap_width) } -/// Hit-test a point against a rendered text run, returning the caret byte -/// offset nearest the point. Coordinates are relative to the text's stored -/// origin `(x, y)`: `local_x = point_x - x`, and `local_y_from_baseline = -/// point_y - y` (the stored `y` is the first-line baseline). Layout-aware, so -/// it is correct for wrapped and multiline text. The caret snaps to the -/// trailing edge of a glyph when the point is on its right half. Returns `None` -/// only when no measurement context is available. -pub(crate) fn hit_test_text( - text: &str, - font_desc_str: &str, - wrap_width: Option, - local_x: f64, - local_y_from_baseline: f64, -) -> Option { - with_legacy_measurer(|measurer| { - measurer.hit_test_text( - text, - font_desc_str, - wrap_width, - local_x, - local_y_from_baseline, - ) - }) -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum VisualLineDirection { Up, @@ -160,85 +135,6 @@ pub(crate) enum VisualLineEdge { End, } -/// Return the adjacent Pango cursor position in physical left/right order. -/// Logical byte order is insufficient for RTL and mixed-direction text. -pub(crate) fn caret_on_adjacent_visual_position( - text: &str, - font_desc_str: &str, - wrap_width: Option, - byte_index: usize, - direction: VisualCaretDirection, -) -> Option { - with_legacy_measurer(|measurer| { - measurer.caret_on_adjacent_visual_position( - text, - font_desc_str, - wrap_width, - byte_index, - direction, - ) - }) -} - -/// Resolve which endpoint of a same-line selection is physically left/right. -/// For selections crossing visual lines, preserve the editor's established -/// document-order collapse behavior. -pub(crate) fn caret_at_visual_selection_edge( - text: &str, - font_desc_str: &str, - wrap_width: Option, - start: usize, - end: usize, - direction: VisualCaretDirection, -) -> Option { - with_legacy_measurer(|measurer| { - measurer.caret_at_visual_selection_edge( - text, - font_desc_str, - wrap_width, - start, - end, - direction, - ) - }) -} - -/// Return the logical byte offset at the start/end of the current Pango visual -/// line, including lines introduced by soft wrapping. -pub(crate) fn caret_on_visual_line_edge( - text: &str, - font_desc_str: &str, - wrap_width: Option, - byte_index: usize, - edge: VisualLineEdge, -) -> Option { - with_legacy_measurer(|measurer| { - measurer.caret_on_visual_line_edge(text, font_desc_str, wrap_width, byte_index, edge) - }) -} - -/// Return the caret offset on the adjacent Pango visual line while preserving -/// the current horizontal layout position. This follows soft wrapping as well -/// as explicit newlines. At the first/last visual line it resolves to the -/// document start/end, matching the editor's existing boundary behavior. -pub(crate) fn caret_on_adjacent_visual_line( - text: &str, - font_desc_str: &str, - wrap_width: Option, - byte_index: usize, - direction: VisualLineDirection, -) -> Option { - with_legacy_measurer(|measurer| { - measurer.caret_on_adjacent_visual_line( - text, - font_desc_str, - wrap_width, - byte_index, - direction, - ) - }) -} - fn hit_position_to_byte(text: &str, index: i32, trailing: i32) -> usize { let mut byte = (index.max(0) as usize).min(text.len()); let mut remaining = trailing.max(0); @@ -303,22 +199,6 @@ pub(crate) struct TextPreviewGeometry { pub logical: LogicalBounds, } -/// Resolve caret geometry and logical bounds together. The damage tracker needs -/// both for the same text whenever a selection or composition is showing, so -/// sharing one layout saves building a second one for those states. Text bounds -/// still go through `measure_text_cached`, which lays out again on a cache miss; -/// this only removes the duplicate pass, it does not make damage layout-free. -pub(crate) fn text_preview_geometry( - text: &str, - font_desc_str: &str, - wrap_width: Option, - byte_index: Option, -) -> Option { - with_legacy_measurer(|measurer| { - measurer.text_preview_geometry(text, font_desc_str, wrap_width, byte_index) - }) -} - fn caret_geometry_in(layout: &pango::Layout, text: &str, byte_index: usize) -> CaretGeometry { let scale = pango::SCALE as f64; let index = snap_char_boundary(text, byte_index); diff --git a/src/draw/shape/text_cache/cursor.rs b/src/draw/shape/text_cache/cursor.rs index 53a7f3445..7b075d6ad 100644 --- a/src/draw/shape/text_cache/cursor.rs +++ b/src/draw/shape/text_cache/cursor.rs @@ -1,6 +1,13 @@ use super::*; impl TextMeasurer { + /// Hit-test a point against a rendered text run, returning the caret byte + /// offset nearest the point. Coordinates are relative to the text's stored + /// origin `(x, y)`: `local_x = point_x - x`, and `local_y_from_baseline = + /// point_y - y` (the stored `y` is the first-line baseline). Layout-aware, so + /// it is correct for wrapped and multiline text. The caret snaps to the + /// trailing edge of a glyph when the point is on its right half. Returns `None` + /// only when no measurement context is available. pub(crate) fn hit_test_text( &self, text: &str, @@ -31,6 +38,8 @@ impl TextMeasurer { hit_position_to_byte(text, index, trailing) }) } + /// Return the adjacent Pango cursor position in physical left/right order. + /// Logical byte order is insufficient for RTL and mixed-direction text. pub(crate) fn caret_on_adjacent_visual_position( &self, text: &str, @@ -58,6 +67,9 @@ impl TextMeasurer { hit_position_to_byte(text, new_index, trailing) }) } + /// Resolve which endpoint of a same-line selection is physically left/right. + /// For selections crossing visual lines, preserve the editor's established + /// document-order collapse behavior. pub(crate) fn caret_at_visual_selection_edge( &self, text: &str, @@ -90,6 +102,8 @@ impl TextMeasurer { } }) } + /// Return the logical byte offset at the start/end of the current Pango visual + /// line, including lines introduced by soft wrapping. pub(crate) fn caret_on_visual_line_edge( &self, text: &str, @@ -120,6 +134,10 @@ impl TextMeasurer { } }) } + /// Return the caret offset on the adjacent Pango visual line while preserving + /// the current horizontal layout position. This follows soft wrapping as well + /// as explicit newlines. At the first/last visual line it resolves to the + /// document start/end, matching the editor's existing boundary behavior. pub(crate) fn caret_on_adjacent_visual_line( &self, text: &str, @@ -161,6 +179,11 @@ impl TextMeasurer { caret_geometry_in(&layout, text, byte_index) }) } + /// Resolve caret geometry and logical bounds together. The damage tracker needs + /// both for the same text whenever a selection or composition is showing, so + /// sharing one layout saves building a second one for those states. Text bounds + /// still go through `measure_text_cached`, which lays out again on a cache miss; + /// this only removes the duplicate pass, it does not make damage layout-free. pub(crate) fn text_preview_geometry( &self, text: &str, diff --git a/src/draw/shape/text_cache/owner/tests/bounds.rs b/src/draw/shape/text_cache/owner/tests/bounds.rs index f238cfae4..e81b55d89 100644 --- a/src/draw/shape/text_cache/owner/tests/bounds.rs +++ b/src/draw/shape/text_cache/owner/tests/bounds.rs @@ -186,7 +186,7 @@ fn dirty_and_provisional_bounds_consume_the_supplied_owner() { let preview = ProvisionalToolStroke::Shape(shape); assert_eq!(preview.bounds_with(&preview_owner), Some(expected)); assert_eq!(preview_owner.cache.borrow().entries.len(), 1); - assert_eq!(preview.bounds(), Some(expected)); + assert_eq!(preview.bounds_with(&preview_owner), Some(expected)); dirty.mark_shape_with( &Shape::Freehand { diff --git a/src/draw/shape/text_cache/tests.rs b/src/draw/shape/text_cache/tests.rs index 70236799e..38bd01ed1 100644 --- a/src/draw/shape/text_cache/tests.rs +++ b/src/draw/shape/text_cache/tests.rs @@ -16,26 +16,33 @@ fn measurement(width: f64) -> TextMeasurement { #[test] fn hit_test_maps_x_extremes_to_buffer_ends() { + let measurer = TextMeasurer::default(); // Far-left click lands at the start; far-right at the end; the exact // glyph widths do not matter, only the ordering and clamping. assert_eq!( - hit_test_text("hello", "Sans 20", None, -100.0, 0.0), + measurer.hit_test_text("hello", "Sans 20", None, -100.0, 0.0), Some(0) ); assert_eq!( - hit_test_text("hello", "Sans 20", None, 100_000.0, 0.0), + measurer.hit_test_text("hello", "Sans 20", None, 100_000.0, 0.0), Some(5) ); // Empty text always resolves to caret 0. - assert_eq!(hit_test_text("", "Sans 20", None, 42.0, 0.0), Some(0)); + assert_eq!( + measurer.hit_test_text("", "Sans 20", None, 42.0, 0.0), + Some(0) + ); } #[test] fn hit_test_result_is_always_a_char_boundary() { + let measurer = TextMeasurer::default(); // '你好' is two 3-byte chars; any x must land on 0, 3, or 6. let text = "你好"; for x in [-10.0, 0.0, 5.0, 12.0, 30.0, 1000.0] { - let offset = hit_test_text(text, "Sans 20", None, x, 0.0).unwrap(); + let offset = measurer + .hit_test_text(text, "Sans 20", None, x, 0.0) + .unwrap(); assert!( text.is_char_boundary(offset), "offset {offset} split a char" diff --git a/src/input/state/actions/key_press/caret_edit.rs b/src/input/state/actions/key_press/caret_edit.rs index 40d19710a..e23e96b5f 100644 --- a/src/input/state/actions/key_press/caret_edit.rs +++ b/src/input/state/actions/key_press/caret_edit.rs @@ -19,15 +19,22 @@ use std::ops::Range; use unicode_segmentation::{GraphemeCursor, UnicodeSegmentation}; -use crate::draw::shape::{ - VisualCaretDirection, VisualLineDirection, VisualLineEdge, caret_on_adjacent_visual_line, - caret_on_visual_line_edge, -}; +use crate::draw::shape::{VisualCaretDirection, VisualLineDirection, VisualLineEdge}; use crate::input::events::Key; use crate::input::state::DrawingState; use super::text_input::move_horizontal_caret; +/// Canonical layout inputs borrowed for one editor navigation operation. +/// The owner is shared with damage measurement; no destination Cairo context +/// or runtime resource is retained by the editor state. +#[derive(Clone, Copy)] +pub(in crate::input::state) struct TextNavigation<'a> { + pub(in crate::input::state) measurer: &'a crate::draw::TextMeasurer, + pub(in crate::input::state) font: &'a str, + pub(in crate::input::state) wrap_width: Option, +} + /// Maximum text-buffer length in bytes, shared by keyboard entry and IME /// commits so both enforce the same cap. pub(in crate::input::state) const MAX_TEXT_LENGTH: usize = 10_000; @@ -492,8 +499,7 @@ impl crate::input::state::core::TextEditing { key: Key, ctrl: bool, shift: bool, - font: &str, - wrap_width: Option, + navigation: TextNavigation<'_>, ) -> Option { let mutates_buffer = match key { Key::Char(_) | Key::Space => !ctrl, @@ -533,8 +539,7 @@ impl crate::input::state::core::TextEditing { Key::Delete => delete_forward(buffer, caret, anchor), Key::Left => move_horizontal_caret( buffer, - font, - wrap_width, + navigation, caret, anchor, shift, @@ -543,44 +548,59 @@ impl crate::input::state::core::TextEditing { ), Key::Right => move_horizontal_caret( buffer, - font, - wrap_width, + navigation, caret, anchor, shift, ctrl, VisualCaretDirection::Right, ), - Key::Up => caret_on_adjacent_visual_line( - buffer, - font, - wrap_width, - *caret, - VisualLineDirection::Up, - ) - .map(|new| move_to_offset(caret, anchor, shift, new)) - .unwrap_or_else(|| move_up(buffer, caret, anchor, shift)), - Key::Down => caret_on_adjacent_visual_line( - buffer, - font, - wrap_width, - *caret, - VisualLineDirection::Down, - ) - .map(|new| move_to_offset(caret, anchor, shift, new)) - .unwrap_or_else(|| move_down(buffer, caret, anchor, shift)), + Key::Up => navigation + .measurer + .caret_on_adjacent_visual_line( + buffer, + navigation.font, + navigation.wrap_width, + *caret, + VisualLineDirection::Up, + ) + .map(|new| move_to_offset(caret, anchor, shift, new)) + .unwrap_or_else(|| move_up(buffer, caret, anchor, shift)), + Key::Down => navigation + .measurer + .caret_on_adjacent_visual_line( + buffer, + navigation.font, + navigation.wrap_width, + *caret, + VisualLineDirection::Down, + ) + .map(|new| move_to_offset(caret, anchor, shift, new)) + .unwrap_or_else(|| move_down(buffer, caret, anchor, shift)), Key::Home if ctrl => move_document_start(caret, anchor, shift), - Key::Home => { - caret_on_visual_line_edge(buffer, font, wrap_width, *caret, VisualLineEdge::Start) - .map(|new| move_to_offset(caret, anchor, shift, new)) - .unwrap_or_else(|| move_line_home(buffer, caret, anchor, shift)) - } + Key::Home => navigation + .measurer + .caret_on_visual_line_edge( + buffer, + navigation.font, + navigation.wrap_width, + *caret, + VisualLineEdge::Start, + ) + .map(|new| move_to_offset(caret, anchor, shift, new)) + .unwrap_or_else(|| move_line_home(buffer, caret, anchor, shift)), Key::End if ctrl => move_document_end(buffer, caret, anchor, shift), - Key::End => { - caret_on_visual_line_edge(buffer, font, wrap_width, *caret, VisualLineEdge::End) - .map(|new| move_to_offset(caret, anchor, shift, new)) - .unwrap_or_else(|| move_line_end(buffer, caret, anchor, shift)) - } + Key::End => navigation + .measurer + .caret_on_visual_line_edge( + buffer, + navigation.font, + navigation.wrap_width, + *caret, + VisualLineEdge::End, + ) + .map(|new| move_to_offset(caret, anchor, shift, new)) + .unwrap_or_else(|| move_line_end(buffer, caret, anchor, shift)), _ => return None, }; @@ -621,17 +641,38 @@ mod tests { #[test] fn owner_key_edits_advance_revision_only_for_buffer_mutations() { + let measurer = crate::draw::TextMeasurer::default(); let mut editing = crate::input::state::core::TextEditing::default(); let mut state = DrawingState::text_input(0, 0, "ab".to_string()); editing.begin_session(); assert_eq!( - editing.apply_key_edit(&mut state, Key::Char('c'), false, false, "", None), + editing.apply_key_edit( + &mut state, + Key::Char('c'), + false, + false, + TextNavigation { + measurer: &measurer, + font: "", + wrap_width: None + } + ), Some(true) ); assert_eq!(editing.revision(), 1); assert_eq!( - editing.apply_key_edit(&mut state, Key::Left, false, false, "", None), + editing.apply_key_edit( + &mut state, + Key::Left, + false, + false, + TextNavigation { + measurer: &measurer, + font: "", + wrap_width: None + } + ), Some(true) ); assert_eq!(editing.revision(), 1, "caret motion is not a buffer edit"); diff --git a/src/input/state/actions/key_press/text_input.rs b/src/input/state/actions/key_press/text_input.rs index 893a959f8..c7fa5a533 100644 --- a/src/input/state/actions/key_press/text_input.rs +++ b/src/input/state/actions/key_press/text_input.rs @@ -1,9 +1,8 @@ +use crate::draw::{TextMeasurer, with_legacy_measurer}; use log::warn; use crate::draw::Shape; -use crate::draw::shape::{ - VisualCaretDirection, caret_at_visual_selection_edge, caret_on_adjacent_visual_position, -}; +use crate::draw::shape::VisualCaretDirection; use crate::input::events::Key; use crate::input::state::core::TextEditing; use crate::input::state::{ @@ -12,7 +11,7 @@ use crate::input::state::{ }; use super::bindings::{fallback_unshifted_label, key_to_action_label}; -use super::caret_edit::{self, MAX_TEXT_LENGTH}; +use super::caret_edit::{self, MAX_TEXT_LENGTH, TextNavigation}; impl InputState { pub(in crate::input::state) fn handle_text_input_key(&mut self, key: Key) { @@ -81,66 +80,70 @@ impl InputState { // Handle Return key for finalizing text input (only plain Return, not Shift+Return) if matches!(key, Key::Return) && !self.modifiers.shift { - let (x, y, text) = if let DrawingState::TextInput { x, y, buffer, .. } = &self.state { - (*x, *y, buffer.clone()) - } else { - (0, 0, String::new()) - }; - - if text.is_empty() { - if self.text_editing.edit_target().is_some() { - self.cancel_text_input(); - } else { - self.end_text_input_session(); - } - return; - } + with_legacy_measurer(|measurer| self.finalize_text_input_with(measurer)); + } + } - let shape = match self.text_editing.mode() { - TextInputMode::Plain => Shape::Text { - x, - y, - text, - color: self.style.current_color, - size: self.style.current_font_size, - font_descriptor: self.style.font_descriptor.clone(), - background_enabled: self.style.text_background_enabled, - wrap_width: self.style.text_wrap_width, - }, - TextInputMode::StickyNote => Shape::StickyNote { - x, - y, - text, - background: self.style.current_color, - size: self.style.current_font_size, - font_descriptor: self.style.font_descriptor.clone(), - wrap_width: self.style.text_wrap_width, - }, - }; - let bounds = shape.bounding_box(); - - if self.commit_text_edit(shape.clone()) { - self.end_text_input_session(); - return; - } + fn finalize_text_input_with(&mut self, measurer: &TextMeasurer) { + let (x, y, text) = if let DrawingState::TextInput { x, y, buffer, .. } = &self.state { + (*x, *y, buffer.clone()) + } else { + (0, 0, String::new()) + }; - let max_shapes = self.max_shapes_per_frame(); - let added = self - .boards - .active_frame_mut() - .try_add_shape(shape, max_shapes); - if added { - self.dirty_tracker.mark_optional_rect(bounds); - self.needs_redraw = true; - self.mark_session_dirty(); + if text.is_empty() { + if self.text_editing.edit_target().is_some() { + self.cancel_text_input_with(measurer); } else { - warn!( - "Shape limit ({}) reached; new text not added", - self.max_shapes_per_frame() - ); + self.end_text_input_session(); } + return; + } + + let shape = match self.text_editing.mode() { + TextInputMode::Plain => Shape::Text { + x, + y, + text, + color: self.style.current_color, + size: self.style.current_font_size, + font_descriptor: self.style.font_descriptor.clone(), + background_enabled: self.style.text_background_enabled, + wrap_width: self.style.text_wrap_width, + }, + TextInputMode::StickyNote => Shape::StickyNote { + x, + y, + text, + background: self.style.current_color, + size: self.style.current_font_size, + font_descriptor: self.style.font_descriptor.clone(), + wrap_width: self.style.text_wrap_width, + }, + }; + let bounds = shape.bounding_box_with(measurer); + + if self.commit_text_edit_with(measurer, shape.clone()) { self.end_text_input_session(); + return; } + + let max_shapes = self.max_shapes_per_frame(); + let added = self + .boards + .active_frame_mut() + .try_add_shape(shape, max_shapes); + if added { + self.dirty_tracker.mark_optional_rect(bounds); + self.needs_redraw = true; + self.mark_session_dirty(); + } else { + warn!( + "Shape limit ({}) reached; new text not added", + self.max_shapes_per_frame() + ); + } + self.end_text_input_session(); } /// Apply caret navigation, in-place editing, and selection for keys the @@ -148,6 +151,10 @@ impl InputState { /// stops routing it). Non-editing keys (Escape, F-keys, plain Return, and /// Ctrl/Alt shortcuts like undo/exit) return `false` and fall through. fn handle_text_editing_key(&mut self, key: Key) -> bool { + with_legacy_measurer(|measurer| self.handle_text_editing_key_with(measurer, key)) + } + + fn handle_text_editing_key_with(&mut self, measurer: &TextMeasurer, key: Key) -> bool { let ctrl = self.modifiers.ctrl; let alt = self.modifiers.alt; let shift = self.modifiers.shift; @@ -206,15 +213,18 @@ impl InputState { key, ctrl, shift, - font_for_navigation.as_deref().unwrap_or_default(), - self.style.text_wrap_width, + TextNavigation { + measurer, + font: font_for_navigation.as_deref().unwrap_or_default(), + wrap_width: self.style.text_wrap_width, + }, ) { Some(changed) => changed, None => return false, }; if changed { self.needs_redraw = true; - self.update_text_preview_dirty_from_editor(); + self.update_text_preview_dirty_from_editor_with(measurer); } true } @@ -251,10 +261,18 @@ impl InputState { /// Insert clipboard text at the caret, then coordinate redraw and protocol /// effects owned by the root state. pub(crate) fn insert_text_at_caret(&mut self, text: &str) -> bool { + with_legacy_measurer(|measurer| self.insert_text_at_caret_with(measurer, text)) + } + + pub(crate) fn insert_text_at_caret_with( + &mut self, + measurer: &TextMeasurer, + text: &str, + ) -> bool { let changed = self.text_editing.insert_text(&mut self.state, text); if changed { self.needs_redraw = true; - self.update_text_preview_dirty_from_editor(); + self.update_text_preview_dirty_from_editor_with(measurer); } changed } @@ -272,19 +290,36 @@ impl InputState { &mut self, target: TextPasteTarget, text: &str, + ) -> Option { + with_legacy_measurer(|measurer| self.apply_text_paste_with(measurer, target, text)) + } + + pub(crate) fn apply_text_paste_with( + &mut self, + measurer: &TextMeasurer, + target: TextPasteTarget, + text: &str, ) -> Option { let edit = self .text_editing .apply_paste(&mut self.state, target, text)?; self.needs_redraw = true; - self.update_text_preview_dirty_from_editor(); + self.update_text_preview_dirty_from_editor_with(measurer); Some(edit) } pub(crate) fn complete_text_copy(&mut self, request: TextClipboardRequest) { + with_legacy_measurer(|measurer| self.complete_text_copy_with(measurer, request)) + } + + pub(crate) fn complete_text_copy_with( + &mut self, + measurer: &TextMeasurer, + request: TextClipboardRequest, + ) { if self.text_editing.complete_copy(&mut self.state, request) { self.needs_redraw = true; - self.update_text_preview_dirty_from_editor(); + self.update_text_preview_dirty_from_editor_with(measurer); } } } @@ -456,11 +491,9 @@ impl TextEditing { } } -#[allow(clippy::too_many_arguments)] pub(super) fn move_horizontal_caret( buffer: &str, - font: &str, - wrap_width: Option, + navigation: TextNavigation<'_>, caret: &mut usize, anchor: &mut Option, extend: bool, @@ -472,21 +505,27 @@ pub(super) fn move_horizontal_caret( VisualCaretDirection::Left => range.start, VisualCaretDirection::Right => range.end, }; - let target = caret_at_visual_selection_edge( - buffer, - font, - wrap_width, - range.start, - range.end, - direction, - ) - .unwrap_or(fallback); + let target = navigation + .measurer + .caret_at_visual_selection_edge( + buffer, + navigation.font, + navigation.wrap_width, + range.start, + range.end, + direction, + ) + .unwrap_or(fallback); return caret_edit::move_to_offset(caret, anchor, false, target); } - let Some(adjacent) = - caret_on_adjacent_visual_position(buffer, font, wrap_width, *caret, direction) - else { + let Some(adjacent) = navigation.measurer.caret_on_adjacent_visual_position( + buffer, + navigation.font, + navigation.wrap_width, + *caret, + direction, + ) else { return match (direction, by_word) { (VisualCaretDirection::Left, false) => { caret_edit::move_left(buffer, caret, anchor, extend) @@ -555,3 +594,6 @@ mod tests { )); } } + +#[cfg(test)] +mod measurement_tests; diff --git a/src/input/state/actions/key_press/text_input/measurement_tests.rs b/src/input/state/actions/key_press/text_input/measurement_tests.rs new file mode 100644 index 000000000..de0a8e857 --- /dev/null +++ b/src/input/state/actions/key_press/text_input/measurement_tests.rs @@ -0,0 +1,169 @@ +use super::*; + +fn text_state(buffer: &str) -> InputState { + let mut state = crate::input::state::test_support::make_test_input_state(); + state.state = DrawingState::text_input(0, 0, buffer.to_string()); + state +} + +fn buffer(state: &InputState) -> String { + match &state.state { + DrawingState::TextInput { buffer, .. } => buffer.clone(), + other => panic!("expected TextInput, got {other:?}"), + } +} + +fn caret(state: &InputState) -> usize { + match &state.state { + DrawingState::TextInput { caret, .. } => *caret, + other => panic!("expected TextInput, got {other:?}"), + } +} + +#[test] +fn explicit_horizontal_arrows_follow_visual_order_in_rtl_text() { + let measurer = TextMeasurer::default(); + let mut state = text_state("אבג"); + let logical_end = buffer(&state).len(); + assert_eq!(caret(&state), logical_end); + + assert!(state.handle_text_editing_key_with(&measurer, Key::Right)); + assert!( + caret(&state) < logical_end, + "Right from the visual left edge of RTL text moves into the line" + ); + + assert!(state.handle_text_editing_key_with(&measurer, Key::Left)); + assert_eq!( + caret(&state), + logical_end, + "Left reverses the visual movement" + ); +} + +#[test] +fn explicit_ctrl_arrows_and_selection_collapse_follow_visual_order_in_rtl_text() { + let measurer = TextMeasurer::default(); + let mut state = text_state("אבג"); + let logical_end = buffer(&state).len(); + + state.modifiers.ctrl = true; + assert!(state.handle_text_editing_key_with(&measurer, Key::Left)); + assert_eq!( + caret(&state), + logical_end, + "Ctrl+Left stays at the visual left edge" + ); + assert!(state.handle_text_editing_key_with(&measurer, Key::Right)); + assert!( + caret(&state) < logical_end, + "Ctrl+Right moves inward through an RTL word" + ); + + state.modifiers.ctrl = false; + state.modifiers.shift = true; + assert!(state.handle_text_editing_key_with(&measurer, Key::Left)); + state.modifiers.shift = false; + assert!(state.handle_text_editing_key_with(&measurer, Key::Left)); + assert!( + caret(&state) > 0, + "Left collapses the RTL selection to its visual-left endpoint" + ); +} + +#[test] +fn explicit_up_and_down_follow_wrapped_visual_lines() { + let measurer = TextMeasurer::default(); + let mut state = text_state("abcdefghij"); + state.style.text_wrap_width = Some(35); + state.modifiers.ctrl = true; + assert!(state.handle_text_editing_key_with(&measurer, Key::Home)); + state.modifiers.ctrl = false; + + assert!(state.handle_text_editing_key_with(&measurer, Key::Down)); + let next_line = caret(&state); + assert!(next_line > 0, "Down advances to the next visible line"); + assert!( + next_line < buffer(&state).len(), + "Down must not skip all wrapped lines to the buffer end" + ); + + assert!(state.handle_text_editing_key_with(&measurer, Key::Up)); + assert_eq!(caret(&state), 0, "Up returns to the prior visible line"); +} + +#[test] +fn explicit_home_and_end_stay_on_the_current_wrapped_visual_line() { + let measurer = TextMeasurer::default(); + let mut state = text_state("abcdefghij"); + state.style.current_font_size = 20.0; + state.style.text_wrap_width = Some(50); + state.modifiers.ctrl = true; + assert!(state.handle_text_editing_key_with(&measurer, Key::Home)); + state.modifiers.ctrl = false; + assert!(state.handle_text_editing_key_with(&measurer, Key::Down)); + + assert!(state.handle_text_editing_key_with(&measurer, Key::Right)); + assert!(state.handle_text_editing_key_with(&measurer, Key::Home)); + let wrapped_line_start = caret(&state); + assert!(wrapped_line_start > 0); + + assert!(state.handle_text_editing_key_with(&measurer, Key::Right)); + assert!(state.handle_text_editing_key_with(&measurer, Key::End)); + assert!( + caret(&state) < buffer(&state).len(), + "End stops at the current soft-wrapped line" + ); + assert!( + caret(&state) > wrapped_line_start, + "End moves to the other edge of the current soft-wrapped line" + ); +} + +#[test] +fn explicit_finalize_commits_utf8_edit_and_empty_edit_restores_original() { + let measurer = TextMeasurer::default(); + let mut state = text_state(""); + state.view.set_screen_dimensions(800, 600); + let shape = Shape::Text { + x: 100, + y: 150, + text: "original".into(), + color: state.style.current_color, + size: state.style.current_font_size, + font_descriptor: state.style.font_descriptor.clone(), + background_enabled: false, + wrap_width: Some(140), + }; + let id = state.boards.active_frame_mut().add_shape(shape); + state.set_selection(vec![id]); + assert!(state.edit_selected_text_with(&measurer)); + state.modifiers.ctrl = true; + assert!(state.handle_text_editing_key_with(&measurer, Key::Char('a'))); + state.modifiers.ctrl = false; + assert!(state.insert_text_at_caret_with(&measurer, "שלום café")); + state.finalize_text_input_with(&measurer); + assert!(matches!(state.state, DrawingState::Idle)); + let committed = state.boards.active_frame().shape(id).unwrap().shape.clone(); + assert!(matches!(&committed, Shape::Text { text, .. } if text == "שלום café")); + let action = state + .boards + .active_frame_mut() + .undo_last() + .expect("edit undo"); + state.apply_action_side_effects_with(&measurer, &action); + assert!( + matches!(&state.boards.active_frame().shape(id).unwrap().shape, Shape::Text { text, .. } if text == "original") + ); + state.set_selection(vec![id]); + assert!(state.edit_selected_text_with(&measurer)); + state.modifiers.ctrl = true; + assert!(state.handle_text_editing_key_with(&measurer, Key::Char('a'))); + state.modifiers.ctrl = false; + assert!(state.handle_text_editing_key_with(&measurer, Key::Backspace)); + state.finalize_text_input_with(&measurer); + assert!(matches!(state.state, DrawingState::Idle)); + assert!( + matches!(&state.boards.active_frame().shape(id).unwrap().shape, Shape::Text { text, .. } if text == "original") + ); +} diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs index 8a86c98c5..6b2ff2ceb 100644 --- a/src/input/state/core/dirty.rs +++ b/src/input/state/core/dirty.rs @@ -1,9 +1,9 @@ use super::base::{DrawingState, InputState, TextInputMode}; -use crate::draw::Shape; use crate::draw::shape::{ - CaretGeometry, LogicalBounds, bounding_box_for_points, bounding_box_for_sticky_note_preview, - bounding_box_for_text, + CaretGeometry, LogicalBounds, bounding_box_for_points, + bounding_box_for_sticky_note_preview_with, bounding_box_for_text_with, }; +use crate::draw::{Shape, TextMeasurer, with_legacy_measurer}; use crate::input::tool::{ PROVISIONAL_POLYGON_DAMAGE_PADDING, ToolMotionBehavior, ToolMotionSizeSource, }; @@ -37,6 +37,17 @@ impl InputState { /// Updates tracked provisional shape bounds for dirty-region purposes. pub(crate) fn update_provisional_dirty(&mut self, current_x: i32, current_y: i32) { + with_legacy_measurer(|measurer| { + self.update_provisional_dirty_with(measurer, current_x, current_y) + }) + } + + pub(crate) fn update_provisional_dirty_with( + &mut self, + measurer: &TextMeasurer, + current_x: i32, + current_y: i32, + ) { if let Some((append_bounds, append_regions)) = self.compute_append_only_provisional_damage() { for region in append_regions { @@ -46,7 +57,7 @@ impl InputState { return; } - let new_bounds = self.compute_provisional_bounds(current_x, current_y); + let new_bounds = self.compute_provisional_bounds(measurer, current_x, current_y); let previous = self.pointer.provisional_bounds(); if new_bounds != previous @@ -66,18 +77,27 @@ impl InputState { /// This is needed when existing provisional geometry changes in place, for /// example when the first tablet pressure sample backfills previous widths. pub(crate) fn mark_current_provisional_dirty_full(&mut self) { + with_legacy_measurer(|measurer| self.mark_current_provisional_dirty_full_with(measurer)) + } + + pub(crate) fn mark_current_provisional_dirty_full_with(&mut self, measurer: &TextMeasurer) { let (current_x, current_y) = self.pointer.canvas(); - if let Some(bounds) = self.compute_provisional_bounds(current_x, current_y) { + if let Some(bounds) = self.compute_provisional_bounds(measurer, current_x, current_y) { self.dirty_tracker.mark_rect(bounds); self.pointer.union_provisional_bounds(bounds); } } - fn compute_provisional_bounds(&self, current_x: i32, current_y: i32) -> Option { + fn compute_provisional_bounds( + &self, + measurer: &TextMeasurer, + current_x: i32, + current_y: i32, + ) -> Option { match &self.state { - DrawingState::Drawing { .. } => { - self.provisional_tool_stroke(current_x, current_y).bounds() - } + DrawingState::Drawing { .. } => self + .provisional_tool_stroke(current_x, current_y) + .bounds_with(measurer), DrawingState::Selecting { start_x, start_y, .. } => Self::selection_rect_from_points(*start_x, *start_y, current_x, current_y) @@ -141,8 +161,12 @@ impl InputState { /// Updates dirty tracking for the live text preview/caret overlay. pub(crate) fn update_text_preview_dirty(&mut self) { + with_legacy_measurer(|measurer| self.update_text_preview_dirty_with(measurer)) + } + + pub(crate) fn update_text_preview_dirty_with(&mut self, measurer: &TextMeasurer) { self.text_editing.mark_cursor_rect_dirty(); - let new_bounds = self.compute_text_preview_bounds(); + let new_bounds = self.compute_text_preview_bounds(measurer); let previous = self.text_editing.replace_preview_bounds(new_bounds); if new_bounds != previous @@ -156,9 +180,9 @@ impl InputState { /// Updates text preview damage for a change authored outside the input /// method. The backend uses this bit to publish text-input-v3's `Other` /// change cause with the coalesced surrounding-text/caret update. - pub(crate) fn update_text_preview_dirty_from_editor(&mut self) { + pub(crate) fn update_text_preview_dirty_from_editor_with(&mut self, measurer: &TextMeasurer) { self.text_editing.mark_external_change_dirty(); - self.update_text_preview_dirty(); + self.update_text_preview_dirty_with(measurer); } /// Clears the cached text preview bounds. @@ -179,7 +203,7 @@ impl InputState { self.text_editing.take_external_change_dirty() } - fn compute_text_preview_bounds(&self) -> Option { + fn compute_text_preview_bounds(&self, measurer: &TextMeasurer) -> Option { let DrawingState::TextInput { x, y, .. } = &self.state else { return None; }; @@ -190,7 +214,8 @@ impl InputState { }; let preview = self.text_input_preview(cursor_glyph)?; let text_bounds = match self.text_editing.mode() { - TextInputMode::Plain => bounding_box_for_text( + TextInputMode::Plain => bounding_box_for_text_with( + measurer, *x, *y, &preview.text, @@ -199,7 +224,8 @@ impl InputState { self.style.text_background_enabled, self.style.text_wrap_width, ), - TextInputMode::StickyNote => bounding_box_for_sticky_note_preview( + TextInputMode::StickyNote => bounding_box_for_sticky_note_preview_with( + measurer, *x, *y, &preview.text, @@ -229,7 +255,7 @@ impl InputState { .style .font_descriptor .to_pango_string(self.style.current_font_size); - if let Some(geometry) = crate::draw::shape::text_preview_geometry( + if let Some(geometry) = measurer.text_preview_geometry( &preview.text, &font, self.style.text_wrap_width, @@ -253,7 +279,7 @@ impl InputState { // When the block has been moved, the ghost renders at the *original* // spot, away from the live text. Fold its bounds in so the ghost is // erased on move-back and on commit (otherwise it lingers there). - let ghost_bounds = self.text_edit_ghost_damage_bounds(); + let ghost_bounds = self.text_edit_ghost_damage_bounds(measurer); let bounds = match (live_bounds, ghost_bounds) { (Some(live), Some(ghost)) => live.union(ghost).or(Some(live)), (Some(live), None) => Some(live), @@ -285,14 +311,14 @@ impl InputState { /// Damage bounds for the edit ghost when it is visible: the original shape's /// bounding box, padded to cover the dashed border. `None` when no ghost /// shows. - fn text_edit_ghost_damage_bounds(&self) -> Option { + fn text_edit_ghost_damage_bounds(&self, measurer: &TextMeasurer) -> Option { if !self.text_edit_ghost_visible() { return None; } let (_, snapshot) = self.text_editing.edit_target()?; snapshot .shape - .bounding_box()? + .bounding_box_with(measurer)? .inflated(GHOST_DAMAGE_PADDING) } @@ -301,15 +327,18 @@ impl InputState { /// shares a layout instead; this stands alone for callers that have only a /// buffer and an offset. #[cfg(test)] - fn caret_damage_rect_for(&self, buffer: &str, x: i32, y: i32, caret: usize) -> Option { + fn caret_damage_rect_for( + &self, + measurer: &TextMeasurer, + buffer: &str, + x: i32, + y: i32, + caret: usize, + ) -> Option { let size = self.style.current_font_size; let font = self.style.font_descriptor.to_pango_string(size); - let geom = crate::draw::shape::caret_geometry_text( - buffer, - &font, - self.style.text_wrap_width, - caret, - )?; + let geom = + measurer.caret_geometry_text(buffer, &font, self.style.text_wrap_width, caret)?; caret_damage_rect(geom, x, y, size) } @@ -320,6 +349,10 @@ impl InputState { /// append-only "right edge of the preview" assumption. `None` outside text /// input or when no measurement context exists. pub(crate) fn caret_cursor_rect_canvas(&self) -> Option { + with_legacy_measurer(|measurer| self.caret_cursor_rect_canvas_with(measurer)) + } + + pub(crate) fn caret_cursor_rect_canvas_with(&self, measurer: &TextMeasurer) -> Option { let DrawingState::TextInput { x, y, .. } = &self.state else { return None; }; @@ -334,7 +367,7 @@ impl InputState { .style .font_descriptor .to_pango_string(self.style.current_font_size); - let geom = crate::draw::shape::caret_geometry_text( + let geom = measurer.caret_geometry_text( &preview.text, &font, self.style.text_wrap_width, @@ -485,6 +518,7 @@ mod tests { #[test] fn leading_whitespace_selection_damages_to_the_logical_left_edge() { + let measurer = TextMeasurer::default(); // Selecting leading spaces highlights logical cells that start at the // text origin (x), left of the ink box; the damage must reach them. let mut state = make_test_input_state(); @@ -501,7 +535,7 @@ mod tests { } let bounds = state - .compute_text_preview_bounds() + .compute_text_preview_bounds(&measurer) .expect("active text input has preview bounds"); assert!( bounds.x <= 100, @@ -512,6 +546,7 @@ mod tests { #[test] fn a_preedit_underline_is_damaged_below_the_baseline() { + let measurer = TextMeasurer::default(); // Pango draws the composition underline *below* the baseline. With text // that has no descenders the ink box stops at the baseline, so damage // built from ink alone leaves the underline behind when the block moves. @@ -523,9 +558,10 @@ mod tests { assert!(state.ime_apply_done()); let bounds = state - .compute_text_preview_bounds() + .compute_text_preview_bounds(&measurer) .expect("a composing block has preview bounds"); - let ink = bounding_box_for_text( + let ink = bounding_box_for_text_with( + &measurer, 100, 100, "kako", @@ -548,14 +584,16 @@ mod tests { #[test] fn preview_damage_carries_the_antialiasing_margin() { + let measurer = TextMeasurer::default(); // The preview is dragged under the pointer, so it damages a margin // around its exact geometry; a shortfall would trail stray pixels. let mut state = make_test_input_state(); state.state = DrawingState::text_input(100, 100, "kako".to_string()); let bounds = state - .compute_text_preview_bounds() + .compute_text_preview_bounds(&measurer) .expect("active text input has preview bounds"); - let ink = bounding_box_for_text( + let ink = bounding_box_for_text_with( + &measurer, 100, 100, "kako", @@ -576,6 +614,7 @@ mod tests { #[test] fn caret_damage_covers_the_stroke_around_a_fractional_caret_position() { + let measurer = TextMeasurer::default(); // The caret is stroked centred on its position: rounding the centre to a // pixel before subtracting a whole-pixel half-width can drop the leftmost // column the stroke touches, which is a 1px sliver per drag step. @@ -585,9 +624,9 @@ mod tests { .style .font_descriptor .to_pango_string(state.style.current_font_size); - let geom = crate::draw::shape::caret_geometry_text("hi", &font, None, 1).unwrap(); + let geom = measurer.caret_geometry_text("hi", &font, None, 1).unwrap(); let rect = state - .caret_damage_rect_for("hi", 100, 100, 1) + .caret_damage_rect_for(&measurer, "hi", 100, 100, 1) .expect("caret has a damage rect"); let half = crate::draw::caret_outline_width(state.style.current_font_size) / 2.0; let painted_left = 100.0 + geom.x - half; @@ -606,17 +645,18 @@ mod tests { #[test] fn caret_cursor_rect_tracks_the_caret_not_the_buffer_end() { + let measurer = TextMeasurer::default(); let mut state = make_test_input_state(); state.state = DrawingState::text_input(100, 100, "hello".to_string()); let end = state - .caret_cursor_rect_canvas() + .caret_cursor_rect_canvas_with(&measurer) .expect("caret at end has a rect"); if let DrawingState::TextInput { caret, .. } = &mut state.state { *caret = 0; } let start = state - .caret_cursor_rect_canvas() + .caret_cursor_rect_canvas_with(&measurer) .expect("caret at start has a rect"); assert!( @@ -633,6 +673,7 @@ mod tests { #[test] fn ime_cursor_rect_uses_the_selection_replacement_point() { + let measurer = TextMeasurer::default(); let mut state = make_test_input_state(); state.state = DrawingState::text_input(100, 100, "hello world".to_string()); if let DrawingState::TextInput { @@ -650,7 +691,7 @@ mod tests { state.ime_apply_done(); let rect = state - .caret_cursor_rect_canvas() + .caret_cursor_rect_canvas_with(&measurer) .expect("active preedit cursor has a rectangle"); assert!( @@ -662,14 +703,15 @@ mod tests { #[test] fn text_preview_damage_covers_the_full_caret_line() { + let measurer = TextMeasurer::default(); let mut state = make_test_input_state(); state.state = DrawingState::text_input(100, 100, "hi".to_string()); let bounds = state - .compute_text_preview_bounds() + .compute_text_preview_bounds(&measurer) .expect("an active text input has preview bounds"); let caret = state - .caret_damage_rect_for("hi", 100, 100, 2) + .caret_damage_rect_for(&measurer, "hi", 100, 100, 2) .expect("the caret has geometry"); // The caret's exact rect (mirroring the renderer) must fall inside the @@ -689,11 +731,12 @@ mod tests { #[test] fn text_preview_updates_coalesce_one_backend_cursor_request() { + let measurer = TextMeasurer::default(); let mut state = make_test_input_state(); state.state = DrawingState::text_input(100, 100, "hi".to_string()); - state.update_text_preview_dirty(); - state.update_text_preview_dirty(); + state.update_text_preview_dirty_with(&measurer); + state.update_text_preview_dirty_with(&measurer); assert!(state.take_text_input_cursor_rect_dirty()); assert!( @@ -704,17 +747,18 @@ mod tests { #[test] fn external_editor_changes_are_tracked_separately_from_ime_damage() { + let measurer = TextMeasurer::default(); let mut state = make_test_input_state(); state.state = DrawingState::text_input(100, 100, "hi".to_string()); - state.update_text_preview_dirty(); + state.update_text_preview_dirty_with(&measurer); assert!(state.take_text_input_cursor_rect_dirty()); assert!( !state.take_text_input_external_change_dirty(), "IME-driven preview damage keeps the protocol's InputMethod cause" ); - state.update_text_preview_dirty_from_editor(); + state.update_text_preview_dirty_from_editor_with(&measurer); assert!(state.take_text_input_cursor_rect_dirty()); assert!(state.take_text_input_external_change_dirty()); assert!( @@ -725,12 +769,13 @@ mod tests { #[test] fn empty_sticky_note_damage_covers_the_background_not_only_the_caret() { + let measurer = TextMeasurer::default(); let mut state = make_test_input_state(); state.text_editing.set_mode(TextInputMode::StickyNote); state.state = DrawingState::text_input(100, 100, String::new()); let bounds = state - .compute_text_preview_bounds() + .compute_text_preview_bounds(&measurer) .expect("empty sticky-note preview has damage bounds"); assert!( diff --git a/src/input/state/core/properties/apply_selection/helpers.rs b/src/input/state/core/properties/apply_selection/helpers.rs index 19dd72ff2..fcae0bc5a 100644 --- a/src/input/state/core/properties/apply_selection/helpers.rs +++ b/src/input/state/core/properties/apply_selection/helpers.rs @@ -1,6 +1,6 @@ use super::super::super::base::InputState; use super::super::summary::shape_color; -use crate::draw::{Color, Shape, TextMeasurer, with_legacy_measurer}; +use crate::draw::{Color, Shape, TextMeasurer}; use crate::input::state::{Toast, ToastPriority}; #[derive(Default)] @@ -55,20 +55,6 @@ impl InputState { if mixed { Some(true) } else { Some(!first) } } - pub(in crate::input::state::core) fn apply_selection_change( - &mut self, - applicable: A, - apply: F, - ) -> SelectionApplyResult - where - A: FnMut(&Shape) -> bool, - F: FnMut(&mut Shape) -> bool, - { - with_legacy_measurer(|measurer| { - self.apply_selection_change_with(measurer, applicable, apply) - }) - } - pub(in crate::input::state::core) fn apply_selection_change_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/properties/apply_selection/helpers/tests.rs b/src/input/state/core/properties/apply_selection/helpers/tests.rs index ea7d525c1..0825bb2bc 100644 --- a/src/input/state/core/properties/apply_selection/helpers/tests.rs +++ b/src/input/state/core/properties/apply_selection/helpers/tests.rs @@ -216,7 +216,8 @@ fn apply_selection_change_reports_applicable_locked_and_changed_counts() { state.needs_redraw = false; state.clear_session_dirty(); - let result = state.apply_selection_change( + let result = state.apply_selection_change_with( + &TextMeasurer::default(), |shape| matches!(shape, Shape::Rect { .. }), |shape| match shape { Shape::Rect { fill, .. } => { diff --git a/src/input/state/core/selection_actions/measurement_tests/mutations.rs b/src/input/state/core/selection_actions/measurement_tests/mutations.rs index 837de20dc..5931e4b53 100644 --- a/src/input/state/core/selection_actions/measurement_tests/mutations.rs +++ b/src/input/state/core/selection_actions/measurement_tests/mutations.rs @@ -149,3 +149,40 @@ fn explicit_keyboard_menu_anchor_projects_decorated_selection_with_pan_and_zoom( state.toggle_context_menu_via_keyboard_with(&owner); assert!(!state.is_context_menu_open()); } + +#[test] +fn explicit_cancellation_restores_decorated_move_bounds_and_hits() { + for (shape, probe) in fixtures() { + let owner = TextMeasurer::default(); + let (mut state, id, locked_id) = state_with(shape); + let original = bounds(&state, &owner, id); + let locked = bounds(&state, &owner, locked_id); + let snapshots = state.capture_movable_selection_snapshots(); + assert_eq!(state.hit_test_at_with(&owner, probe.0, probe.1 + 300), None); + state.hit_test_at_with(&owner, probe.0, probe.1); + assert!(state.apply_translation_to_selection_with(&owner, 0, 300)); + let moved = bounds(&state, &owner, id); + assert_ne!(moved, original); + assert_eq!( + state.hit_test_at_with(&owner, probe.0, probe.1 + 300), + Some(id) + ); + state.state = crate::input::DrawingState::MovingSelection { + last_x: probe.0, + last_y: probe.1 + 300, + snapshots, + moved: true, + }; + state.take_dirty_regions(); + assert!(state.try_cancel_active_interaction_with(&owner)); + assert!(matches!(state.state, crate::input::DrawingState::Idle)); + assert_eq!(bounds(&state, &owner, id), original); + assert_eq!(bounds(&state, &owner, locked_id), locked); + assert_eq!(state.hit_test_at_with(&owner, probe.0, probe.1), Some(id)); + assert_eq!(state.hit_test_at_with(&owner, probe.0, probe.1 + 300), None); + let dirty = state.take_dirty_regions(); + assert_dirty_covers(&dirty, original); + assert_dirty_covers(&dirty, moved); + assert!(!state.try_cancel_active_interaction_with(&owner)); + } +} diff --git a/src/input/state/core/selection_actions/resize.rs b/src/input/state/core/selection_actions/resize.rs index 8d7ba6ab4..bf96b8daa 100644 --- a/src/input/state/core/selection_actions/resize.rs +++ b/src/input/state/core/selection_actions/resize.rs @@ -112,12 +112,6 @@ impl InputState { } /// Restore shapes from snapshots (used for cancel). - pub(crate) fn restore_resize_from_snapshots(&mut self, snapshots: &[(ShapeId, ShapeSnapshot)]) { - with_legacy_measurer(|measurer| { - self.restore_resize_from_snapshots_with(measurer, snapshots) - }) - } - pub(crate) fn restore_resize_from_snapshots_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/text/edit.rs b/src/input/state/core/selection_actions/text/edit.rs index ab7e96b34..2121da22c 100644 --- a/src/input/state/core/selection_actions/text/edit.rs +++ b/src/input/state/core/selection_actions/text/edit.rs @@ -1,8 +1,13 @@ +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::{DrawingState, InputState}; use std::time::Instant; impl InputState { pub(crate) fn edit_selected_text(&mut self) -> bool { + with_legacy_measurer(|measurer| self.edit_selected_text_with(measurer)) + } + + pub(crate) fn edit_selected_text_with(&mut self, measurer: &TextMeasurer) -> bool { if self.selected_shape_ids().len() != 1 { return false; } @@ -21,10 +26,11 @@ impl InputState { } if matches!(self.state, DrawingState::TextInput { .. }) { - self.cancel_text_input(); + self.cancel_text_input_with(measurer); } let Some(start) = self.text_editing.begin_existing( + measurer, self.boards.active_frame_mut(), shape_id, Instant::now(), @@ -46,32 +52,37 @@ impl InputState { } self.style.text_wrap_width = start.wrap_width; self.state = DrawingState::text_input(start.x, start.y, start.text); - self.update_text_preview_dirty(); + self.update_text_preview_dirty_with(measurer); self.dirty_tracker.mark_optional_rect(start.before_bounds); self.dirty_tracker.mark_optional_rect(start.after_bounds); - self.invalidate_hit_cache_for(start.shape_id); + self.invalidate_hit_cache_for_with(measurer, start.shape_id); self.needs_redraw = true; true } - pub(crate) fn cancel_text_edit(&mut self) -> bool { + pub(crate) fn cancel_text_edit_with(&mut self, measurer: &TextMeasurer) -> bool { let Some(change) = self .text_editing - .cancel_existing(self.boards.active_frame_mut()) + .cancel_existing(measurer, self.boards.active_frame_mut()) else { return false; }; self.dirty_tracker.mark_optional_rect(change.before_bounds); self.dirty_tracker.mark_optional_rect(change.after_bounds); - self.invalidate_hit_cache_for(change.shape_id); + self.invalidate_hit_cache_for_with(measurer, change.shape_id); self.needs_redraw = true; true } - pub(crate) fn commit_text_edit(&mut self, new_shape: crate::draw::Shape) -> bool { + pub(crate) fn commit_text_edit_with( + &mut self, + measurer: &TextMeasurer, + new_shape: crate::draw::Shape, + ) -> bool { let undo_limit = self.history_limits.undo_stack_limit(); let Some(change) = self.text_editing.commit_existing( + measurer, self.boards.active_frame_mut(), new_shape, undo_limit, @@ -80,7 +91,7 @@ impl InputState { }; self.dirty_tracker.mark_optional_rect(change.before_bounds); self.dirty_tracker.mark_optional_rect(change.after_bounds); - self.invalidate_hit_cache_for(change.shape_id); + self.invalidate_hit_cache_for_with(measurer, change.shape_id); self.needs_redraw = true; self.mark_session_dirty(); true diff --git a/src/input/state/core/selection_actions/translation/restore.rs b/src/input/state/core/selection_actions/translation/restore.rs index cbf37a0d3..5e618f0f5 100644 --- a/src/input/state/core/selection_actions/translation/restore.rs +++ b/src/input/state/core/selection_actions/translation/restore.rs @@ -1,18 +1,9 @@ use crate::draw::ShapeId; +use crate::draw::TextMeasurer; use crate::draw::frame::ShapeSnapshot; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; impl InputState { - pub(crate) fn restore_selection_from_snapshots( - &mut self, - snapshots: Vec<(ShapeId, ShapeSnapshot)>, - ) { - with_legacy_measurer(|measurer| { - self.restore_selection_from_snapshots_with(measurer, snapshots) - }) - } - pub(crate) fn restore_selection_from_snapshots_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/text_editing.rs b/src/input/state/core/text_editing.rs index 2ca8252da..949a6765e 100644 --- a/src/input/state/core/text_editing.rs +++ b/src/input/state/core/text_editing.rs @@ -1,7 +1,7 @@ use super::base::{DrawingState, TextInputMode}; use super::ime::ImeCompositionState; use crate::draw::frame::{Frame, ShapeSnapshot, UndoAction}; -use crate::draw::{Color, FontDescriptor, Shape, ShapeId}; +use crate::draw::{Color, FontDescriptor, Shape, ShapeId, TextMeasurer}; use crate::util::Rect; pub(super) const TEXT_EDIT_ENTRY_DURATION_MS: u64 = 200; @@ -251,6 +251,7 @@ impl TextEditing { /// the live editor owns it. pub(crate) fn begin_existing( &mut self, + measurer: &TextMeasurer, frame: &mut Frame, shape_id: ShapeId, now: std::time::Instant, @@ -308,13 +309,13 @@ impl TextEditing { }; let shape = frame.shape_mut(shape_id)?; - let before_bounds = shape.bounding_box(); + let before_bounds = shape.bounding_box_with(measurer); match &mut shape.shape { Shape::Text { text, .. } | Shape::StickyNote { text, .. } => text.clear(), _ => return None, } shape.invalidate_bounds(); - let after_bounds = shape.bounding_box(); + let after_bounds = shape.bounding_box_with(measurer); self.text_input_mode = mode; self.text_edit_target = Some((shape_id, snapshot)); @@ -337,13 +338,17 @@ impl TextEditing { }) } - pub(crate) fn cancel_existing(&mut self, frame: &mut Frame) -> Option { + pub(crate) fn cancel_existing( + &mut self, + measurer: &TextMeasurer, + frame: &mut Frame, + ) -> Option { let (shape_id, snapshot) = self.text_edit_target.take()?; let shape = frame.shape_mut(shape_id)?; - let before_bounds = shape.bounding_box(); + let before_bounds = shape.bounding_box_with(measurer); shape.set_shape(snapshot.shape); shape.locked = snapshot.locked; - let after_bounds = shape.bounding_box(); + let after_bounds = shape.bounding_box_with(measurer); Some(TextShapeChange { shape_id, before_bounds, @@ -353,15 +358,16 @@ impl TextEditing { pub(crate) fn commit_existing( &mut self, + measurer: &TextMeasurer, frame: &mut Frame, new_shape: Shape, undo_stack_limit: usize, ) -> Option { let (shape_id, before) = self.text_edit_target.take()?; let shape = frame.shape_mut(shape_id)?; - let before_bounds = shape.bounding_box(); + let before_bounds = shape.bounding_box_with(measurer); shape.set_shape(new_shape); - let after_bounds = shape.bounding_box(); + let after_bounds = shape.bounding_box_with(measurer); let after = ShapeSnapshot { shape: shape.shape.clone(), locked: shape.locked, @@ -422,10 +428,11 @@ mod tests { fn existing_edit_hides_then_restores_the_original_shape() { let mut frame = Frame::new(); let shape_id = frame.add_shape(text_shape("before")); + let measurer = TextMeasurer::default(); let mut editing = TextEditing::default(); let started = editing - .begin_existing(&mut frame, shape_id, std::time::Instant::now()) + .begin_existing(&measurer, &mut frame, shape_id, std::time::Instant::now()) .expect("editable text shape"); assert_eq!(started.text, "before"); @@ -436,7 +443,7 @@ mod tests { )); let restored = editing - .cancel_existing(&mut frame) + .cancel_existing(&measurer, &mut frame) .expect("active edit restores"); assert_eq!(restored.shape_id, shape_id); assert!(editing.edit_target().is_none()); @@ -450,13 +457,14 @@ mod tests { fn committing_an_existing_edit_records_one_undoable_replacement() { let mut frame = Frame::new(); let shape_id = frame.add_shape(text_shape("before")); + let measurer = TextMeasurer::default(); let mut editing = TextEditing::default(); editing - .begin_existing(&mut frame, shape_id, std::time::Instant::now()) + .begin_existing(&measurer, &mut frame, shape_id, std::time::Instant::now()) .expect("editable text shape"); editing - .commit_existing(&mut frame, text_shape("after"), 10) + .commit_existing(&measurer, &mut frame, text_shape("after"), 10) .expect("active edit commits"); assert!(editing.edit_target().is_none()); diff --git a/src/input/state/core/text_font.rs b/src/input/state/core/text_font.rs index ee7be10db..9ab880ddd 100644 --- a/src/input/state/core/text_font.rs +++ b/src/input/state/core/text_font.rs @@ -8,6 +8,7 @@ use super::InputState; use crate::draw::{FontDescriptor, Shape, families_match}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; fn text_font_descriptor(shape: &Shape) -> Option<&FontDescriptor> { match shape { @@ -94,9 +95,13 @@ impl InputState { /// so turning bold off from here lands on `normal` rather than restoring /// whatever number was there. pub(crate) fn set_font_bold(&mut self, bold: bool) -> bool { + with_legacy_measurer(|measurer| self.set_font_bold_with(measurer, bold)) + } + + pub(crate) fn set_font_bold_with(&mut self, measurer: &TextMeasurer, bold: bool) -> bool { let weight = if bold { "bold" } else { "normal" }; if self.selection_has_text() { - return self.apply_weight_to_selected_text(weight); + return self.apply_weight_to_selected_text(measurer, weight); } let descriptor = crate::draw::FontDescriptor::new( self.style.font_descriptor.family.clone(), @@ -107,9 +112,9 @@ impl InputState { } /// Restyle every selected text shape to `weight`. - fn apply_weight_to_selected_text(&mut self, weight: &str) -> bool { + fn apply_weight_to_selected_text(&mut self, measurer: &TextMeasurer, weight: &str) -> bool { let target = weight.to_string(); - self.apply_descriptor_to_selected_text("weight", move |descriptor| { + self.apply_descriptor_to_selected_text(measurer, "weight", move |descriptor| { if descriptor.weight.eq_ignore_ascii_case(&target) { return false; } @@ -127,9 +132,17 @@ impl InputState { pub(in crate::input::state::core) fn apply_family_to_selected_text( &mut self, family: &str, + ) -> bool { + with_legacy_measurer(|measurer| self.apply_family_to_selected_text_with(measurer, family)) + } + + pub(in crate::input::state::core) fn apply_family_to_selected_text_with( + &mut self, + measurer: &TextMeasurer, + family: &str, ) -> bool { let target = family.to_string(); - self.apply_descriptor_to_selected_text("font", move |descriptor| { + self.apply_descriptor_to_selected_text(measurer, "font", move |descriptor| { if families_match(&descriptor.family, &target) { return false; } @@ -142,10 +155,12 @@ impl InputState { /// shape, preserving shared lock, undo, damage, and partial-result reporting. fn apply_descriptor_to_selected_text( &mut self, + measurer: &TextMeasurer, property: &'static str, mut apply: impl FnMut(&mut FontDescriptor) -> bool, ) -> bool { - let result = self.apply_selection_change( + let result = self.apply_selection_change_with( + measurer, |shape| text_font_descriptor(shape).is_some(), move |shape| text_font_descriptor_mut(shape).is_some_and(&mut apply), ); diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index 7dee11389..cd624bc98 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -1,5 +1,6 @@ use super::super::base::{DrawingState, InputState, PasteAnchor}; use crate::draw::DirtyRegionReport; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::util::Rect; use std::time::Instant; @@ -150,7 +151,11 @@ impl InputState { /// Cancels the current text input session and restores any edited shape. pub(crate) fn cancel_text_input(&mut self) { - self.cancel_text_edit(); + with_legacy_measurer(|measurer| self.cancel_text_input_with(measurer)) + } + + pub(crate) fn cancel_text_input_with(&mut self, measurer: &TextMeasurer) { + self.cancel_text_edit_with(measurer); self.end_text_input_session(); } @@ -169,21 +174,29 @@ impl InputState { /// /// Returns `true` when an active interaction consumed the caller's event. pub(crate) fn try_cancel_active_interaction(&mut self) -> bool { + with_legacy_measurer(|measurer| self.try_cancel_active_interaction_with(measurer)) + } + + pub(crate) fn try_cancel_active_interaction_with(&mut self, measurer: &TextMeasurer) -> bool { if matches!(self.state, DrawingState::Idle) { return false; } - self.cancel_active_interaction(); + self.cancel_active_interaction_with(measurer); true } /// Cancels any in-progress interaction without exiting the application. pub(crate) fn cancel_active_interaction(&mut self) { + with_legacy_measurer(|measurer| self.cancel_active_interaction_with(measurer)) + } + + pub(crate) fn cancel_active_interaction_with(&mut self, measurer: &TextMeasurer) { // A canceled interaction never leaves a dangling block-move drag. self.text_editing.set_text_block_drag(None); match &self.state { DrawingState::TextInput { .. } => { - self.cancel_text_input(); + self.cancel_text_input_with(measurer); } DrawingState::PendingTextClick { .. } => { self.state = DrawingState::Idle; @@ -200,7 +213,7 @@ impl InputState { self.needs_redraw = true; } DrawingState::MovingSelection { snapshots, .. } => { - self.restore_selection_from_snapshots(snapshots.clone()); + self.restore_selection_from_snapshots_with(measurer, snapshots.clone()); self.state = DrawingState::Idle; } DrawingState::Selecting { .. } => { @@ -211,17 +224,23 @@ impl InputState { DrawingState::ResizingText { shape_id, snapshot, .. } => { - self.restore_selection_from_snapshots(vec![(*shape_id, snapshot.clone())]); + self.restore_selection_from_snapshots_with( + measurer, + vec![(*shape_id, snapshot.clone())], + ); self.state = DrawingState::Idle; } DrawingState::BendingArrow { shape_id, snapshot } | DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot } => { - self.restore_selection_from_snapshots(vec![(*shape_id, snapshot.clone())]); + self.restore_selection_from_snapshots_with( + measurer, + vec![(*shape_id, snapshot.clone())], + ); self.state = DrawingState::Idle; } DrawingState::ResizingSelection { snapshots, .. } => { let snapshots = snapshots.clone(); - self.restore_resize_from_snapshots(snapshots.as_ref()); + self.restore_resize_from_snapshots_with(measurer, snapshots.as_ref()); self.state = DrawingState::Idle; } DrawingState::Idle => {} diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index 7ffcf6ce8..648d10a5a 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -1,4 +1,5 @@ use crate::draw::Shape; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::tool::ToolPressBehavior; use crate::input::{DragTool, Tool, events::MouseButton}; use std::sync::Arc; @@ -223,6 +224,18 @@ impl InputState { canvas_x: i32, canvas_y: i32, color: Option, + ) -> bool { + with_legacy_measurer(|measurer| { + self.handle_text_input_left_press_with(measurer, canvas_x, canvas_y, color) + }) + } + + pub(in crate::input::state) fn handle_text_input_left_press_with( + &mut self, + measurer: &TextMeasurer, + canvas_x: i32, + canvas_y: i32, + color: Option, ) -> bool { if !matches!(self.state, DrawingState::TextInput { .. }) { return false; @@ -251,9 +264,9 @@ impl InputState { *selection_anchor = None; } self.needs_redraw = true; - self.update_text_preview_dirty_from_editor(); + self.update_text_preview_dirty_from_editor_with(measurer); } else { - self.place_text_caret_at_canvas(canvas_x, canvas_y, self.modifiers.shift); + self.place_text_caret_at_canvas(measurer, canvas_x, canvas_y, self.modifiers.shift); } true } @@ -265,7 +278,13 @@ impl InputState { /// one formula covers both. Active IME composition is hit-tested against /// the same effective preview as rendering, then mapped back to committed /// buffer coordinates. - fn place_text_caret_at_canvas(&mut self, canvas_x: i32, canvas_y: i32, extend: bool) { + fn place_text_caret_at_canvas( + &mut self, + measurer: &TextMeasurer, + canvas_x: i32, + canvas_y: i32, + extend: bool, + ) { let offset = { let DrawingState::TextInput { x, y, .. } = &self.state else { return; @@ -282,7 +301,7 @@ impl InputState { .style .font_descriptor .to_pango_string(self.style.current_font_size); - let Some(preview_offset) = crate::draw::shape::hit_test_text( + let Some(preview_offset) = measurer.hit_test_text( &preview.text, &font, self.style.text_wrap_width, @@ -309,7 +328,7 @@ impl InputState { } *caret = offset; self.needs_redraw = true; - self.update_text_preview_dirty_from_editor(); + self.update_text_preview_dirty_from_editor_with(measurer); } } @@ -341,11 +360,20 @@ impl InputState { /// Update the active text block's origin from a canvas-space pointer during /// an Alt+drag, preserving the grab offset. No-op when not dragging. pub(in crate::input::state) fn drag_text_block_to(&mut self, canvas_x: i32, canvas_y: i32) { + with_legacy_measurer(|measurer| self.drag_text_block_to_with(measurer, canvas_x, canvas_y)) + } + + pub(in crate::input::state) fn drag_text_block_to_with( + &mut self, + measurer: &TextMeasurer, + canvas_x: i32, + canvas_y: i32, + ) { if self .text_editing .drag_block_to(&mut self.state, canvas_x, canvas_y) { - self.update_text_preview_dirty(); + self.update_text_preview_dirty_with(measurer); self.needs_redraw = true; } } diff --git a/src/input/state/tests/text_edit/commit_cancel.rs b/src/input/state/tests/text_edit/commit_cancel.rs index 371702bba..93b753881 100644 --- a/src/input/state/tests/text_edit/commit_cancel.rs +++ b/src/input/state/tests/text_edit/commit_cancel.rs @@ -2,6 +2,7 @@ use super::*; #[test] fn edit_selected_text_commit_updates_and_undo() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Text { x: 100, @@ -15,7 +16,7 @@ fn edit_selected_text_commit_updates_and_undo() { }); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&measurer)); if let DrawingState::TextInput { buffer, .. } = &mut state.state { buffer.push_str(" world"); @@ -49,6 +50,7 @@ fn edit_selected_text_commit_updates_and_undo() { #[test] fn edit_selected_text_cancel_restores_original() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Text { x: 40, @@ -62,7 +64,7 @@ fn edit_selected_text_cancel_restores_original() { }); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&measurer)); if let DrawingState::TextInput { buffer, .. } = &mut state.state { buffer.push_str(" edit"); @@ -70,7 +72,7 @@ fn edit_selected_text_cancel_restores_original() { panic!("Expected text input state"); } - state.cancel_text_input(); + state.cancel_text_input_with(&measurer); assert!(matches!(state.state, DrawingState::Idle)); assert!(state.text_editing.edit_target().is_none()); @@ -85,6 +87,7 @@ fn edit_selected_text_cancel_restores_original() { #[test] fn edit_selected_sticky_note_commit_updates_and_undo() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let background = Color { r: 0.9, @@ -106,7 +109,7 @@ fn edit_selected_sticky_note_commit_updates_and_undo() { }); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&measurer)); if let DrawingState::TextInput { buffer, .. } = &mut state.state { buffer.push_str(" updated"); @@ -154,6 +157,7 @@ fn edit_selected_sticky_note_commit_updates_and_undo() { #[test] fn edit_selected_sticky_note_cancel_restores_original() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let background = Color { r: 0.3, @@ -175,7 +179,7 @@ fn edit_selected_sticky_note_cancel_restores_original() { }); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&measurer)); if let DrawingState::TextInput { buffer, .. } = &mut state.state { buffer.push_str(" edit"); @@ -183,7 +187,7 @@ fn edit_selected_sticky_note_cancel_restores_original() { panic!("Expected text input state"); } - state.cancel_text_input(); + state.cancel_text_input_with(&measurer); assert!(matches!(state.state, DrawingState::Idle)); assert!(state.text_editing.edit_target().is_none()); diff --git a/src/input/state/tests/transform.rs b/src/input/state/tests/transform.rs index 380d319fd..6f59be27c 100644 --- a/src/input/state/tests/transform.rs +++ b/src/input/state/tests/transform.rs @@ -355,7 +355,7 @@ fn restore_selection_snapshots_reverts_translation() { assert_eq!(snapshots.len(), 1); assert!(state.apply_translation_to_selection(20, 30)); - state.restore_selection_from_snapshots(snapshots); + state.restore_selection_from_snapshots_with(&crate::draw::TextMeasurer::default(), snapshots); let frame = state.boards.active_frame(); let shape = frame.shape(shape_id).unwrap(); diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index 6b66d6e38..e44b4907c 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -4,7 +4,7 @@ use crate::draw::shape::{ }; use crate::draw::{ ArrowLabel, ArrowStyle, BlurRectParams, BlurStyle, Color, EraserBrush, EraserKind, Shape, - TextMeasurer, with_legacy_measurer, + TextMeasurer, }; use crate::input::tool::{ EraserMode, Tool, ToolDrawingBehavior, ToolPathKind, ToolPressureBehavior, @@ -409,10 +409,6 @@ impl Tool { } impl<'a> ProvisionalToolStroke<'a> { - pub(crate) fn bounds(&self) -> Option { - with_legacy_measurer(|measurer| self.bounds_with(measurer)) - } - pub(crate) fn bounds_with(&self, measurer: &TextMeasurer) -> Option { match self { Self::BorrowedFreehand { points, size, .. } => bounding_box_for_points(points, *size), diff --git a/src/input/tool/tests.rs b/src/input/tool/tests.rs index abaefe806..aaf1a5e80 100644 --- a/src/input/tool/tests.rs +++ b/src/input/tool/tests.rs @@ -181,6 +181,7 @@ fn marker_opacity_helper_preserves_current_alpha_clamp() { #[test] fn provisional_polygon_bounds_include_extra_preview_padding() { + let measurer = crate::draw::TextMeasurer::default(); let stroke = Tool::Triangle.provisional_polygon_stroke(PolygonProvisionalSnapshot { tool: Tool::Triangle, start: (10, 10), @@ -198,7 +199,7 @@ fn provisional_polygon_bounds_include_extra_preview_padding() { .bounding_box() .expect("polygon preview should have bounds"); assert_eq!( - stroke.bounds(), + stroke.bounds_with(&measurer), base.inflated(PROVISIONAL_POLYGON_DAMAGE_PADDING), "polygon drag preview damage should clear antialias leftovers" ); From 513681bc21f5346f4b7fd51afaabfaa457b58d94 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:41:27 +0200 Subject: [PATCH 18/42] refactor(text): share resources across board and properties panels --- src/backend/wayland/state/render/runtime.rs | 9 ++ src/backend/wayland/state/render/ui.rs | 36 +++++- src/input/state/core/menus/layout.rs | 23 +++- src/input/state/core/properties/panel.rs | 27 +++-- .../core/properties/panel_layout/layout.rs | 113 +++++++++++++++++- src/ui.rs | 2 + src/ui/board_picker.rs | 24 ++-- src/ui/board_picker/page_panel.rs | 103 +++++++++------- .../page_panel/thumbnail/cards.rs | 90 ++++++++------ .../page_panel/thumbnail/content.rs | 113 ++++++++++-------- src/ui/board_picker/rows.rs | 23 ++-- src/ui/board_picker/tests.rs | 65 ++++++++++ src/ui/context_menu.rs | 84 ++++++++++++- src/ui/properties_panel.rs | 28 ++++- 14 files changed, 557 insertions(+), 183 deletions(-) create mode 100644 src/ui/board_picker/tests.rs diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index e663a0c16..d1519cbb7 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -179,6 +179,15 @@ impl RenderRuntime { &mut self.draw_caches } + pub(in crate::backend::wayland::state) fn draw_ui_text_parts_mut( + &mut self, + ) -> ( + &mut crate::draw::RenderCaches, + &crate::ui_text::UiTextEngine, + ) { + (&mut self.draw_caches, &self.ui_text) + } + pub(in crate::backend::wayland::state) fn canvas_draw_parts_mut( &mut self, ) -> (&mut CanvasLayerCache, &mut crate::draw::RenderCaches) { diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 84239b56c..52854db00 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -235,8 +235,10 @@ impl WaylandState { if !capture_picker && self.input_state.is_board_picker_open() { self.input_state .update_board_picker_layout(ctx, width, height); - let mut render = crate::draw::RenderCtx::new(ctx, self.render.draw_caches_mut()); + let (caches, engine) = self.render.draw_ui_text_parts_mut(); + let mut render = crate::draw::RenderCtx::new(ctx, caches); crate::ui::render_board_picker_with_halo( + engine, &mut render, &self.input_state, width, @@ -343,18 +345,40 @@ impl WaylandState { } if self.input_state.is_properties_panel_open() { self.input_state - .update_properties_panel_layout(ctx, width, height); + .update_properties_panel_layout_with_resources( + self.render.ui_text(), + self.render.text_measurer(), + ctx, + width, + height, + ); } else { self.input_state.clear_properties_panel_layout(); } - crate::ui::render_properties_panel(ctx, &self.input_state, width, height); + crate::ui::render_properties_panel_with_engine( + self.render.ui_text(), + ctx, + &self.input_state, + width, + height, + ); if self.input_state.is_context_menu_open() { - self.input_state - .update_context_menu_layout(ctx, width, height); + self.input_state.update_context_menu_layout_with_engine( + self.render.ui_text(), + ctx, + width, + height, + ); } else { self.input_state.clear_context_menu_layout(); } - crate::ui::render_context_menu(ctx, &self.input_state, width, height); + crate::ui::render_context_menu_with_engine( + self.render.ui_text(), + ctx, + &self.input_state, + width, + height, + ); } fn render_inline_and_modal_ui( diff --git a/src/input/state/core/menus/layout.rs b/src/input/state/core/menus/layout.rs index f8a841eb0..1e7ec5a81 100644 --- a/src/input/state/core/menus/layout.rs +++ b/src/input/state/core/menus/layout.rs @@ -1,6 +1,6 @@ use super::super::base::InputState; use super::types::{ContextMenuCursorHint, ContextMenuLayout, ContextMenuState}; -use crate::ui_text::{UiTextStyle, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use crate::util::Rect; use cairo::Context as CairoContext; @@ -22,6 +22,21 @@ impl InputState { ctx: &CairoContext, screen_width: u32, screen_height: u32, + ) { + self.update_context_menu_layout_with_engine( + &UiTextEngine::default(), + ctx, + screen_width, + screen_height, + ); + } + + pub(crate) fn update_context_menu_layout_with_engine( + &mut self, + engine: &UiTextEngine, + ctx: &CairoContext, + screen_width: u32, + screen_height: u32, ) { if !self.is_context_menu_open() { self.context_menu.layout = None; @@ -52,10 +67,12 @@ impl InputState { let mut max_label_width: f64 = 0.0; let mut max_shortcut_width: f64 = 0.0; for entry in &entries { - let extents = text_layout(ctx, text_style, &entry.label, None).ink_extents(); + let extents = engine + .layout(ctx, text_style, &entry.label, None) + .ink_extents(); max_label_width = max_label_width.max(extents.width()); if let Some(shortcut) = &entry.shortcut { - let extents = text_layout(ctx, text_style, shortcut, None).ink_extents(); + let extents = engine.layout(ctx, text_style, shortcut, None).ink_extents(); max_shortcut_width = max_shortcut_width.max(extents.width()); } } diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs index b76328f50..8548a4af3 100644 --- a/src/input/state/core/properties/panel.rs +++ b/src/input/state/core/properties/panel.rs @@ -130,10 +130,6 @@ impl InputState { true } - pub(super) fn refresh_properties_panel(&mut self) { - with_legacy_measurer(|measurer| self.refresh_properties_panel_with(measurer)) - } - pub(super) fn refresh_properties_panel_with(&mut self, measurer: &TextMeasurer) { self.properties.begin_refresh(); let update = (|| { @@ -266,20 +262,22 @@ mod tests { #[test] fn show_properties_panel_returns_false_without_selection() { let mut state = make_state(); + let measurer = TextMeasurer::default(); - assert!(!state.show_properties_panel()); + assert!(!state.show_properties_panel_with(&measurer)); assert!(state.properties_panel().is_none()); } #[test] fn refresh_properties_panel_closes_panel_when_selection_is_empty() { let mut state = make_state(); + let measurer = TextMeasurer::default(); let shape_id = add_rect(&mut state, 10, 20, 30, 40); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&measurer)); state.selection_interaction.clear(); - state.refresh_properties_panel(); + state.refresh_properties_panel_with(&measurer); assert!(state.properties_panel().is_none()); } @@ -287,9 +285,10 @@ mod tests { #[test] fn refresh_properties_panel_preserves_valid_keyboard_focus() { let mut state = make_state(); + let measurer = TextMeasurer::default(); let shape_id = add_rect(&mut state, 10, 20, 30, 40); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&measurer)); state .properties .panel @@ -297,7 +296,7 @@ mod tests { .expect("panel") .keyboard_focus = Some(0); - state.refresh_properties_panel(); + state.refresh_properties_panel_with(&measurer); assert_eq!( state @@ -310,14 +309,15 @@ mod tests { #[test] fn refresh_properties_panel_clears_invalid_focus_and_hover() { let mut state = make_state(); + let measurer = TextMeasurer::default(); let shape_id = add_rect(&mut state, 10, 20, 30, 40); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&measurer)); let panel = state.properties.panel.as_mut().expect("panel"); panel.keyboard_focus = Some(99); panel.hover_index = Some(99); - state.refresh_properties_panel(); + state.refresh_properties_panel_with(&measurer); let panel = state.properties_panel().expect("panel after refresh"); assert_eq!(panel.keyboard_focus, None); @@ -327,13 +327,14 @@ mod tests { #[test] fn refresh_properties_panel_updates_summary_when_selection_expands() { let mut state = make_state(); + let measurer = TextMeasurer::default(); let first = add_rect(&mut state, 10, 20, 30, 40); let second = add_rect(&mut state, 80, 30, 20, 10); state.set_selection(vec![first]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&measurer)); set_selection_state(&mut state, vec![first, second]); - state.refresh_properties_panel(); + state.refresh_properties_panel_with(&measurer); let panel = state.properties_panel().expect("panel after refresh"); assert_eq!(panel.title, "Selection Properties"); diff --git a/src/input/state/core/properties/panel_layout/layout.rs b/src/input/state/core/properties/panel_layout/layout.rs index 6b87862ab..f7204c0dd 100644 --- a/src/input/state/core/properties/panel_layout/layout.rs +++ b/src/input/state/core/properties/panel_layout/layout.rs @@ -7,7 +7,7 @@ use super::{ PANEL_MARGIN, PANEL_PADDING_X, PANEL_PADDING_Y, PANEL_ROW_HEIGHT, PANEL_SECTION_GAP, PANEL_TITLE_FONT, }; -use crate::ui_text::{UiTextStyle, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use crate::util::Rect; impl InputState { @@ -20,9 +20,26 @@ impl InputState { ctx: &CairoContext, screen_width: u32, screen_height: u32, + ) { + self.update_properties_panel_layout_with_resources( + &UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), + ctx, + screen_width, + screen_height, + ); + } + + pub(crate) fn update_properties_panel_layout_with_resources( + &mut self, + engine: &UiTextEngine, + measurer: &crate::draw::TextMeasurer, + ctx: &CairoContext, + screen_width: u32, + screen_height: u32, ) { if self.properties.needs_refresh() { - self.refresh_properties_panel(); + self.refresh_properties_panel_with(measurer); } let Some(panel) = self.properties.panel.as_ref() else { self.properties.layout = None; @@ -46,17 +63,23 @@ impl InputState { weight: cairo::FontWeight::Normal, size: PANEL_BODY_FONT, }; - let extents = text_layout(ctx, title_style, &panel.title, None).ink_extents(); + let extents = engine + .layout(ctx, title_style, &panel.title, None) + .ink_extents(); max_line_width = max_line_width.max(extents.width()); for line in &panel.lines { - let extents = text_layout(ctx, body_style, line, None).ink_extents(); + let extents = engine.layout(ctx, body_style, line, None).ink_extents(); max_line_width = max_line_width.max(extents.width()); } for entry in &panel.entries { - let extents = text_layout(ctx, body_style, &entry.label, None).ink_extents(); + let extents = engine + .layout(ctx, body_style, &entry.label, None) + .ink_extents(); max_label_width = max_label_width.max(extents.width()); - let extents = text_layout(ctx, body_style, &entry.value, None).ink_extents(); + let extents = engine + .layout(ctx, body_style, &entry.value, None) + .ink_extents(); max_value_width = max_value_width.max(extents.width()); } let _ = ctx.restore(); @@ -214,3 +237,81 @@ fn mark_properties_panel_region(state: &mut InputState, layout: PropertiesPanelL state.dirty_tracker.mark_full(); } } + +#[cfg(test)] +mod engine_tests { + use super::*; + use crate::draw::{Shape, TextMeasurer}; + + fn paint(engine: &UiTextEngine, state: &InputState) -> Vec { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + crate::ui::render_properties_panel_with_engine(engine, &ctx, state, 800, 600); + } + surface.data().unwrap().to_vec() + } + + #[test] + fn deferred_property_refresh_uses_both_owners_before_layout_and_paint() { + let engine = UiTextEngine::default(); + let measurer = TextMeasurer::default(); + let mut state = crate::input::state::test_support::make_test_input_state(); + let id = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 40, + y: 60, + text: "你好 initial".into(), + color: crate::draw::Color::new(1.0, 0.0, 0.0, 1.0), + size: 18.0, + font_descriptor: Default::default(), + background_enabled: false, + wrap_width: None, + }); + state.set_selection(vec![id]); + assert!(state.show_properties_panel_with(&measurer)); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + state.update_properties_panel_layout_with_resources(&engine, &measurer, &ctx, 800, 600); + let old = state.properties_panel().unwrap().lines.clone(); + { + let frame = state.boards.active_frame_mut(); + let shape = frame.shape_mut(id).unwrap(); + if let Shape::Text { text, .. } = &mut shape.shape { + *text = "A substantially wider changed text label".into(); + } + shape.invalidate_bounds(); + } + state.properties.mark_needs_refresh(); + state.update_properties_panel_layout_with_resources(&engine, &measurer, &ctx, 800, 600); + assert!(!state.properties.needs_refresh()); + assert_ne!( + state.properties_panel().unwrap().lines, + old, + "deferred refresh updates measured shape summary" + ); + let layout = *state.properties_panel_layout().unwrap(); + let actual = paint(&engine, &state); + assert!(actual.iter().any(|&byte| byte != 0)); + state.update_properties_panel_layout(&ctx, 800, 600); + let fresh = state.properties_panel_layout().unwrap(); + assert_eq!( + (fresh.origin_x, fresh.origin_y, fresh.width, fresh.height), + ( + layout.origin_x, + layout.origin_y, + layout.width, + layout.height + ) + ); + assert_eq!( + (fresh.label_x, fresh.value_x, fresh.entry_start_y), + (layout.label_x, layout.value_x, layout.entry_start_y) + ); + assert!(actual == paint(&UiTextEngine::default(), &state)); + state.selection_interaction.set(Vec::new()); + state.properties.mark_needs_refresh(); + state.update_properties_panel_layout_with_resources(&engine, &measurer, &ctx, 800, 600); + assert!(state.properties_panel().is_none()); + assert!(state.properties_panel_layout().is_none()); + } +} diff --git a/src/ui.rs b/src/ui.rs index 598cac804..416e6e03c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -37,6 +37,7 @@ pub(crate) use board_picker::render_board_picker_with_halo; pub use color_picker_popup::{color_picker_popup_visual_geometry, render_color_picker_popup}; pub use command_palette::{command_palette_visual_geometry, render_command_palette}; pub use context_menu::render_context_menu; +pub(crate) use context_menu::render_context_menu_with_engine; pub(crate) use eyedropper_loupe::{compute_eyedropper_loupe_layout, render_eyedropper_loupe}; pub use font_picker::render_font_picker; #[allow(unused_imports)] @@ -60,6 +61,7 @@ pub use precision_entry::render_precision_entry_popup; pub(crate) use primitives::ellipsize_to_fit_with_engine; pub(crate) use primitives::{checkerboard_behind, draw_pill}; pub use properties_panel::render_properties_panel; +pub(crate) use properties_panel::render_properties_panel_with_engine; pub use radial_menu::render_radial_menu; pub(crate) use radial_menu::render_radial_menu_with_context; pub(crate) use region_action_bar::{ diff --git a/src/ui/board_picker.rs b/src/ui/board_picker.rs index 5e39bbeb3..967ee1a3d 100644 --- a/src/ui/board_picker.rs +++ b/src/ui/board_picker.rs @@ -1,6 +1,6 @@ use crate::input::InputState; -use crate::ui::primitives::{draw_rounded_rect, text_extents_for}; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ self, NAV_HINT_BOARD_PICKER, OVERLAY_DIM_LIGHT, OVERLAY_DIM_MEDIUM, RADIUS_PANEL, TEXT_HINT, @@ -24,8 +24,10 @@ pub fn render_board_picker( screen_width: u32, screen_height: u32, ) { + let engine = UiTextEngine::default(); let mut caches = crate::draw::RenderCaches::default(); render_board_picker_with_halo( + &engine, &mut crate::draw::RenderCtx::new(ctx, &mut caches), input_state, screen_width, @@ -35,6 +37,7 @@ pub fn render_board_picker( } pub(crate) fn render_board_picker_with_halo( + engine: &UiTextEngine, render: &mut crate::draw::RenderCtx<'_, '_>, input_state: &InputState, screen_width: u32, @@ -90,7 +93,7 @@ pub(crate) fn render_board_picker_with_halo( }; constants::set_color(ctx, TEXT_PRIMARY); let title_y = layout.origin_y + layout.padding_y + layout.title_font_size; - draw_text_baseline( + engine.draw_baseline( ctx, title_style, &title, @@ -110,7 +113,7 @@ pub(crate) fn render_board_picker_with_halo( }; constants::set_color(ctx, TEXT_TERTIARY); let footer_y = layout.origin_y + layout.height - layout.padding_y; - let footer_extents = draw_text_baseline( + let footer_extents = engine.draw_baseline( ctx, footer_style, &footer, @@ -119,7 +122,8 @@ pub(crate) fn render_board_picker_with_halo( None, ); // Navigation hint on right side - let nav_extents = text_extents_for( + let nav_extents = text_extents_for_with_engine( + engine, ctx, "Sans", cairo::FontSlant::Normal, @@ -131,7 +135,7 @@ pub(crate) fn render_board_picker_with_halo( let footer_end = layout.origin_x + layout.padding_x + footer_extents.width(); if footer_end + layout.footer_font_size * 0.5 <= nav_start { constants::set_color(ctx, constants::with_alpha(TEXT_HINT, 0.7)); - draw_text_baseline( + engine.draw_baseline( ctx, footer_style, NAV_HINT_BOARD_PICKER, @@ -143,7 +147,7 @@ pub(crate) fn render_board_picker_with_halo( if let Some(recent) = recent { let recent_y = footer_y - layout.recent_height; constants::set_color(ctx, constants::with_alpha(TEXT_HINT, 0.8)); - draw_text_baseline( + engine.draw_baseline( ctx, footer_style, &recent, @@ -153,10 +157,11 @@ pub(crate) fn render_board_picker_with_halo( ); } - render_board_rows(ctx, input_state, layout, board_count, max_count); + render_board_rows(engine, ctx, input_state, layout, board_count, max_count); render_board_palette(ctx, input_state, layout); render_page_panel( + engine, render, input_state, layout, @@ -167,3 +172,6 @@ pub(crate) fn render_board_picker_with_halo( let _ = ctx.restore(); } + +#[cfg(test)] +mod tests; diff --git a/src/ui/board_picker/page_panel.rs b/src/ui/board_picker/page_panel.rs index f760234dd..fbd764ea2 100644 --- a/src/ui/board_picker/page_panel.rs +++ b/src/ui/board_picker/page_panel.rs @@ -7,9 +7,9 @@ use crate::input::state::{ use crate::ui::constants::{ self, BG_HOVER, DIVIDER_LIGHT, RADIUS_SM, TEXT_HINT, TEXT_SECONDARY, TEXT_TERTIARY, TEXT_WHITE, }; -use crate::ui::primitives::{draw_rounded_rect, text_extents_for}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use crate::ui::theme::Rgba; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; // File-local colors with no matching theme token (kept from the pre-theme // literals). @@ -27,6 +27,7 @@ use thumbnail::{ }; pub(super) fn render_page_panel( + engine: &UiTextEngine, render: &mut crate::draw::RenderCtx<'_, '_>, input_state: &InputState, layout: &BoardPickerLayout, @@ -75,7 +76,7 @@ pub(super) fn render_page_panel( }; constants::set_color(ctx, TEXT_TERTIARY); let label_y = layout.origin_y + layout.padding_y + layout.title_font_size; - draw_text_baseline( + engine.draw_baseline( ctx, footer_style, &label, @@ -95,6 +96,7 @@ pub(super) fn render_page_panel( if page_count == 0 { let add_hover = input_state.board_picker_page_add_card_at(pointer_x, pointer_y); render_add_page_card( + engine, ctx, start_x, start_y, @@ -137,28 +139,31 @@ pub(super) fn render_page_panel( && d.target_board == Some(board_index) && d.current_index == index }); - render_page_thumbnail(PageThumbnailArgs { - render, - frame: page, - background: &board.spec.background, - x: thumb_x, - y: thumb_y, - width: layout.page_thumb_width, - height: layout.page_thumb_height, - screen_width, - screen_height, - text_halo_enabled, - page_number: index + 1, - page_name: page.page_name(), - is_active, - is_drop_target, - is_search_match: input_state.board_picker_page_matches_current_search(index), - is_hovered: hover_index == Some(index), - is_keyboard_focused: page_focus_page_index == Some(index), - delete_hovered: hover_delete == Some(index), - duplicate_hovered: hover_duplicate == Some(index), - rename_hovered: hover_rename == Some(index), - }); + render_page_thumbnail( + engine, + PageThumbnailArgs { + render, + frame: page, + background: &board.spec.background, + x: thumb_x, + y: thumb_y, + width: layout.page_thumb_width, + height: layout.page_thumb_height, + screen_width, + screen_height, + text_halo_enabled, + page_number: index + 1, + page_name: page.page_name(), + is_active, + is_drop_target, + is_search_match: input_state.board_picker_page_matches_current_search(index), + is_hovered: hover_index == Some(index), + is_keyboard_focused: page_focus_page_index == Some(index), + delete_hovered: hover_delete == Some(index), + duplicate_hovered: hover_duplicate == Some(index), + rename_hovered: hover_rename == Some(index), + }, + ); } if let Some(hover_index) = hover_index @@ -172,19 +177,22 @@ pub(super) fn render_page_panel( let thumb_x = start_x + col as f64 * (layout.page_thumb_width + layout.page_thumb_gap); let thumb_y = start_y + row as f64 * row_stride; let page = &pages[hover_index]; - render_page_preview(PagePreviewArgs { - render, - frame: page, - background: &board.spec.background, - thumb_x, - thumb_y, - thumb_w: layout.page_thumb_width, - thumb_h: layout.page_thumb_height, - screen_width, - screen_height, - text_halo_enabled, - page_number: hover_index + 1, - }); + render_page_preview( + engine, + PagePreviewArgs { + render, + frame: page, + background: &board.spec.background, + thumb_x, + thumb_y, + thumb_w: layout.page_thumb_width, + thumb_h: layout.page_thumb_height, + screen_width, + screen_height, + text_halo_enabled, + page_number: hover_index + 1, + }, + ); } if let Some((edit_board, edit_page, buffer)) = input_state.board_picker_page_edit_state() @@ -198,6 +206,7 @@ pub(super) fn render_page_panel( let thumb_x = start_x + col as f64 * (layout.page_thumb_width + layout.page_thumb_gap); let thumb_y = start_y + row as f64 * row_stride; render_page_rename_overlay( + engine, ctx, thumb_x, thumb_y, @@ -225,6 +234,7 @@ pub(super) fn render_page_panel( layout.page_thumb_height, ); render_add_page_card( + engine, ctx, add_x, add_y, @@ -235,7 +245,7 @@ pub(super) fn render_page_panel( ); } - render_sticky_add_button(ctx, layout, pointer_x, pointer_y); + render_sticky_add_button(engine, ctx, layout, pointer_x, pointer_y); if page_count > visible { let first_label = first_visible + 1; @@ -248,7 +258,7 @@ pub(super) fn render_page_panel( constants::set_color(ctx, TEXT_HINT); } let hint_y = layout.page_add_button_y - 4.0; - let extents = draw_text_baseline(ctx, footer_style, &hint, start_x, hint_y, None); + let extents = engine.draw_baseline(ctx, footer_style, &hint, start_x, hint_y, None); if overflow_hover { ctx.set_line_width(1.0); ctx.move_to(start_x, hint_y + 2.0); @@ -259,6 +269,7 @@ pub(super) fn render_page_panel( } fn render_sticky_add_button( + engine: &UiTextEngine, ctx: &cairo::Context, layout: &BoardPickerLayout, pointer_x: i32, @@ -305,7 +316,8 @@ fn render_sticky_add_button( }; constants::set_color(ctx, TEXT_SECONDARY); let label = "+ Add page"; - let extents = text_extents_for( + let extents = text_extents_for_with_engine( + engine, ctx, "Sans", cairo::FontSlant::Normal, @@ -316,7 +328,7 @@ fn render_sticky_add_button( let text_x = layout.page_add_button_x + (layout.page_add_button_width - extents.width()) * 0.5; let text_y = layout.page_add_button_y + (layout.page_add_button_height + extents.height()) * 0.5 - 1.0; - draw_text_baseline(ctx, label_style, label, text_x, text_y, None); + engine.draw_baseline(ctx, label_style, label, text_x, text_y, None); } fn point_in_rect(x: i32, y: i32, rx: f64, ry: f64, rw: f64, rh: f64) -> bool { @@ -325,7 +337,9 @@ fn point_in_rect(x: i32, y: i32, rx: f64, ry: f64, rw: f64, rh: f64) -> bool { x >= rx && x <= rx + rw && y >= ry && y <= ry + rh } +#[allow(clippy::too_many_arguments)] fn render_page_rename_overlay( + engine: &UiTextEngine, ctx: &cairo::Context, x: f64, y: f64, @@ -360,10 +374,11 @@ fn render_page_rename_overlay( let _ = ctx.save(); ctx.rectangle(input_x + 4.0, input_y, input_w - 8.0, input_h); ctx.clip(); - draw_text_baseline(ctx, text_style, text, text_x, text_y, None); + engine.draw_baseline(ctx, text_style, text, text_x, text_y, None); let _ = ctx.restore(); - let extents = text_extents_for( + let extents = text_extents_for_with_engine( + engine, ctx, "Sans", cairo::FontSlant::Normal, diff --git a/src/ui/board_picker/page_panel/thumbnail/cards.rs b/src/ui/board_picker/page_panel/thumbnail/cards.rs index 903a3bf9b..2388493b0 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cards.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cards.rs @@ -3,9 +3,9 @@ use crate::ui::constants::{ self, ACCENT_BRIGHT, ACCENT_PRIMARY, BG_SELECTION, BORDER_FOCUS, RADIUS_MD, RADIUS_SM, RADIUS_STD, SHADOW_DEEP, TEXT_WHITE, }; -use crate::ui::primitives::{draw_rounded_rect, text_extents_for}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use crate::ui::theme::Rgba; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; // File-local colors with no matching theme token (kept from the pre-theme // literals). @@ -23,6 +23,7 @@ use super::icons::{ use super::types::{PREVIEW_SCALE, PageContentArgs, PagePreviewArgs, PageThumbnailArgs}; pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail( + engine: &UiTextEngine, args: PageThumbnailArgs<'_, '_, '_>, ) { let PageThumbnailArgs { @@ -60,18 +61,21 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail( ctx.set_line_width(1.0); let _ = ctx.stroke(); - render_page_content(PageContentArgs { - render, - frame, - background, - x, - y, - width, - height, - screen_width, - screen_height, - text_halo_enabled, - }); + render_page_content( + engine, + PageContentArgs { + render, + frame, + background, + x, + y, + width, + height, + screen_width, + screen_height, + text_halo_enabled, + }, + ); if is_active { constants::set_color(ctx, ACCENT_PRIMARY); @@ -128,8 +132,8 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail( duplicate_hovered, delete_hovered, ); - draw_page_badge(ctx, x, y, page_number, is_hovered); - render_page_name_label(ctx, x, y, width, height, page_name, is_hovered); + draw_page_badge(engine, ctx, x, y, page_number, is_hovered); + render_page_name_label(engine, ctx, x, y, width, height, page_name, is_hovered); } #[allow(clippy::too_many_arguments)] @@ -180,7 +184,14 @@ fn draw_thumbnail_actions( ); } -fn draw_page_badge(ctx: &cairo::Context, x: f64, y: f64, page_number: usize, is_hovered: bool) { +fn draw_page_badge( + engine: &UiTextEngine, + ctx: &cairo::Context, + x: f64, + y: f64, + page_number: usize, + is_hovered: bool, +) { let badge = page_number.to_string(); let badge_font_size = if is_hovered { 10.0 } else { 9.0 }; let badge_bg_alpha = if is_hovered { 0.6 } else { 0.35 }; @@ -190,7 +201,8 @@ fn draw_page_badge(ctx: &cairo::Context, x: f64, y: f64, page_number: usize, is_ weight: cairo::FontWeight::Bold, size: badge_font_size, }; - let extents = text_extents_for( + let extents = text_extents_for_with_engine( + engine, ctx, "Sans", cairo::FontSlant::Normal, @@ -207,7 +219,7 @@ fn draw_page_badge(ctx: &cairo::Context, x: f64, y: f64, page_number: usize, is_ draw_rounded_rect(ctx, badge_x, badge_y, badge_w, badge_h, RADIUS_SM); let _ = ctx.fill(); constants::set_color(ctx, constants::with_alpha(TEXT_WHITE, 0.9)); - draw_text_baseline( + engine.draw_baseline( ctx, badge_style, &badge, @@ -217,7 +229,9 @@ fn draw_page_badge(ctx: &cairo::Context, x: f64, y: f64, page_number: usize, is_ ); } +#[allow(clippy::too_many_arguments)] pub(in crate::ui::board_picker::page_panel) fn render_add_page_card( + engine: &UiTextEngine, ctx: &cairo::Context, x: f64, y: f64, @@ -269,7 +283,8 @@ pub(in crate::ui::board_picker::page_panel) fn render_add_page_card( }; let text_alpha = if is_hovered { 0.7 } else { 0.4 }; constants::set_color(ctx, constants::with_alpha(TEXT_WHITE, text_alpha)); - let extents = text_extents_for( + let extents = text_extents_for_with_engine( + engine, ctx, "Sans", cairo::FontSlant::Normal, @@ -277,7 +292,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_add_page_card( 10.0, label, ); - draw_text_baseline( + engine.draw_baseline( ctx, label_style, label, @@ -288,6 +303,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_add_page_card( } pub(in crate::ui::board_picker::page_panel) fn render_page_preview( + engine: &UiTextEngine, args: PagePreviewArgs<'_, '_, '_>, ) { let PagePreviewArgs { @@ -337,18 +353,21 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview( ctx.set_line_width(1.2); let _ = ctx.stroke(); - render_page_content(PageContentArgs { - render, - frame, - background, - x: preview_x, - y: preview_y, - width: preview_w, - height: preview_h, - screen_width, - screen_height, - text_halo_enabled, - }); + render_page_content( + engine, + PageContentArgs { + render, + frame, + background, + x: preview_x, + y: preview_y, + width: preview_w, + height: preview_h, + screen_width, + screen_height, + text_halo_enabled, + }, + ); let label = frame .page_name() @@ -360,7 +379,8 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview( weight: cairo::FontWeight::Normal, size: 11.0, }; - let extents = text_extents_for( + let extents = text_extents_for_with_engine( + engine, ctx, "Sans", cairo::FontSlant::Normal, @@ -378,7 +398,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview( let _ = ctx.save(); ctx.rectangle(label_x + 4.0, label_y, label_w - 8.0, 16.0); ctx.clip(); - draw_text_baseline( + engine.draw_baseline( ctx, label_style, &label, diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs index 1ad84566f..14fba2773 100644 --- a/src/ui/board_picker/page_panel/thumbnail/content.rs +++ b/src/ui/board_picker/page_panel/thumbnail/content.rs @@ -8,9 +8,9 @@ use crate::input::state::{PAGE_NAME_HEIGHT, PAGE_NAME_PADDING}; use crate::ui::constants::{ self, PANEL_BG_CONTEXT_MENU, RADIUS_STD, TEXT_HINT, TEXT_PRIMARY, TEXT_TERTIARY, }; -use crate::ui::primitives::{draw_rounded_rect, text_extents_for}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use crate::ui::theme::Rgba; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::types::PageContentArgs; @@ -24,7 +24,7 @@ const THUMBNAIL_SPOTLIGHT_FEATHER: f64 = 0.35; const TRANSPARENT_TINT: Rgba = (1.0, 1.0, 1.0, 0.06); const TRANSPARENT_CROSS: Rgba = (1.0, 1.0, 1.0, 0.08); -pub(super) fn render_page_content(args: PageContentArgs<'_, '_, '_>) { +pub(super) fn render_page_content(engine: &UiTextEngine, args: PageContentArgs<'_, '_, '_>) { let PageContentArgs { render, frame, @@ -74,6 +74,7 @@ pub(super) fn render_page_content(args: PageContentArgs<'_, '_, '_>) { ctx.translate(x + inset + offset_x, y + inset + offset_y); ctx.scale(scale, scale); render_frame_shapes( + engine, render, frame, background, @@ -86,6 +87,7 @@ pub(super) fn render_page_content(args: PageContentArgs<'_, '_, '_>) { } fn render_frame_shapes( + engine: &UiTextEngine, render: &mut crate::draw::RenderCtx<'_, '_>, frame: &crate::draw::Frame, background: &BoardBackground, @@ -153,11 +155,12 @@ fn render_frame_shapes( }, ); if !magnifier_source.is_complete() { - render_unavailable_magnification_labels(ctx, ®ions); + render_unavailable_magnification_labels(engine, ctx, ®ions); } } fn render_unavailable_magnification_labels( + engine: &UiTextEngine, ctx: &cairo::Context, regions: &[crate::draw::SpotlightRegion], ) { @@ -174,7 +177,8 @@ fn render_unavailable_magnification_labels( size: font_size, }; let _ = ctx.save(); - let extents = text_extents_for( + let extents = text_extents_for_with_engine( + engine, ctx, style.family, style.slant, @@ -196,7 +200,7 @@ fn render_unavailable_magnification_labels( constants::set_color(ctx, PANEL_BG_CONTEXT_MENU); let _ = ctx.fill(); constants::set_color(ctx, TEXT_PRIMARY); - draw_text_baseline( + engine.draw_baseline( ctx, style, &label, @@ -208,7 +212,9 @@ fn render_unavailable_magnification_labels( } } +#[allow(clippy::too_many_arguments)] pub(super) fn render_page_name_label( + engine: &UiTextEngine, ctx: &cairo::Context, x: f64, y: f64, @@ -245,7 +251,7 @@ pub(super) fn render_page_name_label( PAGE_NAME_HEIGHT, ); ctx.clip(); - draw_text_baseline(ctx, label_style, label, label_x, label_y, None); + engine.draw_baseline(ctx, label_style, label, label_x, label_y, None); let _ = ctx.restore(); } @@ -283,21 +289,24 @@ mod tests { ry: 70, magnification, }); - render_page_content(PageContentArgs { - render: &mut crate::draw::RenderCtx::new( - &ctx, - &mut crate::draw::RenderCaches::default(), - ), - frame: &frame, - background, - x: 0.0, - y: 0.0, - width: 120.0, - height: 90.0, - screen_width: 400, - screen_height: 300, - text_halo_enabled: true, - }); + render_page_content( + &UiTextEngine::default(), + PageContentArgs { + render: &mut crate::draw::RenderCtx::new( + &ctx, + &mut crate::draw::RenderCaches::default(), + ), + frame: &frame, + background, + x: 0.0, + y: 0.0, + width: 120.0, + height: 90.0, + screen_width: 400, + screen_height: 300, + text_halo_enabled: true, + }, + ); } let mut surface = surface; surface.flush(); @@ -320,21 +329,24 @@ mod tests { background_enabled: false, wrap_width: None, }); - render_page_content(PageContentArgs { - render: &mut crate::draw::RenderCtx::new( - &ctx, - &mut crate::draw::RenderCaches::default(), - ), - frame: &frame, - background: &BoardBackground::Solid(Color::new(1.0, 1.0, 1.0, 1.0)), - x: 0.0, - y: 0.0, - width: 120.0, - height: 90.0, - screen_width: 400, - screen_height: 300, - text_halo_enabled, - }); + render_page_content( + &UiTextEngine::default(), + PageContentArgs { + render: &mut crate::draw::RenderCtx::new( + &ctx, + &mut crate::draw::RenderCaches::default(), + ), + frame: &frame, + background: &BoardBackground::Solid(Color::new(1.0, 1.0, 1.0, 1.0)), + x: 0.0, + y: 0.0, + width: 120.0, + height: 90.0, + screen_width: 400, + screen_height: 300, + text_halo_enabled, + }, + ); } surface.flush(); surface.data().expect("thumbnail pixels").to_vec() @@ -413,18 +425,21 @@ mod tests { let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 124, 64).unwrap(); { let ctx = cairo::Context::new(&surface).unwrap(); - render_page_content(PageContentArgs { - render: &mut RenderCtx::new(&ctx, caches), - frame: &frame, - background: &BoardBackground::Solid(crate::draw::WHITE), - x: 0.0, - y: 0.0, - width: 124.0, - height: 64.0, - screen_width: 120, - screen_height: 60, - text_halo_enabled: false, - }); + render_page_content( + &UiTextEngine::default(), + PageContentArgs { + render: &mut RenderCtx::new(&ctx, caches), + frame: &frame, + background: &BoardBackground::Solid(crate::draw::WHITE), + x: 0.0, + y: 0.0, + width: 124.0, + height: 64.0, + screen_width: 120, + screen_height: 60, + text_halo_enabled: false, + }, + ); } surface.flush(); surface.data().unwrap().to_vec() diff --git a/src/ui/board_picker/rows.rs b/src/ui/board_picker/rows.rs index b338b09e3..9357c79e9 100644 --- a/src/ui/board_picker/rows.rs +++ b/src/ui/board_picker/rows.rs @@ -1,9 +1,9 @@ use crate::draw::Color; use crate::input::state::{BoardPickerEditMode, BoardPickerLayout}; use crate::input::{BoardBackground, InputState}; -use crate::ui::primitives::{draw_rounded_rect, text_extents_for}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use crate::ui::theme::Rgba; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ self, ACCENT_PRIMARY, BG_SELECTED_INDICATOR, BG_SELECTION, DIVIDER_LIGHT, ICON_PIN_ACTIVE, @@ -16,16 +16,18 @@ use super::helpers::{ const SWATCH_TRANSPARENT_OUTLINE: Rgba = (0.62, 0.68, 0.76, 0.85); pub(super) fn render_board_rows( + engine: &UiTextEngine, ctx: &cairo::Context, input_state: &InputState, layout: &BoardPickerLayout, board_count: usize, max_count: usize, ) { - BoardRowsRenderer::new(ctx, input_state, layout, board_count, max_count).render(); + BoardRowsRenderer::new(engine, ctx, input_state, layout, board_count, max_count).render(); } struct BoardRowsRenderer<'a> { + engine: &'a UiTextEngine, ctx: &'a cairo::Context, input: &'a InputState, layout: &'a BoardPickerLayout, @@ -47,6 +49,7 @@ struct BoardRowsRenderer<'a> { impl<'a> BoardRowsRenderer<'a> { fn new( + engine: &'a UiTextEngine, ctx: &'a cairo::Context, input: &'a InputState, layout: &'a BoardPickerLayout, @@ -68,6 +71,7 @@ impl<'a> BoardRowsRenderer<'a> { .unwrap_or(list_right - layout.padding_x); let hint_x = (layout.hint_width > 0.0).then_some(hint_right_edge - layout.hint_width); Self { + engine, ctx, input, layout, @@ -107,7 +111,7 @@ impl<'a> BoardRowsRenderer<'a> { ..self.body_style }; constants::set_color(self.ctx, constants::with_alpha(TEXT_HINT, 0.6)); - draw_text_baseline( + self.engine.draw_baseline( self.ctx, style, "Pinned", @@ -219,7 +223,7 @@ impl<'a> BoardRowsRenderer<'a> { "New board" }; constants::set_color(self.ctx, TEXT_HINT); - draw_text_baseline( + self.engine.draw_baseline( self.ctx, self.body_style, label, @@ -363,7 +367,7 @@ impl<'a> BoardRowsRenderer<'a> { fn render_board_name(&self, board_index: usize, row_center: f64, active: bool, name: &str) { constants::set_color(self.ctx, if active { TEXT_ACTIVE } else { TEXT_SECONDARY }); - draw_text_baseline( + self.engine.draw_baseline( self.ctx, self.body_style, name, @@ -379,7 +383,7 @@ impl<'a> BoardRowsRenderer<'a> { } let extents = self.text_extents(name); constants::set_color(self.ctx, constants::with_alpha(TEXT_HINT, 0.85)); - draw_text_baseline( + self.engine.draw_baseline( self.ctx, self.body_style, &format!(" ({page_count} pages)"), @@ -403,7 +407,7 @@ impl<'a> BoardRowsRenderer<'a> { return; }; constants::set_color(self.ctx, TEXT_HINT); - draw_text_baseline( + self.engine.draw_baseline( self.ctx, self.body_style, &hint, @@ -469,7 +473,8 @@ impl<'a> BoardRowsRenderer<'a> { } fn text_extents(&self, text: &str) -> cairo::TextExtents { - text_extents_for( + text_extents_for_with_engine( + self.engine, self.ctx, "Sans", cairo::FontSlant::Normal, diff --git a/src/ui/board_picker/tests.rs b/src/ui/board_picker/tests.rs new file mode 100644 index 000000000..05cc96cbe --- /dev/null +++ b/src/ui/board_picker/tests.rs @@ -0,0 +1,65 @@ +use super::*; + +fn pixels( + engine: &UiTextEngine, + caches: &mut crate::draw::RenderCaches, + state: &InputState, + size: (i32, i32), + density: i32, +) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, size.0 * density, size.1 * density) + .unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + render_board_picker_with_halo( + engine, + &mut crate::draw::RenderCtx::new(&ctx, caches), + state, + size.0 as u32, + size.1 as u32, + true, + ); + } + surface.data().unwrap().to_vec() +} + +#[test] +fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layouts() { + let engine = UiTextEngine::default(); + let mut caches = crate::draw::RenderCaches::default(); + let mut state = crate::input::state::test_support::make_test_input_state(); + state.open_board_picker(); + for (width, height, density) in [(900, 700, 1), (420, 300, 2), (900, 700, 1)] { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + state.update_board_picker_layout(&ctx, width as u32, height as u32); + let before = pixels(&engine, &mut caches, &state, (width, height), density); + let board_index = state + .board_picker_layout() + .unwrap() + .page_board_index + .unwrap(); + state.board_picker_start_page_rename(board_index, 0); + for ch in "你好 Καλημέρα long page name".chars() { + state.board_picker_page_edit_append(ch); + } + let actual = pixels(&engine, &mut caches, &state, (width, height), density); + let expected = pixels( + &UiTextEngine::default(), + &mut crate::draw::RenderCaches::default(), + &state, + (width, height), + density, + ); + assert!(actual.iter().any(|&byte| byte != 0)); + assert!(actual == expected, "retained board UI pixels differ"); + assert!( + actual != before, + "rename overlay must paint the edited label" + ); + state.board_picker_cancel_page_edit(); + assert!(pixels(&engine, &mut caches, &state, (width, height), density) == before); + } +} diff --git a/src/ui/context_menu.rs b/src/ui/context_menu.rs index a5223db40..9204af2c5 100644 --- a/src/ui/context_menu.rs +++ b/src/ui/context_menu.rs @@ -2,7 +2,7 @@ use crate::input::InputState; use crate::input::state::ContextMenuState; use crate::ui::primitives::draw_rounded_rect; use crate::ui::theme::Rgba; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ self, BG_HOVER, BORDER_FOCUS, FOCUS_RING_WIDTH, ICON_SUBMENU_ARROW, NAV_HINT_MENU, @@ -22,6 +22,22 @@ pub fn render_context_menu( input_state: &InputState, _screen_width: u32, _screen_height: u32, +) { + render_context_menu_with_engine( + &UiTextEngine::default(), + ctx, + input_state, + _screen_width, + _screen_height, + ); +} + +pub(crate) fn render_context_menu_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + input_state: &InputState, + _screen_width: u32, + _screen_height: u32, ) { let (hover_index, focus_index) = match input_state.context_menu.state() { ContextMenuState::Open { @@ -110,7 +126,7 @@ pub fn render_context_menu( let text_a = text_color.3; constants::set_color(ctx, text_color); - draw_text_baseline( + engine.draw_baseline( ctx, text_style, &entry.label, @@ -126,7 +142,7 @@ pub fn render_context_menu( - layout.padding_x - layout.arrow_width - layout.shortcut_width; - draw_text_baseline( + engine.draw_baseline( ctx, text_style, shortcut, @@ -173,7 +189,7 @@ pub fn render_context_menu( // Draw hint text constants::set_color(ctx, HINT_FOOTER_TEXT); - draw_text_baseline( + engine.draw_baseline( ctx, hint_style, NAV_HINT_MENU, @@ -184,3 +200,63 @@ pub fn render_context_menu( let _ = ctx.restore(); } + +#[cfg(test)] +mod engine_tests { + use super::*; + use crate::input::state::ContextMenuKind; + + fn paint(engine: &UiTextEngine, state: &InputState, density: i32) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 640 * density, 480 * density) + .unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + render_context_menu_with_engine(engine, &ctx, state, 640, 480); + } + surface.data().unwrap().to_vec() + } + + #[test] + fn retained_context_menu_owner_preserves_layout_pixels_and_row_hits() { + let engine = UiTextEngine::default(); + let mut state = crate::input::state::test_support::make_test_input_state(); + for (kind, density) in [ + (ContextMenuKind::Canvas, 1), + (ContextMenuKind::Zoom, 2), + (ContextMenuKind::Canvas, 1), + ] { + state.open_context_menu((620, 460), Vec::new(), kind, None); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 640, 480).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + state.update_context_menu_layout_with_engine(&engine, &ctx, 640, 480); + let layout = *state.context_menu_layout().unwrap(); + let actual = paint(&engine, &state, density); + assert!(actual.iter().any(|&byte| byte != 0)); + state.update_context_menu_layout_with_engine(&UiTextEngine::default(), &ctx, 640, 480); + let fresh = state.context_menu_layout().unwrap(); + assert_eq!( + (fresh.origin_x, fresh.origin_y, fresh.width, fresh.height), + ( + layout.origin_x, + layout.origin_y, + layout.width, + layout.height + ) + ); + assert_eq!( + (fresh.row_height, fresh.shortcut_width, fresh.arrow_width), + (layout.row_height, layout.shortcut_width, layout.arrow_width) + ); + assert!(actual == paint(&UiTextEngine::default(), &state, density)); + for index in 0..state.context_menu_entries().len() { + let x = (layout.origin_x + layout.padding_x) as i32; + let y = (layout.origin_y + + layout.padding_y + + layout.row_height * (index as f64 + 0.5)) as i32; + assert_eq!(state.context_menu_index_at(x, y), Some(index)); + } + } + } +} diff --git a/src/ui/properties_panel.rs b/src/ui/properties_panel.rs index dfa616aae..dbf7b05cd 100644 --- a/src/ui/properties_panel.rs +++ b/src/ui/properties_panel.rs @@ -1,6 +1,6 @@ use crate::input::InputState; use crate::ui::primitives::draw_rounded_rect; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ self, BG_HOVER, BORDER_FOCUS, DIVIDER, EMPTY_PROPERTIES, FOCUS_RING_WIDTH, RADIUS_PANEL, @@ -12,6 +12,22 @@ pub fn render_properties_panel( input_state: &InputState, _screen_width: u32, _screen_height: u32, +) { + render_properties_panel_with_engine( + &UiTextEngine::default(), + ctx, + input_state, + _screen_width, + _screen_height, + ); +} + +pub(crate) fn render_properties_panel_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + input_state: &InputState, + _screen_width: u32, + _screen_height: u32, ) { let panel = match input_state.properties_panel() { Some(panel) => panel, @@ -60,7 +76,7 @@ pub fn render_properties_panel( } else { constants::set_color(ctx, TEXT_PRIMARY); } - draw_text_baseline( + engine.draw_baseline( ctx, title_style, &panel.title, @@ -87,7 +103,7 @@ pub fn render_properties_panel( }; constants::set_color(ctx, TEXT_TERTIARY); let empty_y = layout.info_start_y + line_height; - draw_text_baseline( + engine.draw_baseline( ctx, empty_style, EMPTY_PROPERTIES, @@ -102,7 +118,7 @@ pub fn render_properties_panel( constants::set_color(ctx, TEXT_SECONDARY); let mut text_y = layout.info_start_y; for line in &panel.lines { - draw_text_baseline(ctx, body_style, line, layout.label_x, text_y, None); + engine.draw_baseline(ctx, body_style, line, layout.label_x, text_y, None); text_y += line_height; } @@ -150,7 +166,7 @@ pub fn render_properties_panel( }; let text_a = text_color.3; constants::set_color(ctx, text_color); - draw_text_baseline( + engine.draw_baseline( ctx, body_style, &entry.label, @@ -161,7 +177,7 @@ pub fn render_properties_panel( let value_color = constants::with_alpha(TEXT_HINT, text_a); constants::set_color(ctx, value_color); - draw_text_baseline( + engine.draw_baseline( ctx, body_style, &entry.value, From 6fc2be22da55ab4a239cf42e0c41dade7429a0ce Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:41:35 +0200 Subject: [PATCH 19/42] refactor(text): share palette and color popup geometry and painting --- src/backend/wayland/state/render/ui.rs | 16 +- .../wayland/state/render/ui_effect_damage.rs | 30 +- src/ui.rs | 6 + src/ui/color_picker_popup.rs | 514 +++--------------- src/ui/color_picker_popup/controls.rs | 450 +++++++++++++++ src/ui/color_picker_popup/tests/engine.rs | 57 ++ src/ui/command_palette.rs | 265 ++++----- src/ui/command_palette/command_palette_row.rs | 34 +- src/ui/command_palette/modals.rs | 152 ++++++ src/ui/command_palette/tests/engine.rs | 115 ++++ src/ui/primitives.rs | 13 - src/ui/text_highlight.rs | 15 +- 12 files changed, 1011 insertions(+), 656 deletions(-) create mode 100644 src/ui/color_picker_popup/controls.rs create mode 100644 src/ui/color_picker_popup/tests/engine.rs create mode 100644 src/ui/command_palette/modals.rs create mode 100644 src/ui/command_palette/tests/engine.rs diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 52854db00..a931d1958 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -251,7 +251,13 @@ impl WaylandState { if !capture_picker && self.input_state.is_color_picker_popup_open() { self.input_state .update_color_picker_popup_layout(width, height); - crate::ui::render_color_picker_popup(ctx, &self.input_state, width, height); + crate::ui::render_color_picker_popup_with_engine( + self.render.ui_text(), + ctx, + &self.input_state, + width, + height, + ); } else { self.input_state.clear_color_picker_popup_layout(); } @@ -407,7 +413,13 @@ impl WaylandState { if let Some(card) = self.first_run_onboarding_card() { crate::ui::render_onboarding_card(ctx, width, height, &card); } - crate::ui::render_command_palette(ctx, &self.input_state, width, height); + crate::ui::render_command_palette_with_engine( + self.render.ui_text(), + ctx, + &self.input_state, + width, + height, + ); crate::ui::render_tour(ctx, &self.input_state, width, height); } diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index 80c4932bf..7b1401971 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -28,8 +28,13 @@ pub(super) fn effect_rect(bounds: (f64, f64, f64, f64), width: u32, height: u32) Rect::from_min_max(min_x, min_y, max_x, max_y) } -fn color_picker_effect_rect(input_state: &InputState, width: u32, height: u32) -> Option { - crate::ui::color_picker_popup_visual_geometry(input_state, width, height) +fn color_picker_effect_rect( + engine: &crate::ui_text::UiTextEngine, + input_state: &InputState, + width: u32, + height: u32, +) -> Option { + crate::ui::color_picker_popup_visual_geometry_with_engine(engine, input_state, width, height) .and_then(|bounds| effect_rect(bounds, width, height)) } @@ -210,8 +215,13 @@ impl WaylandState { // optional action tooltip change, so typing and selection no longer // fall through to the full-surface empty-damage fallback. let command_palette_rect = if flags.active(UiEffect::CommandPalette) { - crate::ui::command_palette_visual_geometry(&self.input_state, width, height) - .and_then(|bounds| effect_rect(bounds, width, height)) + crate::ui::command_palette_visual_geometry_with_engine( + self.render.ui_text(), + &self.input_state, + width, + height, + ) + .and_then(|bounds| effect_rect(bounds, width, height)) } else { None }; @@ -227,7 +237,9 @@ impl WaylandState { // typing cannot fall through to the full-screen empty-damage fallback. let color_picker_rect = flags .active(UiEffect::ColorPicker) - .then(|| color_picker_effect_rect(&self.input_state, width, height)) + .then(|| { + color_picker_effect_rect(self.render.ui_text(), &self.input_state, width, height) + }) .flatten(); self.render .ui_damage_mut() @@ -537,7 +549,9 @@ mod tests { let mut input = crate::input::state::test_support::make_test_input_state(); input.open_color_picker_popup(); - let damage = color_picker_effect_rect(&input, 1920, 1080).expect("popup damage"); + let damage = + color_picker_effect_rect(&crate::ui_text::UiTextEngine::default(), &input, 1920, 1080) + .expect("popup damage"); // Before targeted popup damage, an ordinary key used the renderer's // 1920x1080 empty-damage fallback (2,073,600 pixels). Now it is the @@ -569,7 +583,9 @@ mod tests { layout.eyedropper_btn_y + layout.action_btn_size / 2.0, ))); - let damage = color_picker_effect_rect(&input, 1920, 1080).expect("tooltip damage"); + let damage = + color_picker_effect_rect(&crate::ui_text::UiTextEngine::default(), &input, 1920, 1080) + .expect("tooltip damage"); assert!(damage.width > 304); assert!(damage.width < 1920); diff --git a/src/ui.rs b/src/ui.rs index 416e6e03c..b92c3b745 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -35,7 +35,13 @@ pub(crate) use arrow_bend_handle::render_arrow_bend_handle; pub use board_picker::render_board_picker; pub(crate) use board_picker::render_board_picker_with_halo; pub use color_picker_popup::{color_picker_popup_visual_geometry, render_color_picker_popup}; +pub(crate) use color_picker_popup::{ + color_picker_popup_visual_geometry_with_engine, render_color_picker_popup_with_engine, +}; pub use command_palette::{command_palette_visual_geometry, render_command_palette}; +pub(crate) use command_palette::{ + command_palette_visual_geometry_with_engine, render_command_palette_with_engine, +}; pub use context_menu::render_context_menu; pub(crate) use context_menu::render_context_menu_with_engine; pub(crate) use eyedropper_loupe::{compute_eyedropper_loupe_layout, render_eyedropper_loupe}; diff --git a/src/ui/color_picker_popup.rs b/src/ui/color_picker_popup.rs index c58695899..08c002e42 100644 --- a/src/ui/color_picker_popup.rs +++ b/src/ui/color_picker_popup.rs @@ -10,11 +10,11 @@ use crate::input::state::{ COLOR_PICKER_RECENT_SWATCH_SIZE as RECENT_SWATCH_SIZE, ColorPickerPopupLayout, }; use crate::ui::primitives::{ - checkerboard_behind, draw_alpha_checkerboard, draw_rounded_rect, ellipsize_to_fit, - text_extents_for, + checkerboard_behind, draw_alpha_checkerboard, draw_rounded_rect, ellipsize_to_fit_with_engine, + text_extents_for_with_engine, }; use crate::ui::theme::{Rgba, toolbar as toolbar_theme}; -use crate::ui_text::{UiTextStyle, draw_text_baseline, measure_text}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ self, ACCENT_BRIGHT, ACCENT_PRIMARY, BG_INPUT_SELECTION, INPUT_BG, INPUT_BORDER_FOCUSED, @@ -22,6 +22,14 @@ use super::constants::{ TEXT_PRIMARY, }; +mod controls; + +use controls::{ + action_tooltip_geometry, draw_action_button, draw_action_tooltip, draw_alpha_bar, + draw_bar_marker, draw_button, draw_color_indicator, draw_hex_input, draw_hue_bar, + draw_preview_swatch, draw_recent_swatch, draw_sat_val_square, +}; + // File-local colors with no matching theme token (M1 keep-if-not-matching // rule: values kept verbatim from the pre-theme literals). /// Hairline around the HSV gradient so it separates from the panel. @@ -65,6 +73,20 @@ pub fn color_picker_popup_visual_geometry( input_state: &InputState, screen_width: u32, screen_height: u32, +) -> Option<(f64, f64, f64, f64)> { + color_picker_popup_visual_geometry_with_engine( + &UiTextEngine::default(), + input_state, + screen_width, + screen_height, + ) +} + +pub(crate) fn color_picker_popup_visual_geometry_with_engine( + engine: &UiTextEngine, + input_state: &InputState, + screen_width: u32, + screen_height: u32, ) -> Option<(f64, f64, f64, f64)> { if !input_state.is_color_picker_popup_open() { return None; @@ -85,6 +107,7 @@ pub fn color_picker_popup_visual_geometry( && let Some((tooltip, anchor_x, anchor_y)) = layout.action_tooltip_anchor_at(hover_x, hover_y) && let Some((x, y, width, height)) = action_tooltip_geometry( + engine, tooltip, anchor_x, anchor_y, @@ -119,6 +142,22 @@ pub fn render_color_picker_popup( input_state: &InputState, screen_width: u32, screen_height: u32, +) { + render_color_picker_popup_with_engine( + &UiTextEngine::default(), + ctx, + input_state, + screen_width, + screen_height, + ) +} + +pub(crate) fn render_color_picker_popup_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + input_state: &InputState, + screen_width: u32, + screen_height: u32, ) { if !input_state.is_color_picker_popup_open() { return; @@ -176,12 +215,13 @@ pub fn render_color_picker_popup( // so it is trimmed to the panel: unwrapped text wider than the panel would // draw outside the bounds this popup reports as damaged. let title = fit_title_to_panel( + engine, ctx, title_style, &input_state.color_picker_popup_title(), &layout, ); - draw_text_baseline( + engine.draw_baseline( ctx, title_style, &title, @@ -242,6 +282,7 @@ pub fn render_color_picker_popup( // Hex input field draw_hex_input( + engine, ctx, layout.hex_input_x, layout.hex_input_y, @@ -311,6 +352,7 @@ pub fn render_color_picker_popup( .map(|(hx, hy)| layout.point_in_default_button(hx, hy)) .unwrap_or(false); draw_button( + engine, ctx, default_x, default_y, @@ -324,6 +366,7 @@ pub fn render_color_picker_popup( // OK button draw_button( + engine, ctx, layout.ok_btn_x, layout.ok_btn_y, @@ -336,6 +379,7 @@ pub fn render_color_picker_popup( // Cancel button draw_button( + engine, ctx, layout.cancel_btn_x, layout.cancel_btn_y, @@ -355,7 +399,8 @@ pub fn render_color_picker_popup( }; constants::set_color(ctx, TEXT_HINT_DIM); let hint = "Enter = OK • Esc = Cancel"; - let hint_extents = text_extents_for( + let hint_extents = text_extents_for_with_engine( + engine, ctx, "Sans", cairo::FontSlant::Normal, @@ -365,13 +410,14 @@ pub fn render_color_picker_popup( ); let hint_x = layout.origin_x + (layout.width - hint_extents.width()) / 2.0; let hint_y = layout.ok_btn_y + layout.btn_height + 12.0; - draw_text_baseline(ctx, hint_style, hint, hint_x, hint_y, None); + engine.draw_baseline(ctx, hint_style, hint, hint_x, hint_y, None); if let Some((hover_x, hover_y)) = hover_pos && let Some((tooltip, anchor_x, anchor_y)) = layout.action_tooltip_anchor_at(hover_x, hover_y) { draw_action_tooltip( + engine, ctx, tooltip, anchor_x, @@ -388,13 +434,15 @@ pub fn render_color_picker_popup( /// `config.toml`, so no character budget can bound the shaped width; the real /// text is measured instead. fn fit_title_to_panel( + engine: &UiTextEngine, ctx: &cairo::Context, style: UiTextStyle<'_>, title: &str, layout: &ColorPickerPopupLayout, ) -> String { let max_width = (layout.width - TITLE_INSET * 2.0).max(0.0); - ellipsize_to_fit( + ellipsize_to_fit_with_engine( + engine, ctx, title, style.family, @@ -404,436 +452,6 @@ fn fit_title_to_panel( ) } -/// Draw the HSV color gradient. -/// Draw the saturation (x) by value (y) square for one hue. -/// -/// White-to-hue horizontally, then black over the top vertically — the standard -/// construction, and the one the toolbar's inline picker already uses. Both -/// axes are real: unlike the previous hue-by-value gradient, every point here -/// maps to the colour actually produced by clicking it. -fn draw_sat_val_square(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, hue: f64) { - let full = crate::draw::color::hsv_to_rgb(hue, 1.0, 1.0); - - let sat_grad = cairo::LinearGradient::new(x, y, x + w, y); - sat_grad.add_color_stop_rgba(0.0, 1.0, 1.0, 1.0, 1.0); - sat_grad.add_color_stop_rgba(1.0, full.r, full.g, full.b, 1.0); - ctx.rectangle(x, y, w, h); - let _ = ctx.set_source(&sat_grad); - let _ = ctx.fill(); - - let val_grad = cairo::LinearGradient::new(x, y, x, y + h); - val_grad.add_color_stop_rgba(0.0, 0.0, 0.0, 0.0, 0.0); - val_grad.add_color_stop_rgba(1.0, 0.0, 0.0, 0.0, 1.0); - ctx.rectangle(x, y, w, h); - let _ = ctx.set_source(&val_grad); - let _ = ctx.fill(); - - constants::set_color(ctx, GRADIENT_BORDER); - ctx.rectangle(x + 0.5, y + 0.5, w - 1.0, h - 1.0); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); -} - -/// Draw the horizontal hue bar. -fn draw_hue_bar(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64) { - let hue_grad = cairo::LinearGradient::new(x, y, x + w, y); - for step in 0..=6 { - let t = f64::from(step) / 6.0; - let color = crate::draw::color::hsv_to_rgb(t, 1.0, 1.0); - hue_grad.add_color_stop_rgba(t, color.r, color.g, color.b, 1.0); - } - ctx.rectangle(x, y, w, h); - let _ = ctx.set_source(&hue_grad); - let _ = ctx.fill(); - - constants::set_color(ctx, GRADIENT_BORDER); - ctx.rectangle(x + 0.5, y + 0.5, w - 1.0, h - 1.0); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); -} - -/// Draw one recent-color swatch, ringed when it matches the live color. -fn draw_recent_swatch(ctx: &cairo::Context, x: f64, y: f64, color: Color, selected: bool) { - if color.a < 1.0 { - draw_alpha_checkerboard(ctx, x, y, RECENT_SWATCH_SIZE, RECENT_SWATCH_SIZE); - } - ctx.set_source_rgba(color.r, color.g, color.b, color.a); - ctx.rectangle(x, y, RECENT_SWATCH_SIZE, RECENT_SWATCH_SIZE); - let _ = ctx.fill(); - - if selected { - constants::set_color(ctx, INDICATOR_RING); - ctx.set_line_width(2.0); - ctx.rectangle( - x - 1.0, - y - 1.0, - RECENT_SWATCH_SIZE + 2.0, - RECENT_SWATCH_SIZE + 2.0, - ); - } else { - constants::set_color(ctx, GRADIENT_BORDER); - ctx.set_line_width(1.0); - ctx.rectangle( - x + 0.5, - y + 0.5, - RECENT_SWATCH_SIZE - 1.0, - RECENT_SWATCH_SIZE - 1.0, - ); - } - let _ = ctx.stroke(); -} - -/// Draw the alpha bar for one colour: transparent on the left, opaque on the -/// right, over a checkerboard. -fn draw_alpha_bar(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, color: Color) { - draw_alpha_checkerboard(ctx, x, y, w, h); - - let ramp = cairo::LinearGradient::new(x, y, x + w, y); - ramp.add_color_stop_rgba(0.0, color.r, color.g, color.b, 0.0); - ramp.add_color_stop_rgba(1.0, color.r, color.g, color.b, 1.0); - ctx.rectangle(x, y, w, h); - let _ = ctx.set_source(&ramp); - let _ = ctx.fill(); - - constants::set_color(ctx, GRADIENT_BORDER); - ctx.rectangle(x + 0.5, y + 0.5, w - 1.0, h - 1.0); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); -} - -/// Draw the position marker for a horizontal bar. -fn draw_bar_marker(ctx: &cairo::Context, x: f64, y: f64, h: f64) { - constants::set_color(ctx, INDICATOR_OUTLINE); - ctx.rectangle(x - 2.5, y - 1.5, 5.0, h + 3.0); - ctx.set_line_width(3.0); - let _ = ctx.stroke_preserve(); - constants::set_color(ctx, INDICATOR_RING); - ctx.set_line_width(1.5); - let _ = ctx.stroke(); -} - -/// Draw the color indicator dot on the gradient. -fn draw_color_indicator(ctx: &cairo::Context, x: f64, y: f64, color: Color) { - let radius = 6.0; - - // Outer white ring - constants::set_color(ctx, INDICATOR_RING); - ctx.arc(x, y, radius + 2.0, 0.0, std::f64::consts::PI * 2.0); - let _ = ctx.fill(); - - // Inner color circle - ctx.set_source_rgba(color.r, color.g, color.b, 1.0); - ctx.arc(x, y, radius, 0.0, std::f64::consts::PI * 2.0); - let _ = ctx.fill(); - - // Dark outline - constants::set_color(ctx, INDICATOR_OUTLINE); - ctx.set_line_width(1.0); - ctx.arc(x, y, radius + 2.0, 0.0, std::f64::consts::PI * 2.0); - let _ = ctx.stroke(); -} - -/// Draw the preview swatch. -fn draw_preview_swatch(ctx: &cairo::Context, x: f64, y: f64, size: f64, color: Color) { - // Checkered background for transparency preview, clipped to the rounded - // swatch so tiles cannot spill past its corners. - checkerboard_behind(ctx, color.a, |ctx| { - draw_rounded_rect(ctx, x, y, size, size, RADIUS_SM); - }); - - // Draw color - ctx.set_source_rgba(color.r, color.g, color.b, color.a); - draw_rounded_rect(ctx, x, y, size, size, RADIUS_SM); - let _ = ctx.fill(); - - // Border - let luminance = crate::draw::perceived_luminance(color.r, color.g, color.b); - if luminance < 0.3 { - constants::set_color(ctx, SWATCH_BORDER_ON_DARK); - } else { - constants::set_color(ctx, SWATCH_BORDER_ON_LIGHT); - } - ctx.set_line_width(1.5); - draw_rounded_rect(ctx, x, y, size, size, RADIUS_SM); - let _ = ctx.stroke(); -} - -/// Draw the hex input field with validation feedback. -#[allow(clippy::too_many_arguments)] -fn draw_hex_input( - ctx: &cairo::Context, - x: f64, - y: f64, - w: f64, - h: f64, - value: &str, - focused: bool, - selected: bool, - valid: bool, -) { - // Outer glow when focused - red if invalid, accent if valid - if focused { - if valid { - constants::set_color(ctx, constants::with_alpha(ACCENT_PRIMARY, 0.2)); - } else { - constants::set_color(ctx, HEX_INVALID_GLOW); - } - draw_rounded_rect(ctx, x - 2.0, y - 2.0, w + 4.0, h + 4.0, RADIUS_STD); - let _ = ctx.fill(); - } - - // Background - constants::set_color(ctx, INPUT_BG); - draw_rounded_rect(ctx, x, y, w, h, RADIUS_SM); - let _ = ctx.fill(); - - // Border - red if invalid, blue if focused, gray otherwise - if !valid && focused { - constants::set_color(ctx, HEX_INVALID_BORDER); - ctx.set_line_width(2.0); - } else if focused { - constants::set_color(ctx, INPUT_BORDER_FOCUSED); - ctx.set_line_width(2.0); - } else { - constants::set_color(ctx, INPUT_BORDER_IDLE); - ctx.set_line_width(1.0); - } - draw_rounded_rect(ctx, x, y, w, h, RADIUS_SM); - let _ = ctx.stroke(); - - // Text - let value_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: 13.0, - }; - let extents = text_extents_for( - ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - 13.0, - value, - ); - let text_x = x + 8.0; - let text_y = y + h / 2.0 + extents.height() / 2.0; - - // Draw selection highlight when selected (full text selected) - if selected { - constants::set_color(ctx, BG_INPUT_SELECTION); - draw_rounded_rect( - ctx, - text_x - 2.0, - y + 3.0, - extents.width() + 4.0, - h - 6.0, - 2.0, - ); - let _ = ctx.fill(); - } - - constants::set_color(ctx, TEXT_PRIMARY); - draw_text_baseline(ctx, value_style, value, text_x, text_y, None); - - // Cursor when focused (at end of text) - if focused { - constants::set_color(ctx, INPUT_CARET); - let cursor_x = text_x + extents.width() + 2.0; - ctx.set_line_width(1.5); - ctx.move_to(cursor_x, y + 4.0); - ctx.line_to(cursor_x, y + h - 4.0); - let _ = ctx.stroke(); - } -} - -/// Draw one square action button (copy / paste / eyedropper) on the popup's -/// preview row: a neutral rounded fill washed with the accent on hover, and a -/// centered icon. -fn draw_action_button( - ctx: &cairo::Context, - x: f64, - y: f64, - size: f64, - hovered: bool, - icon: fn(&cairo::Context, f64, f64, f64), - icon_size: f64, -) { - draw_rounded_rect(ctx, x, y, size, size, RADIUS_MD); - if hovered { - constants::set_color(ctx, constants::with_alpha(ACCENT_PRIMARY, 0.8)); - } else { - constants::set_color(ctx, EYEDROPPER_BG); - } - let _ = ctx.fill_preserve(); - constants::set_color(ctx, crate::ui::theme::popup::border_modal()); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); - constants::set_color(ctx, TEXT_PRIMARY); - icon( - ctx, - x + (size - icon_size) / 2.0, - y + (size - icon_size) / 2.0, - icon_size, - ); -} - -fn draw_action_tooltip( - ctx: &cairo::Context, - text: &str, - anchor_x: f64, - anchor_y: f64, - screen_width: f64, - screen_height: f64, -) { - let Some((x, y, width, height)) = - action_tooltip_geometry(text, anchor_x, anchor_y, screen_width, screen_height) - else { - return; - }; - let style = action_tooltip_text_style(); - - constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_SHADOW); - draw_rounded_rect( - ctx, - x + TOOLTIP_SHADOW_OFFSET, - y + TOOLTIP_SHADOW_OFFSET, - width, - height, - RADIUS_SM, - ); - let _ = ctx.fill(); - - constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_BACKGROUND); - draw_rounded_rect(ctx, x, y, width, height, RADIUS_SM); - let _ = ctx.fill_preserve(); - constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_BORDER); - ctx.set_line_width(1.0); - let _ = ctx.stroke(); - - constants::set_color(ctx, TEXT_PRIMARY); - draw_text_baseline( - ctx, - style, - text, - x + TOOLTIP_PADDING_X, - y + TOOLTIP_PADDING_Y + style.size, - None, - ); -} - -fn action_tooltip_text_style() -> UiTextStyle<'static> { - UiTextStyle { - family: toolbar_theme::FONT_FAMILY_DEFAULT, - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: toolbar_theme::FONT_SIZE_TOOLTIP, - } -} - -fn action_tooltip_geometry( - text: &str, - anchor_x: f64, - anchor_y: f64, - screen_width: f64, - screen_height: f64, -) -> Option<(f64, f64, f64, f64)> { - let style = action_tooltip_text_style(); - let extents = measure_text(style, text, None)?; - let width = extents.width() + TOOLTIP_PADDING_X * 2.0; - let height = style.size + TOOLTIP_PADDING_Y * 2.0; - let max_x = (screen_width - width - TOOLTIP_SCREEN_MARGIN).max(TOOLTIP_SCREEN_MARGIN); - let x = (anchor_x + TOOLTIP_POINTER_OFFSET).clamp(TOOLTIP_SCREEN_MARGIN, max_x); - let above_y = anchor_y - height - TOOLTIP_POINTER_OFFSET; - let preferred_y = if above_y >= TOOLTIP_SCREEN_MARGIN { - above_y - } else { - anchor_y + TOOLTIP_POINTER_OFFSET - }; - let max_y = (screen_height - height - TOOLTIP_SCREEN_MARGIN).max(TOOLTIP_SCREEN_MARGIN); - let y = preferred_y.clamp(TOOLTIP_SCREEN_MARGIN, max_y); - Some((x, y, width, height)) -} - -/// Draw a button with hover state. -#[allow(clippy::too_many_arguments)] -fn draw_button( - ctx: &cairo::Context, - x: f64, - y: f64, - w: f64, - h: f64, - label: &str, - primary: bool, - hover: bool, -) { - // Hover glow effect - if hover { - let glow_color = if primary { - constants::with_alpha(ACCENT_PRIMARY, 0.25) - } else { - BUTTON_HOVER_GLOW - }; - constants::set_color(ctx, glow_color); - draw_rounded_rect(ctx, x - 2.0, y - 2.0, w + 4.0, h + 4.0, RADIUS_MD + 2.0); - let _ = ctx.fill(); - } - - // Background - brighter on hover - if primary { - if hover { - // Accent nudged towards accent-bright so hover reads brighter - let fill = constants::lerp_color(ACCENT_PRIMARY, ACCENT_BRIGHT, 0.25); - constants::set_color(ctx, constants::with_alpha(fill, 0.98)); - } else { - constants::set_color(ctx, constants::with_alpha(ACCENT_PRIMARY, 0.95)); - } - } else if hover { - constants::set_color(ctx, BUTTON_SECONDARY_BG_HOVER); - } else { - constants::set_color(ctx, BUTTON_SECONDARY_BG); - } - draw_rounded_rect(ctx, x, y, w, h, RADIUS_MD); - let _ = ctx.fill(); - - // Border - stronger on hover - if primary { - if hover { - constants::set_color(ctx, ACCENT_BRIGHT); - } else { - constants::set_color(ctx, constants::with_alpha(ACCENT_BRIGHT, 0.9)); - } - } else if hover { - constants::set_color(ctx, BUTTON_SECONDARY_BORDER_HOVER); - } else { - constants::set_color(ctx, BUTTON_SECONDARY_BORDER); - } - ctx.set_line_width(1.0); - draw_rounded_rect(ctx, x, y, w, h, RADIUS_MD); - let _ = ctx.stroke(); - - // Label - let label_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Bold, - size: 13.0, - }; - constants::set_color(ctx, TEXT_PRIMARY); - - let extents = text_extents_for( - ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, - 13.0, - label, - ); - let text_x = x + (w - extents.width()) / 2.0; - let text_y = y + h / 2.0 + extents.height() / 2.0; - draw_text_baseline(ctx, label_style, label, text_x, text_y, None); -} - #[cfg(test)] mod tests { use super::*; @@ -853,9 +471,10 @@ mod tests { } } - fn measured_width(ctx: &cairo::Context, text: &str) -> f64 { + fn measured_width(engine: &UiTextEngine, ctx: &cairo::Context, text: &str) -> f64 { let style = title_style(); - text_extents_for( + text_extents_for_with_engine( + engine, ctx, style.family, style.slant, @@ -868,6 +487,7 @@ mod tests { #[test] fn wide_titles_are_trimmed_to_the_panel_not_a_character_budget() { + let engine = &UiTextEngine::default(); let ctx = test_context(); let layout = ColorPickerPopupLayout::compute(1920, 1080, true); let content_width = layout.width - TITLE_INSET * 2.0; @@ -875,24 +495,28 @@ mod tests { // Wide glyphs: few characters, far more pixels than a Latin label of // the same length, which is why the budget has to be measured. let wide = format!("Recolor {}", "W".repeat(40)); - let fitted = fit_title_to_panel(&ctx, title_style(), &wide, &layout); + let fitted = fit_title_to_panel(engine, &ctx, title_style(), &wide, &layout); assert!( - measured_width(&ctx, &wide) > content_width, + measured_width(engine, &ctx, &wide) > content_width, "fixture is wide" ); - assert!(measured_width(&ctx, &fitted) <= content_width); + assert!(measured_width(engine, &ctx, &fitted) <= content_width); assert!(fitted.len() < wide.len()); // Non-Latin scripts take the same path. let cjk = format!("Recolor {}", "測".repeat(40)); - let fitted_cjk = fit_title_to_panel(&ctx, title_style(), &cjk, &layout); - assert!(measured_width(&ctx, &fitted_cjk) <= content_width); + let fitted_cjk = fit_title_to_panel(engine, &ctx, title_style(), &cjk, &layout); + assert!(measured_width(engine, &ctx, &fitted_cjk) <= content_width); // A title that already fits is left exactly as composed. let short = "Recolor Pink"; assert_eq!( - fit_title_to_panel(&ctx, title_style(), short, &layout), + fit_title_to_panel(engine, &ctx, title_style(), short, &layout), short ); } } + +#[cfg(test)] +#[path = "color_picker_popup/tests/engine.rs"] +mod engine_tests; diff --git a/src/ui/color_picker_popup/controls.rs b/src/ui/color_picker_popup/controls.rs new file mode 100644 index 000000000..c9acd0ca6 --- /dev/null +++ b/src/ui/color_picker_popup/controls.rs @@ -0,0 +1,450 @@ +//! Popup color controls and their labels/tooltips. + +use super::*; + +/// Draw the HSV color gradient. +/// Draw the saturation (x) by value (y) square for one hue. +/// +/// White-to-hue horizontally, then black over the top vertically — the standard +/// construction, and the one the toolbar's inline picker already uses. Both +/// axes are real: unlike the previous hue-by-value gradient, every point here +/// maps to the colour actually produced by clicking it. +pub(super) fn draw_sat_val_square(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, hue: f64) { + let full = crate::draw::color::hsv_to_rgb(hue, 1.0, 1.0); + + let sat_grad = cairo::LinearGradient::new(x, y, x + w, y); + sat_grad.add_color_stop_rgba(0.0, 1.0, 1.0, 1.0, 1.0); + sat_grad.add_color_stop_rgba(1.0, full.r, full.g, full.b, 1.0); + ctx.rectangle(x, y, w, h); + let _ = ctx.set_source(&sat_grad); + let _ = ctx.fill(); + + let val_grad = cairo::LinearGradient::new(x, y, x, y + h); + val_grad.add_color_stop_rgba(0.0, 0.0, 0.0, 0.0, 0.0); + val_grad.add_color_stop_rgba(1.0, 0.0, 0.0, 0.0, 1.0); + ctx.rectangle(x, y, w, h); + let _ = ctx.set_source(&val_grad); + let _ = ctx.fill(); + + constants::set_color(ctx, GRADIENT_BORDER); + ctx.rectangle(x + 0.5, y + 0.5, w - 1.0, h - 1.0); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); +} + +/// Draw the horizontal hue bar. +pub(super) fn draw_hue_bar(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64) { + let hue_grad = cairo::LinearGradient::new(x, y, x + w, y); + for step in 0..=6 { + let t = f64::from(step) / 6.0; + let color = crate::draw::color::hsv_to_rgb(t, 1.0, 1.0); + hue_grad.add_color_stop_rgba(t, color.r, color.g, color.b, 1.0); + } + ctx.rectangle(x, y, w, h); + let _ = ctx.set_source(&hue_grad); + let _ = ctx.fill(); + + constants::set_color(ctx, GRADIENT_BORDER); + ctx.rectangle(x + 0.5, y + 0.5, w - 1.0, h - 1.0); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); +} + +/// Draw one recent-color swatch, ringed when it matches the live color. +pub(super) fn draw_recent_swatch( + ctx: &cairo::Context, + x: f64, + y: f64, + color: Color, + selected: bool, +) { + if color.a < 1.0 { + draw_alpha_checkerboard(ctx, x, y, RECENT_SWATCH_SIZE, RECENT_SWATCH_SIZE); + } + ctx.set_source_rgba(color.r, color.g, color.b, color.a); + ctx.rectangle(x, y, RECENT_SWATCH_SIZE, RECENT_SWATCH_SIZE); + let _ = ctx.fill(); + + if selected { + constants::set_color(ctx, INDICATOR_RING); + ctx.set_line_width(2.0); + ctx.rectangle( + x - 1.0, + y - 1.0, + RECENT_SWATCH_SIZE + 2.0, + RECENT_SWATCH_SIZE + 2.0, + ); + } else { + constants::set_color(ctx, GRADIENT_BORDER); + ctx.set_line_width(1.0); + ctx.rectangle( + x + 0.5, + y + 0.5, + RECENT_SWATCH_SIZE - 1.0, + RECENT_SWATCH_SIZE - 1.0, + ); + } + let _ = ctx.stroke(); +} + +/// Draw the alpha bar for one colour: transparent on the left, opaque on the +/// right, over a checkerboard. +pub(super) fn draw_alpha_bar(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, color: Color) { + draw_alpha_checkerboard(ctx, x, y, w, h); + + let ramp = cairo::LinearGradient::new(x, y, x + w, y); + ramp.add_color_stop_rgba(0.0, color.r, color.g, color.b, 0.0); + ramp.add_color_stop_rgba(1.0, color.r, color.g, color.b, 1.0); + ctx.rectangle(x, y, w, h); + let _ = ctx.set_source(&ramp); + let _ = ctx.fill(); + + constants::set_color(ctx, GRADIENT_BORDER); + ctx.rectangle(x + 0.5, y + 0.5, w - 1.0, h - 1.0); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); +} + +/// Draw the position marker for a horizontal bar. +pub(super) fn draw_bar_marker(ctx: &cairo::Context, x: f64, y: f64, h: f64) { + constants::set_color(ctx, INDICATOR_OUTLINE); + ctx.rectangle(x - 2.5, y - 1.5, 5.0, h + 3.0); + ctx.set_line_width(3.0); + let _ = ctx.stroke_preserve(); + constants::set_color(ctx, INDICATOR_RING); + ctx.set_line_width(1.5); + let _ = ctx.stroke(); +} + +/// Draw the color indicator dot on the gradient. +pub(super) fn draw_color_indicator(ctx: &cairo::Context, x: f64, y: f64, color: Color) { + let radius = 6.0; + + // Outer white ring + constants::set_color(ctx, INDICATOR_RING); + ctx.arc(x, y, radius + 2.0, 0.0, std::f64::consts::PI * 2.0); + let _ = ctx.fill(); + + // Inner color circle + ctx.set_source_rgba(color.r, color.g, color.b, 1.0); + ctx.arc(x, y, radius, 0.0, std::f64::consts::PI * 2.0); + let _ = ctx.fill(); + + // Dark outline + constants::set_color(ctx, INDICATOR_OUTLINE); + ctx.set_line_width(1.0); + ctx.arc(x, y, radius + 2.0, 0.0, std::f64::consts::PI * 2.0); + let _ = ctx.stroke(); +} + +/// Draw the preview swatch. +pub(super) fn draw_preview_swatch(ctx: &cairo::Context, x: f64, y: f64, size: f64, color: Color) { + // Checkered background for transparency preview, clipped to the rounded + // swatch so tiles cannot spill past its corners. + checkerboard_behind(ctx, color.a, |ctx| { + draw_rounded_rect(ctx, x, y, size, size, RADIUS_SM); + }); + + // Draw color + ctx.set_source_rgba(color.r, color.g, color.b, color.a); + draw_rounded_rect(ctx, x, y, size, size, RADIUS_SM); + let _ = ctx.fill(); + + // Border + let luminance = crate::draw::perceived_luminance(color.r, color.g, color.b); + if luminance < 0.3 { + constants::set_color(ctx, SWATCH_BORDER_ON_DARK); + } else { + constants::set_color(ctx, SWATCH_BORDER_ON_LIGHT); + } + ctx.set_line_width(1.5); + draw_rounded_rect(ctx, x, y, size, size, RADIUS_SM); + let _ = ctx.stroke(); +} + +/// Draw the hex input field with validation feedback. +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_hex_input( + engine: &UiTextEngine, + ctx: &cairo::Context, + x: f64, + y: f64, + w: f64, + h: f64, + value: &str, + focused: bool, + selected: bool, + valid: bool, +) { + // Outer glow when focused - red if invalid, accent if valid + if focused { + if valid { + constants::set_color(ctx, constants::with_alpha(ACCENT_PRIMARY, 0.2)); + } else { + constants::set_color(ctx, HEX_INVALID_GLOW); + } + draw_rounded_rect(ctx, x - 2.0, y - 2.0, w + 4.0, h + 4.0, RADIUS_STD); + let _ = ctx.fill(); + } + + // Background + constants::set_color(ctx, INPUT_BG); + draw_rounded_rect(ctx, x, y, w, h, RADIUS_SM); + let _ = ctx.fill(); + + // Border - red if invalid, blue if focused, gray otherwise + if !valid && focused { + constants::set_color(ctx, HEX_INVALID_BORDER); + ctx.set_line_width(2.0); + } else if focused { + constants::set_color(ctx, INPUT_BORDER_FOCUSED); + ctx.set_line_width(2.0); + } else { + constants::set_color(ctx, INPUT_BORDER_IDLE); + ctx.set_line_width(1.0); + } + draw_rounded_rect(ctx, x, y, w, h, RADIUS_SM); + let _ = ctx.stroke(); + + // Text + let value_style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: 13.0, + }; + let extents = text_extents_for_with_engine( + engine, + ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + 13.0, + value, + ); + let text_x = x + 8.0; + let text_y = y + h / 2.0 + extents.height() / 2.0; + + // Draw selection highlight when selected (full text selected) + if selected { + constants::set_color(ctx, BG_INPUT_SELECTION); + draw_rounded_rect( + ctx, + text_x - 2.0, + y + 3.0, + extents.width() + 4.0, + h - 6.0, + 2.0, + ); + let _ = ctx.fill(); + } + + constants::set_color(ctx, TEXT_PRIMARY); + engine.draw_baseline(ctx, value_style, value, text_x, text_y, None); + + // Cursor when focused (at end of text) + if focused { + constants::set_color(ctx, INPUT_CARET); + let cursor_x = text_x + extents.width() + 2.0; + ctx.set_line_width(1.5); + ctx.move_to(cursor_x, y + 4.0); + ctx.line_to(cursor_x, y + h - 4.0); + let _ = ctx.stroke(); + } +} + +/// Draw one square action button (copy / paste / eyedropper) on the popup's +/// preview row: a neutral rounded fill washed with the accent on hover, and a +/// centered icon. +pub(super) fn draw_action_button( + ctx: &cairo::Context, + x: f64, + y: f64, + size: f64, + hovered: bool, + icon: fn(&cairo::Context, f64, f64, f64), + icon_size: f64, +) { + draw_rounded_rect(ctx, x, y, size, size, RADIUS_MD); + if hovered { + constants::set_color(ctx, constants::with_alpha(ACCENT_PRIMARY, 0.8)); + } else { + constants::set_color(ctx, EYEDROPPER_BG); + } + let _ = ctx.fill_preserve(); + constants::set_color(ctx, crate::ui::theme::popup::border_modal()); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + constants::set_color(ctx, TEXT_PRIMARY); + icon( + ctx, + x + (size - icon_size) / 2.0, + y + (size - icon_size) / 2.0, + icon_size, + ); +} + +pub(super) fn draw_action_tooltip( + engine: &UiTextEngine, + ctx: &cairo::Context, + text: &str, + anchor_x: f64, + anchor_y: f64, + screen_width: f64, + screen_height: f64, +) { + let Some((x, y, width, height)) = action_tooltip_geometry( + engine, + text, + anchor_x, + anchor_y, + screen_width, + screen_height, + ) else { + return; + }; + let style = action_tooltip_text_style(); + + constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_SHADOW); + draw_rounded_rect( + ctx, + x + TOOLTIP_SHADOW_OFFSET, + y + TOOLTIP_SHADOW_OFFSET, + width, + height, + RADIUS_SM, + ); + let _ = ctx.fill(); + + constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_BACKGROUND); + draw_rounded_rect(ctx, x, y, width, height, RADIUS_SM); + let _ = ctx.fill_preserve(); + constants::set_color(ctx, toolbar_theme::COLOR_TOOLTIP_BORDER); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + + constants::set_color(ctx, TEXT_PRIMARY); + engine.draw_baseline( + ctx, + style, + text, + x + TOOLTIP_PADDING_X, + y + TOOLTIP_PADDING_Y + style.size, + None, + ); +} + +fn action_tooltip_text_style() -> UiTextStyle<'static> { + UiTextStyle { + family: toolbar_theme::FONT_FAMILY_DEFAULT, + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: toolbar_theme::FONT_SIZE_TOOLTIP, + } +} + +pub(super) fn action_tooltip_geometry( + engine: &UiTextEngine, + text: &str, + anchor_x: f64, + anchor_y: f64, + screen_width: f64, + screen_height: f64, +) -> Option<(f64, f64, f64, f64)> { + let style = action_tooltip_text_style(); + let extents = engine.measure(style, text, None)?; + let width = extents.width() + TOOLTIP_PADDING_X * 2.0; + let height = style.size + TOOLTIP_PADDING_Y * 2.0; + let max_x = (screen_width - width - TOOLTIP_SCREEN_MARGIN).max(TOOLTIP_SCREEN_MARGIN); + let x = (anchor_x + TOOLTIP_POINTER_OFFSET).clamp(TOOLTIP_SCREEN_MARGIN, max_x); + let above_y = anchor_y - height - TOOLTIP_POINTER_OFFSET; + let preferred_y = if above_y >= TOOLTIP_SCREEN_MARGIN { + above_y + } else { + anchor_y + TOOLTIP_POINTER_OFFSET + }; + let max_y = (screen_height - height - TOOLTIP_SCREEN_MARGIN).max(TOOLTIP_SCREEN_MARGIN); + let y = preferred_y.clamp(TOOLTIP_SCREEN_MARGIN, max_y); + Some((x, y, width, height)) +} + +/// Draw a button with hover state. +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_button( + engine: &UiTextEngine, + ctx: &cairo::Context, + x: f64, + y: f64, + w: f64, + h: f64, + label: &str, + primary: bool, + hover: bool, +) { + // Hover glow effect + if hover { + let glow_color = if primary { + constants::with_alpha(ACCENT_PRIMARY, 0.25) + } else { + BUTTON_HOVER_GLOW + }; + constants::set_color(ctx, glow_color); + draw_rounded_rect(ctx, x - 2.0, y - 2.0, w + 4.0, h + 4.0, RADIUS_MD + 2.0); + let _ = ctx.fill(); + } + + // Background - brighter on hover + if primary { + if hover { + // Accent nudged towards accent-bright so hover reads brighter + let fill = constants::lerp_color(ACCENT_PRIMARY, ACCENT_BRIGHT, 0.25); + constants::set_color(ctx, constants::with_alpha(fill, 0.98)); + } else { + constants::set_color(ctx, constants::with_alpha(ACCENT_PRIMARY, 0.95)); + } + } else if hover { + constants::set_color(ctx, BUTTON_SECONDARY_BG_HOVER); + } else { + constants::set_color(ctx, BUTTON_SECONDARY_BG); + } + draw_rounded_rect(ctx, x, y, w, h, RADIUS_MD); + let _ = ctx.fill(); + + // Border - stronger on hover + if primary { + if hover { + constants::set_color(ctx, ACCENT_BRIGHT); + } else { + constants::set_color(ctx, constants::with_alpha(ACCENT_BRIGHT, 0.9)); + } + } else if hover { + constants::set_color(ctx, BUTTON_SECONDARY_BORDER_HOVER); + } else { + constants::set_color(ctx, BUTTON_SECONDARY_BORDER); + } + ctx.set_line_width(1.0); + draw_rounded_rect(ctx, x, y, w, h, RADIUS_MD); + let _ = ctx.stroke(); + + // Label + let label_style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: 13.0, + }; + constants::set_color(ctx, TEXT_PRIMARY); + + let extents = text_extents_for_with_engine( + engine, + ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + 13.0, + label, + ); + let text_x = x + (w - extents.width()) / 2.0; + let text_y = y + h / 2.0 + extents.height() / 2.0; + engine.draw_baseline(ctx, label_style, label, text_x, text_y, None); +} diff --git a/src/ui/color_picker_popup/tests/engine.rs b/src/ui/color_picker_popup/tests/engine.rs new file mode 100644 index 000000000..834728476 --- /dev/null +++ b/src/ui/color_picker_popup/tests/engine.rs @@ -0,0 +1,57 @@ +use super::*; + +fn pixels(density: i32, paint: impl FnOnce(&cairo::Context)) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 800 * density, 600 * density).unwrap(); + surface.set_device_scale(density as f64, density as f64); + let ctx = cairo::Context::new(&surface).unwrap(); + paint(&ctx); + drop(ctx); + surface.data().unwrap().to_vec() +} + +#[test] +fn retained_color_popup_engine_shares_tooltip_damage_and_paint_across_targets() { + let engine = UiTextEngine::default(); + let mut input = crate::input::state::test_support::make_test_input_state(); + input.open_color_picker_popup(); + input.update_color_picker_popup_layout(800, 600); + let layout = input.color_picker_popup_layout().unwrap(); + let anchor = ( + layout.eyedropper_btn_x + layout.action_btn_size / 2.0, + layout.eyedropper_btn_y + layout.action_btn_size / 2.0, + ); + for hovered in [false, true, false] { + input.color_picker_popup_set_hover(hovered.then_some(anchor)); + for density in [1, 2, 1] { + let bounds = + color_picker_popup_visual_geometry_with_engine(&engine, &input, 800, 600).unwrap(); + assert_eq!( + Some(bounds), + color_picker_popup_visual_geometry(&input, 800, 600) + ); + assert!(bounds.2 >= layout.width && bounds.3 >= layout.height); + if hovered { + let (text, x, y) = layout.action_tooltip_anchor_at(anchor.0, anchor.1).unwrap(); + let tip = action_tooltip_geometry(&engine, text, x, y, 800.0, 600.0).unwrap(); + assert!(bounds.0 <= tip.0 && bounds.1 <= tip.1); + assert!(bounds.0 + bounds.2 >= tip.0 + tip.2 + TOOLTIP_SHADOW_OFFSET); + assert!(bounds.1 + bounds.3 >= tip.1 + tip.3 + TOOLTIP_SHADOW_OFFSET); + } + let actual = pixels(density, |ctx| { + render_color_picker_popup_with_engine(&engine, ctx, &input, 800, 600) + }); + let expected = pixels(density, |ctx| { + render_color_picker_popup(ctx, &input, 800, 600) + }); + assert!( + actual == expected, + "color popup hovered {hovered}, density {density}" + ); + assert_eq!( + Some(bounds), + color_picker_popup_visual_geometry_with_engine(&engine, &input, 800, 600) + ); + } + } +} diff --git a/src/ui/command_palette.rs b/src/ui/command_palette.rs index 350bffbe8..c8fc5d2bd 100644 --- a/src/ui/command_palette.rs +++ b/src/ui/command_palette.rs @@ -7,17 +7,23 @@ use crate::input::state::{ COMMAND_PALETTE_MAX_VISIBLE, COMMAND_PALETTE_PADDING, COMMAND_PALETTE_QUERY_PLACEHOLDER, COMMAND_PALETTE_TOP_RATIO, CommandPaletteListRow, }; -use crate::ui_text::{UiTextStyle, draw_text_baseline, measure_text}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ self, EMPTY_COMMAND_PALETTE, EMPTY_COMMAND_SUGGESTIONS, INPUT_BG, INPUT_BORDER_FOCUSED, OVERLAY_DIM_MEDIUM, RADIUS_LG, RADIUS_STD, SHADOW, TEXT_DESCRIPTION, TEXT_PLACEHOLDER, TEXT_WHITE, }; -use super::primitives::{draw_rounded_rect, text_extents_for}; +use super::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use super::theme::Rgba; mod command_palette_row; +mod modals; + +use modals::{ + command_palette_action_tooltip_geometry, draw_command_palette_action_tooltip, + keybinding_capture_geometry, render_keybinding_capture, +}; use self::command_palette_row::{command_palette_row_styles, render_command_row}; @@ -66,13 +72,36 @@ pub fn render_command_palette( input_state: &InputState, screen_width: u32, screen_height: u32, +) { + render_command_palette_with_engine( + &UiTextEngine::default(), + ctx, + input_state, + screen_width, + screen_height, + ) +} + +pub(crate) fn render_command_palette_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + input_state: &InputState, + screen_width: u32, + screen_height: u32, ) { if !input_state.command_palette_is_engaged() { return; } if let Some(action) = input_state.keybinding_capture_action() { - render_keybinding_capture(ctx, input_state, action, screen_width, screen_height); + render_keybinding_capture( + engine, + ctx, + input_state, + action, + screen_width, + screen_height, + ); return; } @@ -100,6 +129,7 @@ pub fn render_command_palette( let mut cursor_y = y + COMMAND_PALETTE_PADDING; cursor_y = draw_command_palette_input( + engine, ctx, inner_x, cursor_y, @@ -107,10 +137,19 @@ pub fn render_command_palette( &input_state.command_palette.query, ); - render_command_palette_rows(ctx, input_state, &rows, inner_x, inner_width, cursor_y); + render_command_palette_rows( + engine, + ctx, + input_state, + &rows, + inner_x, + inner_width, + cursor_y, + ); if rows.is_empty() && !input_state.command_palette.query.is_empty() { draw_command_palette_empty_state( + engine, ctx, inner_x, inner_width, @@ -132,6 +171,7 @@ pub fn render_command_palette( input_state.command_palette_action_tooltip_for_layout(&rows, geometry) { draw_command_palette_action_tooltip( + engine, ctx, tooltip, pointer_x as f64, @@ -141,7 +181,7 @@ pub fn render_command_palette( ); } - draw_command_palette_escape_hint(ctx, x, y, palette_width, height); + draw_command_palette_escape_hint(engine, ctx, x, y, palette_width, height); } /// Bounds of every pixel the command palette may change this frame, excluding @@ -152,6 +192,20 @@ pub fn command_palette_visual_geometry( input_state: &InputState, screen_width: u32, screen_height: u32, +) -> Option<(f64, f64, f64, f64)> { + command_palette_visual_geometry_with_engine( + &UiTextEngine::default(), + input_state, + screen_width, + screen_height, + ) +} + +pub(crate) fn command_palette_visual_geometry_with_engine( + engine: &UiTextEngine, + input_state: &InputState, + screen_width: u32, + screen_height: u32, ) -> Option<(f64, f64, f64, f64)> { if !input_state.command_palette_is_engaged() { return None; @@ -180,6 +234,7 @@ pub fn command_palette_visual_geometry( if let Some((tooltip, pointer_x, pointer_y)) = input_state.command_palette_action_tooltip_for_layout(&rows, geometry) && let Some(tooltip_bounds) = command_palette_action_tooltip_geometry( + engine, tooltip, pointer_x as f64, pointer_y as f64, @@ -201,148 +256,6 @@ fn union_bounds(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> (f64, f64, (min_x, min_y, max_x - min_x, max_y - min_y) } -fn command_palette_action_tooltip_geometry( - text: &str, - pointer_x: f64, - pointer_y: f64, - screen_width: f64, - screen_height: f64, -) -> Option<(f64, f64, f64, f64)> { - let style = command_palette_text_style( - COMMAND_PALETTE_SHORTCUT_TEXT_SIZE, - cairo::FontWeight::Normal, - cairo::FontSlant::Normal, - ); - let extents = measure_text(style, text, None)?; - let width = extents.width() + TOOLTIP_PADDING_X * 2.0; - let height = style.size + TOOLTIP_PADDING_Y * 2.0; - let x = (pointer_x + TOOLTIP_POINTER_OFFSET) - .min((screen_width - width - FRAME_SHADOW_OFFSET).max(FRAME_SHADOW_OFFSET)); - let y = (pointer_y + TOOLTIP_POINTER_OFFSET) - .min((screen_height - height - FRAME_SHADOW_OFFSET).max(FRAME_SHADOW_OFFSET)); - Some((x, y, width, height)) -} - -fn draw_command_palette_action_tooltip( - ctx: &cairo::Context, - text: &str, - pointer_x: f64, - pointer_y: f64, - screen_width: f64, - screen_height: f64, -) { - let style = command_palette_text_style( - COMMAND_PALETTE_SHORTCUT_TEXT_SIZE, - cairo::FontWeight::Normal, - cairo::FontSlant::Normal, - ); - let Some((x, y, width, height)) = command_palette_action_tooltip_geometry( - text, - pointer_x, - pointer_y, - screen_width, - screen_height, - ) else { - return; - }; - - constants::set_color(ctx, TOOLTIP_BG); - draw_rounded_rect(ctx, x, y, width, height, 5.0); - let _ = ctx.fill(); - constants::set_color(ctx, TEXT_WHITE); - draw_text_baseline( - ctx, - style, - text, - x + TOOLTIP_PADDING_X, - y + TOOLTIP_PADDING_Y + style.size, - None, - ); -} - -/// Frame of the shortcut-capture modal, shared by rendering and damage. -fn keybinding_capture_geometry(screen_width: u32, screen_height: u32) -> (f64, f64, f64, f64) { - let width = 520.0_f64.min(screen_width as f64 - 24.0); - let height = 170.0; - let x = (screen_width as f64 - width) / 2.0; - let y = screen_height as f64 * COMMAND_PALETTE_TOP_RATIO; - (x, y, width, height) -} - -fn render_keybinding_capture( - ctx: &cairo::Context, - input_state: &InputState, - action: crate::config::Action, - screen_width: u32, - screen_height: u32, -) { - let (x, y, width, height) = keybinding_capture_geometry(screen_width, screen_height); - draw_command_palette_frame( - ctx, - screen_width as f64, - screen_height as f64, - x, - y, - width, - height, - ); - - let title_style = - command_palette_text_style(18.0, cairo::FontWeight::Bold, cairo::FontSlant::Normal); - let body_style = - command_palette_text_style(13.0, cairo::FontWeight::Normal, cairo::FontSlant::Normal); - constants::set_color(ctx, TEXT_WHITE); - draw_text_baseline( - ctx, - title_style, - &format!("Rebind {}", action_label(action)), - x + 22.0, - y + 38.0, - None, - ); - let current = input_state.action_binding_labels(action); - constants::set_color(ctx, TEXT_DESCRIPTION); - draw_text_baseline( - ctx, - body_style, - &format!( - "Current: {}", - if current.is_empty() { - "Not bound".to_string() - } else { - current.join(", ") - } - ), - x + 22.0, - y + 70.0, - None, - ); - constants::set_color(ctx, TEXT_WHITE); - draw_text_baseline( - ctx, - body_style, - "Press the new shortcut now", - x + 22.0, - y + 108.0, - None, - ); - constants::set_color(ctx, TEXT_DESCRIPTION); - draw_text_baseline( - ctx, - body_style, - KEYBINDING_CAPTURE_SCOPE_NOTE, - x + 22.0, - y + 140.0, - None, - ); -} - -/// Says what a captured chord costs and what refuses it, at the interaction -/// point. The edit is durable, so the line names the two things that are not -/// obvious: backing out, and what happens to a chord that is already taken. -const KEYBINDING_CAPTURE_SCOPE_NOTE: &str = - "Escape cancels • a shortcut already in use is rejected"; - fn command_palette_text_style( size: f64, weight: cairo::FontWeight, @@ -404,6 +317,7 @@ fn draw_command_palette_frame( } fn draw_command_palette_input( + engine: &UiTextEngine, ctx: &cairo::Context, inner_x: f64, mut cursor_y: f64, @@ -433,7 +347,7 @@ fn draw_command_palette_input( if query.is_empty() { constants::set_color(ctx, TEXT_PLACEHOLDER); - draw_text_baseline( + engine.draw_baseline( ctx, input_style, COMMAND_PALETTE_QUERY_PLACEHOLDER, @@ -443,7 +357,7 @@ fn draw_command_palette_input( ); } else { constants::set_color(ctx, TEXT_WHITE); - draw_text_baseline(ctx, input_style, query, inner_x + 10.0, text_y, None); + engine.draw_baseline(ctx, input_style, query, inner_x + 10.0, text_y, None); } cursor_y += COMMAND_PALETTE_INPUT_HEIGHT + COMMAND_PALETTE_LIST_GAP; @@ -451,6 +365,7 @@ fn draw_command_palette_input( } fn render_command_palette_rows( + engine: &UiTextEngine, ctx: &cairo::Context, input_state: &InputState, rows: &[CommandPaletteListRow], @@ -470,7 +385,7 @@ fn render_command_palette_rows( let item_y = start_y + (visible_idx as f64 * COMMAND_PALETTE_ITEM_HEIGHT); match row { CommandPaletteListRow::Header(label) => { - render_command_group_header(ctx, label, inner_x, inner_width, item_y); + render_command_group_header(engine, ctx, label, inner_x, inner_width, item_y); } CommandPaletteListRow::Command { command, @@ -478,6 +393,7 @@ fn render_command_palette_rows( } => { let is_selected = *command_index == input_state.command_palette.selected; render_command_row( + engine, ctx, input_state, command, @@ -495,6 +411,7 @@ fn render_command_palette_rows( /// Group header row: small uppercase label with a hairline rule filling the /// remaining width. Occupies a full item row so hit-testing stays uniform. fn render_command_group_header( + engine: &UiTextEngine, ctx: &cairo::Context, label: &str, inner_x: f64, @@ -509,7 +426,7 @@ fn render_command_group_header( let text = label.to_uppercase(); let baseline = item_y + COMMAND_PALETTE_ITEM_HEIGHT / 2.0 + style.size / 3.0; constants::set_color(ctx, constants::TEXT_HINT); - let extents = draw_text_baseline(ctx, style, &text, inner_x + 10.0, baseline, None); + let extents = engine.draw_baseline(ctx, style, &text, inner_x + 10.0, baseline, None); let rule_start = inner_x + 10.0 + extents.width() + 10.0; let rule_end = inner_x + inner_width - 8.0; @@ -523,6 +440,7 @@ fn render_command_group_header( } fn draw_command_palette_empty_state( + engine: &UiTextEngine, ctx: &cairo::Context, inner_x: f64, inner_width: f64, @@ -536,7 +454,8 @@ fn draw_command_palette_empty_state( cairo::FontSlant::Normal, ); constants::set_color(ctx, TEXT_DESCRIPTION); - let msg_extents = text_extents_for( + let msg_extents = text_extents_for_with_engine( + engine, ctx, COMMAND_PALETTE_FONT_FAMILY, cairo::FontSlant::Normal, @@ -544,7 +463,7 @@ fn draw_command_palette_empty_state( empty_style.size, EMPTY_COMMAND_PALETTE, ); - draw_text_baseline( + engine.draw_baseline( ctx, empty_style, EMPTY_COMMAND_PALETTE, @@ -559,7 +478,8 @@ fn draw_command_palette_empty_state( cairo::FontSlant::Italic, ); constants::set_color(ctx, constants::with_alpha(TEXT_DESCRIPTION, 0.7)); - let suggest_extents = text_extents_for( + let suggest_extents = text_extents_for_with_engine( + engine, ctx, COMMAND_PALETTE_FONT_FAMILY, cairo::FontSlant::Italic, @@ -567,7 +487,7 @@ fn draw_command_palette_empty_state( suggest_style.size, EMPTY_COMMAND_SUGGESTIONS, ); - draw_text_baseline( + engine.draw_baseline( ctx, suggest_style, EMPTY_COMMAND_SUGGESTIONS, @@ -621,6 +541,7 @@ fn render_command_palette_scroll_indicator( } fn draw_command_palette_escape_hint( + engine: &UiTextEngine, ctx: &cairo::Context, x: f64, y: f64, @@ -634,7 +555,8 @@ fn draw_command_palette_escape_hint( ); constants::set_color(ctx, constants::with_alpha(TEXT_DESCRIPTION, 0.6)); let hint_y = y + height - HINT_BASELINE_BOTTOM_OFFSET; - let hint_extents = text_extents_for( + let hint_extents = text_extents_for_with_engine( + engine, ctx, COMMAND_PALETTE_FONT_FAMILY, cairo::FontSlant::Normal, @@ -642,7 +564,7 @@ fn draw_command_palette_escape_hint( COMMAND_PALETTE_HINT_TEXT_SIZE, COMMAND_PALETTE_INPUT_HINT, ); - draw_text_baseline( + engine.draw_baseline( ctx, hint_style, COMMAND_PALETTE_INPUT_HINT, @@ -652,7 +574,9 @@ fn draw_command_palette_escape_hint( ); } +#[allow(clippy::too_many_arguments)] fn ellipsize_to_width( + engine: &UiTextEngine, ctx: &cairo::Context, text: &str, family: &str, @@ -665,12 +589,13 @@ fn ellipsize_to_width( return String::new(); } - let extents = text_extents_for(ctx, family, slant, weight, size, text); + let extents = text_extents_for_with_engine(engine, ctx, family, slant, weight, size, text); if extents.width() <= max_width { return text.to_string(); } - let ellipsis_extents = text_extents_for(ctx, family, slant, weight, size, ELLIPSIS); + let ellipsis_extents = + text_extents_for_with_engine(engine, ctx, family, slant, weight, size, ELLIPSIS); if ellipsis_extents.width() > max_width { return String::new(); } @@ -685,7 +610,8 @@ fn ellipsize_to_width( while low < high { let mid = (low + high).div_ceil(2); let candidate = format!("{}{}", &text[..boundaries[mid]], ELLIPSIS); - let candidate_extents = text_extents_for(ctx, family, slant, weight, size, &candidate); + let candidate_extents = + text_extents_for_with_engine(engine, ctx, family, slant, weight, size, &candidate); if candidate_extents.width() <= max_width { low = mid; } else { @@ -705,8 +631,9 @@ mod tests { cairo::Context::new(&surface).expect("context") } - fn text_width(ctx: &cairo::Context, text: &str) -> f64 { - text_extents_for( + fn text_width(engine: &UiTextEngine, ctx: &cairo::Context, text: &str) -> f64 { + text_extents_for_with_engine( + engine, ctx, COMMAND_PALETTE_FONT_FAMILY, cairo::FontSlant::Normal, @@ -719,17 +646,19 @@ mod tests { #[test] fn ellipsize_keeps_full_text_when_it_fits() { + let engine = &UiTextEngine::default(); let ctx = test_context(); let text = "Short label"; assert_eq!( ellipsize_to_width( + engine, &ctx, text, COMMAND_PALETTE_FONT_FAMILY, cairo::FontSlant::Normal, cairo::FontWeight::Normal, COMMAND_PALETTE_DESC_TEXT_SIZE, - text_width(&ctx, text), + text_width(engine, &ctx, text), ), text ); @@ -737,10 +666,12 @@ mod tests { #[test] fn ellipsize_binary_search_respects_width_and_unicode_boundaries() { + let engine = &UiTextEngine::default(); let ctx = test_context(); let text = "Capture 🖌️ annotation history safely"; - let max_width = text_width(&ctx, "Capture 🖌️…"); + let max_width = text_width(engine, &ctx, "Capture 🖌️…"); let result = ellipsize_to_width( + engine, &ctx, text, COMMAND_PALETTE_FONT_FAMILY, @@ -751,7 +682,11 @@ mod tests { ); assert!(result.ends_with(ELLIPSIS)); - assert!(text_width(&ctx, &result) <= max_width); + assert!(text_width(engine, &ctx, &result) <= max_width); assert!(result.is_char_boundary(result.len())); } } + +#[cfg(test)] +#[path = "command_palette/tests/engine.rs"] +mod engine_tests; diff --git a/src/ui/command_palette/command_palette_row.rs b/src/ui/command_palette/command_palette_row.rs index 077e4076d..07bd6252d 100644 --- a/src/ui/command_palette/command_palette_row.rs +++ b/src/ui/command_palette/command_palette_row.rs @@ -7,11 +7,11 @@ use crate::input::state::{ COMMAND_PALETTE_ROW_ACTION_COUNT, COMMAND_PALETTE_ROW_ACTION_GAP, COMMAND_PALETTE_ROW_ACTION_SIZE, COMMAND_PALETTE_ROW_ICON_GAP, COMMAND_PALETTE_ROW_ICON_SIZE, }; -use crate::ui::text_highlight::{HighlightStyle, draw_highlight, find_match_range}; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui::text_highlight::{HighlightStyle, draw_highlight_with_engine, find_match_range}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::super::constants::{self, BG_INPUT_SELECTION, RADIUS_SM, TEXT_DESCRIPTION, TEXT_WHITE}; -use super::super::primitives::{draw_rounded_rect, text_extents_for}; +use super::super::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use super::{ COMMAND_PALETTE_FONT_FAMILY, COMMAND_PALETTE_SHORTCUT_BADGE_GAP, COMMAND_PALETTE_SHORTCUT_BADGE_HEIGHT, COMMAND_PALETTE_SHORTCUT_BADGE_PADDING_X, @@ -47,6 +47,7 @@ pub(super) fn command_palette_row_styles() -> CommandPaletteRowStyle { #[allow(clippy::too_many_arguments)] pub(super) fn render_command_row( + engine: &UiTextEngine, ctx: &cairo::Context, input_state: &InputState, cmd: &ActionMeta, @@ -90,6 +91,7 @@ pub(super) fn render_command_row( // user sees why a command surfaced. Drawn before the text so the glyphs // sit on top; fuzzy-only (subsequence) matches draw nothing. draw_label_match_highlights( + engine, ctx, &input_state.command_palette.query, cmd.label, @@ -99,9 +101,10 @@ pub(super) fn render_command_row( ); constants::set_color(ctx, constants::with_alpha(TEXT_WHITE, text_alpha)); - render_command_row_label(ctx, cmd.label, label_x, label_y, styles); + render_command_row_label(engine, ctx, cmd.label, label_x, label_y, styles); - let label_extents = text_extents_for( + let label_extents = text_extents_for_with_engine( + engine, ctx, COMMAND_PALETTE_FONT_FAMILY, cairo::FontSlant::Normal, @@ -123,6 +126,7 @@ pub(super) fn render_command_row( let shortcut_labels = input_state.action_binding_labels(cmd.action); let badge_left_edge = render_command_row_shortcut_badge( + engine, ctx, item_y, content_right, @@ -135,6 +139,7 @@ pub(super) fn render_command_row( let max_desc_width = (badge_left_edge - 12.0 - desc_x).max(0.0); let desc_alpha = if is_selected { 0.9 } else { 0.75 }; render_command_row_description( + engine, ctx, &styles.desc, cmd.description, @@ -180,19 +185,21 @@ fn render_command_row_actions( } fn render_command_row_label( + engine: &UiTextEngine, ctx: &cairo::Context, label: &str, x: f64, y: f64, styles: &CommandPaletteRowStyle, ) { - draw_text_baseline(ctx, styles.label, label, x, y, None); + engine.draw_baseline(ctx, styles.label, label, x, y, None); } /// Draw the accent match backdrop for each query token that appears in the /// label as a literal (case-insensitive) substring. The label text itself is /// drawn by the caller afterwards, over these boxes. fn draw_label_match_highlights( + engine: &UiTextEngine, ctx: &cairo::Context, query: &str, label: &str, @@ -214,12 +221,14 @@ fn draw_label_match_highlights( }; for token in query_tokens(&query_lower) { if let Some(range) = find_match_range(label, token) { - draw_highlight(ctx, label_x, label_y, label, range, &style); + draw_highlight_with_engine(engine, ctx, label_x, label_y, label, range, &style); } } } +#[allow(clippy::too_many_arguments)] pub(super) fn render_command_row_shortcut_badge( + engine: &UiTextEngine, ctx: &cairo::Context, item_y: f64, content_right: f64, @@ -239,6 +248,7 @@ pub(super) fn render_command_row_shortcut_badge( if max_badge_w > COMMAND_PALETTE_SHORTCUT_BADGE_PADDING_X * 2.0 { let max_shortcut_text_w = max_badge_w - COMMAND_PALETTE_SHORTCUT_BADGE_PADDING_X * 2.0; let shortcut_display = ellipsize_to_width( + engine, ctx, shortcut, COMMAND_PALETTE_FONT_FAMILY, @@ -248,7 +258,8 @@ pub(super) fn render_command_row_shortcut_badge( max_shortcut_text_w, ); if !shortcut_display.is_empty() { - let shortcut_extents = text_extents_for( + let shortcut_extents = text_extents_for_with_engine( + engine, ctx, COMMAND_PALETTE_FONT_FAMILY, shortcut_style.slant, @@ -281,7 +292,7 @@ pub(super) fn render_command_row_shortcut_badge( let shortcut_alpha = if is_selected { 0.95 } else { 0.8 }; constants::set_color(ctx, constants::with_alpha(TEXT_WHITE, shortcut_alpha)); - draw_text_baseline( + engine.draw_baseline( ctx, *shortcut_style, &shortcut_display, @@ -295,7 +306,9 @@ pub(super) fn render_command_row_shortcut_badge( badge_left_edge } +#[allow(clippy::too_many_arguments)] pub(super) fn render_command_row_description( + engine: &UiTextEngine, ctx: &cairo::Context, desc_style: &UiTextStyle, description: &str, @@ -307,6 +320,7 @@ pub(super) fn render_command_row_description( constants::set_color(ctx, constants::with_alpha(TEXT_DESCRIPTION, desc_alpha)); if max_desc_width > 6.0 { let desc_display = ellipsize_to_width( + engine, ctx, description, COMMAND_PALETTE_FONT_FAMILY, @@ -316,7 +330,7 @@ pub(super) fn render_command_row_description( max_desc_width, ); if !desc_display.is_empty() { - draw_text_baseline(ctx, *desc_style, &desc_display, desc_x, label_y, None); + engine.draw_baseline(ctx, *desc_style, &desc_display, desc_x, label_y, None); } } } diff --git a/src/ui/command_palette/modals.rs b/src/ui/command_palette/modals.rs new file mode 100644 index 000000000..684648663 --- /dev/null +++ b/src/ui/command_palette/modals.rs @@ -0,0 +1,152 @@ +//! Palette shortcut-capture modal and action tooltips. + +use super::*; + +pub(super) fn command_palette_action_tooltip_geometry( + engine: &UiTextEngine, + text: &str, + pointer_x: f64, + pointer_y: f64, + screen_width: f64, + screen_height: f64, +) -> Option<(f64, f64, f64, f64)> { + let style = command_palette_text_style( + COMMAND_PALETTE_SHORTCUT_TEXT_SIZE, + cairo::FontWeight::Normal, + cairo::FontSlant::Normal, + ); + let extents = engine.measure(style, text, None)?; + let width = extents.width() + TOOLTIP_PADDING_X * 2.0; + let height = style.size + TOOLTIP_PADDING_Y * 2.0; + let x = (pointer_x + TOOLTIP_POINTER_OFFSET) + .min((screen_width - width - FRAME_SHADOW_OFFSET).max(FRAME_SHADOW_OFFSET)); + let y = (pointer_y + TOOLTIP_POINTER_OFFSET) + .min((screen_height - height - FRAME_SHADOW_OFFSET).max(FRAME_SHADOW_OFFSET)); + Some((x, y, width, height)) +} + +pub(super) fn draw_command_palette_action_tooltip( + engine: &UiTextEngine, + ctx: &cairo::Context, + text: &str, + pointer_x: f64, + pointer_y: f64, + screen_width: f64, + screen_height: f64, +) { + let style = command_palette_text_style( + COMMAND_PALETTE_SHORTCUT_TEXT_SIZE, + cairo::FontWeight::Normal, + cairo::FontSlant::Normal, + ); + let Some((x, y, width, height)) = command_palette_action_tooltip_geometry( + engine, + text, + pointer_x, + pointer_y, + screen_width, + screen_height, + ) else { + return; + }; + + constants::set_color(ctx, TOOLTIP_BG); + draw_rounded_rect(ctx, x, y, width, height, 5.0); + let _ = ctx.fill(); + constants::set_color(ctx, TEXT_WHITE); + engine.draw_baseline( + ctx, + style, + text, + x + TOOLTIP_PADDING_X, + y + TOOLTIP_PADDING_Y + style.size, + None, + ); +} + +/// Frame of the shortcut-capture modal, shared by rendering and damage. +pub(super) fn keybinding_capture_geometry( + screen_width: u32, + screen_height: u32, +) -> (f64, f64, f64, f64) { + let width = 520.0_f64.min(screen_width as f64 - 24.0); + let height = 170.0; + let x = (screen_width as f64 - width) / 2.0; + let y = screen_height as f64 * COMMAND_PALETTE_TOP_RATIO; + (x, y, width, height) +} + +pub(super) fn render_keybinding_capture( + engine: &UiTextEngine, + ctx: &cairo::Context, + input_state: &InputState, + action: crate::config::Action, + screen_width: u32, + screen_height: u32, +) { + let (x, y, width, height) = keybinding_capture_geometry(screen_width, screen_height); + draw_command_palette_frame( + ctx, + screen_width as f64, + screen_height as f64, + x, + y, + width, + height, + ); + + let title_style = + command_palette_text_style(18.0, cairo::FontWeight::Bold, cairo::FontSlant::Normal); + let body_style = + command_palette_text_style(13.0, cairo::FontWeight::Normal, cairo::FontSlant::Normal); + constants::set_color(ctx, TEXT_WHITE); + engine.draw_baseline( + ctx, + title_style, + &format!("Rebind {}", action_label(action)), + x + 22.0, + y + 38.0, + None, + ); + let current = input_state.action_binding_labels(action); + constants::set_color(ctx, TEXT_DESCRIPTION); + engine.draw_baseline( + ctx, + body_style, + &format!( + "Current: {}", + if current.is_empty() { + "Not bound".to_string() + } else { + current.join(", ") + } + ), + x + 22.0, + y + 70.0, + None, + ); + constants::set_color(ctx, TEXT_WHITE); + engine.draw_baseline( + ctx, + body_style, + "Press the new shortcut now", + x + 22.0, + y + 108.0, + None, + ); + constants::set_color(ctx, TEXT_DESCRIPTION); + engine.draw_baseline( + ctx, + body_style, + KEYBINDING_CAPTURE_SCOPE_NOTE, + x + 22.0, + y + 140.0, + None, + ); +} + +/// Says what a captured chord costs and what refuses it, at the interaction +/// point. The edit is durable, so the line names the two things that are not +/// obvious: backing out, and what happens to a chord that is already taken. +const KEYBINDING_CAPTURE_SCOPE_NOTE: &str = + "Escape cancels • a shortcut already in use is rejected"; diff --git a/src/ui/command_palette/tests/engine.rs b/src/ui/command_palette/tests/engine.rs new file mode 100644 index 000000000..646490b79 --- /dev/null +++ b/src/ui/command_palette/tests/engine.rs @@ -0,0 +1,115 @@ +use super::*; + +fn pixels(density: i32, paint: impl FnOnce(&cairo::Context)) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 800 * density, 600 * density).unwrap(); + surface.set_device_scale(density as f64, density as f64); + let ctx = cairo::Context::new(&surface).unwrap(); + paint(&ctx); + drop(ctx); + surface.data().unwrap().to_vec() +} + +#[test] +fn retained_palette_engine_matches_public_paint_and_geometry_across_targets() { + let engine = UiTextEngine::default(); + let mut input = crate::input::state::test_support::make_test_input_state(); + input.toggle_command_palette(); + for query in ["", "capture", "No match 測試 🖌️"] { + input.command_palette.query = query.into(); + for density in [1, 2, 1] { + let bounds = + command_palette_visual_geometry_with_engine(&engine, &input, 800, 600).unwrap(); + assert_eq!( + Some(bounds), + command_palette_visual_geometry(&input, 800, 600) + ); + assert!(bounds.2 > 0.0 && bounds.2 < 800.0); + let actual = pixels(density, |ctx| { + render_command_palette_with_engine(&engine, ctx, &input, 800, 600) + }); + let expected = pixels(density, |ctx| render_command_palette(ctx, &input, 800, 600)); + assert!( + actual == expected, + "palette query {query:?}, density {density}" + ); + assert_eq!( + Some(bounds), + command_palette_visual_geometry_with_engine(&engine, &input, 800, 600) + ); + } + } +} + +#[test] +fn retained_palette_tooltip_geometry_contains_pixels_and_unicode_highlight_is_visible() { + use crate::ui::text_highlight::{HighlightStyle, draw_highlight_with_engine, find_match_range}; + let engine = UiTextEngine::default(); + let text = "Edit 測試 🖌️ binding"; + for density in [1, 2, 1] { + let bounds = + command_palette_action_tooltip_geometry(&engine, text, 740.0, 560.0, 800.0, 600.0) + .unwrap(); + let actual = pixels(density, |ctx| { + draw_command_palette_action_tooltip(&engine, ctx, text, 740.0, 560.0, 800.0, 600.0) + }); + let expected = pixels(density, |ctx| { + draw_command_palette_action_tooltip( + &UiTextEngine::default(), + ctx, + text, + 740.0, + 560.0, + 800.0, + 600.0, + ) + }); + assert!(actual == expected, "tooltip density {density}"); + let mut painted = 0; + for (index, pixel) in actual.as_chunks::<4>().0.iter().enumerate() { + if pixel.iter().any(|byte| *byte != 0) { + painted += 1; + let x = (index % (800 * density) as usize) as f64 / density as f64; + let y = (index / (800 * density) as usize) as f64 / density as f64; + assert!(x >= bounds.0 - 2.0 && x <= bounds.0 + bounds.2 + 2.0); + assert!(y >= bounds.1 - 2.0 && y <= bounds.1 + bounds.3 + 2.0); + } + } + assert!(painted > 0); + let style = HighlightStyle { + font_family: "Sans", + font_size: 14.0, + font_weight: cairo::FontWeight::Normal, + color: [1.0, 0.0, 0.0, 1.0], + }; + let range = find_match_range(text, "測試").unwrap(); + let highlight = pixels(density, |ctx| { + draw_highlight_with_engine(&engine, ctx, 30.0, 40.0, text, range, &style) + }); + let fresh = pixels(density, |ctx| { + draw_highlight_with_engine( + &UiTextEngine::default(), + ctx, + 30.0, + 40.0, + text, + range, + &style, + ) + }); + assert!(highlight == fresh, "highlight density {density}"); + assert!(highlight.iter().any(|byte| *byte != 0)); + let invalid = pixels(density, |ctx| { + draw_highlight_with_engine( + &engine, + ctx, + 30.0, + 40.0, + text, + (range.0 + 1, range.1), + &style, + ) + }); + assert!(invalid.iter().all(|byte| *byte == 0)); + } +} diff --git a/src/ui/primitives.rs b/src/ui/primitives.rs index e0735996b..efbb7ed68 100644 --- a/src/ui/primitives.rs +++ b/src/ui/primitives.rs @@ -45,19 +45,6 @@ pub(crate) const ELLIPSIS: &str = "\u{2026}"; /// Trim `text` to `max_width` logical pixels, appending an ellipsis. The /// complete string is measured as it will be shaped, so wide glyphs and /// non-Latin scripts cannot slip past a per-character budget. -pub(crate) fn ellipsize_to_fit( - ctx: &cairo::Context, - text: &str, - font_family: &str, - font_size: f64, - weight: cairo::FontWeight, - max_width: f64, -) -> String { - with_legacy_engine(|engine| { - ellipsize_to_fit_with_engine(engine, ctx, text, font_family, font_size, weight, max_width) - }) -} - pub(crate) fn ellipsize_to_fit_with_engine( engine: &UiTextEngine, ctx: &cairo::Context, diff --git a/src/ui/text_highlight.rs b/src/ui/text_highlight.rs index 74dabf5d8..2ad5d0849 100644 --- a/src/ui/text_highlight.rs +++ b/src/ui/text_highlight.rs @@ -5,7 +5,7 @@ //! want. use super::primitives::text_extents_for_with_engine; -use crate::ui_text::{UiTextEngine, with_legacy_engine}; +use crate::ui_text::UiTextEngine; /// Case-insensitive substring range (byte offsets) of `needle_lower` inside /// `haystack`, or `None` when it does not appear literally. `needle_lower` @@ -30,19 +30,6 @@ pub(crate) struct HighlightStyle<'a> { /// Fill a padded rectangle behind the glyphs of `text[range]`, positioned /// from the text's left edge `x` and `baseline`. The caller draws the text /// itself over the top afterwards. -pub(crate) fn draw_highlight( - ctx: &cairo::Context, - x: f64, - baseline: f64, - text: &str, - range: (usize, usize), - style: &HighlightStyle<'_>, -) { - with_legacy_engine(|engine| { - draw_highlight_with_engine(engine, ctx, x, baseline, text, range, style) - }); -} - pub(crate) fn draw_highlight_with_engine( engine: &UiTextEngine, ctx: &cairo::Context, From d4a59bf5bdb414743efb4b0053aea0507b16c637 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:30:47 +0200 Subject: [PATCH 20/42] refactor(text): share input HUD geometry and painting resources --- src/backend/wayland/state/render/ui.rs | 3 +- .../wayland/state/render/ui_effect_damage.rs | 9 +- src/ui.rs | 1 + src/ui/input_hud.rs | 243 +++++------------ src/ui/input_hud/tests.rs | 255 ++++++++++++++++++ src/ui/primitives.rs | 13 +- 6 files changed, 334 insertions(+), 190 deletions(-) create mode 100644 src/ui/input_hud/tests.rs diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index a931d1958..bb2fb3be6 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -187,7 +187,8 @@ impl WaylandState { ); } if !capture_picker && self.input_state.input_hud_visible() { - crate::ui::render_input_hud( + crate::ui::render_input_hud_with_engine( + self.render.ui_text(), ctx, &self.input_state, &self.config.ui.status_bar_style, diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index 7b1401971..de4fe0f93 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -201,8 +201,13 @@ impl WaylandState { // the same appear → resize → disappear union keeps the stale chips // cleaned up without escalating a keystroke to the full surface. let input_hud_rect = if flags.active(UiEffect::InputHud) { - crate::ui::input_hud_geometry(&self.input_state, width, height) - .and_then(|bounds| effect_rect(bounds, width, height)) + crate::ui::input_hud_geometry_with_engine( + self.render.ui_text(), + &self.input_state, + width, + height, + ) + .and_then(|bounds| effect_rect(bounds, width, height)) } else { None }; diff --git a/src/ui.rs b/src/ui.rs index b92c3b745..4d10bb010 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -55,6 +55,7 @@ pub use help_overlay::{ render_help_overlay_result, }; pub use input_hud::{input_hud_geometry, render_input_hud}; +pub(crate) use input_hud::{input_hud_geometry_with_engine, render_input_hud_with_engine}; pub(crate) use measure_badge::{ ShapeMeasureBadge, measure_shape_badge, shape_measure_badge_text_style, }; diff --git a/src/ui/input_hud.rs b/src/ui/input_hud.rs index fcf6a8a8a..1d46c99b6 100644 --- a/src/ui/input_hud.rs +++ b/src/ui/input_hud.rs @@ -13,7 +13,7 @@ use super::primitives::{ draw_keycap_in_box, draw_rounded_rect, keycap_box_size, keycap_text_style, }; use super::theme::{self, overlay}; -use crate::ui_text::text_layout; +use crate::ui_text::UiTextEngine; /// Inset between the chip row and the screen edges, matching the other /// corner-anchored chrome (status HUD, zoom chip). @@ -58,7 +58,8 @@ fn chip_text(label: &str, count: u32) -> String { /// Lay the chip row out for the current screen size, or `None` when nothing /// would be drawn. Chips are measured newest-first so an overlong row drops /// its oldest chips rather than running off screen. -pub(crate) fn compute_input_hud_layout( +fn compute_input_hud_layout( + engine: &UiTextEngine, input_state: &InputState, screen_width: u32, screen_height: u32, @@ -81,7 +82,7 @@ pub(crate) fn compute_input_hud_layout( for entry in input_state.input_hud_entries().rev() { let text = chip_text(entry.label(), entry.count()); - let Some((width, height)) = keycap_box_size(&text, font_size) else { + let Some((width, height)) = keycap_box_size(engine, &text, font_size) else { continue; }; // The newest chip always renders, but never wider than the available @@ -168,7 +169,21 @@ pub fn input_hud_geometry( screen_width: u32, screen_height: u32, ) -> Option<(f64, f64, f64, f64)> { - let layout = compute_input_hud_layout(input_state, screen_width, screen_height)?; + input_hud_geometry_with_engine( + &UiTextEngine::default(), + input_state, + screen_width, + screen_height, + ) +} + +pub(crate) fn input_hud_geometry_with_engine( + engine: &UiTextEngine, + input_state: &InputState, + screen_width: u32, + screen_height: u32, +) -> Option<(f64, f64, f64, f64)> { + let layout = compute_input_hud_layout(engine, input_state, screen_width, screen_height)?; Some((layout.x, layout.y, layout.width, layout.height)) } @@ -181,10 +196,44 @@ pub fn render_input_hud( screen_width: u32, screen_height: u32, ) { - let Some(layout) = compute_input_hud_layout(input_state, screen_width, screen_height) else { + render_input_hud_with_engine( + &UiTextEngine::default(), + ctx, + input_state, + style, + screen_width, + screen_height, + ); +} + +pub(crate) fn render_input_hud_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + input_state: &InputState, + style: &StatusBarStyle, + screen_width: u32, + screen_height: u32, +) { + let Some(layout) = compute_input_hud_layout(engine, input_state, screen_width, screen_height) + else { return; }; - let font_size = input_state.input_hud_font_size(); + paint_input_hud_layout( + engine, + ctx, + style, + input_state.input_hud_font_size(), + &layout, + ); +} + +fn paint_input_hud_layout( + engine: &UiTextEngine, + ctx: &cairo::Context, + style: &StatusBarStyle, + font_size: f64, + layout: &InputHudLayout, +) { let [br, bg, bb, ba] = style.bg_color; let [tr, tg, tb, ta] = style.text_color; @@ -204,6 +253,7 @@ pub fn render_input_hud( match chip.kind { InputHudEntryKind::Key => { draw_keycap_in_box( + engine, ctx, chip.x, layout.y, @@ -217,6 +267,7 @@ pub fn render_input_hud( } InputHudEntryKind::Mouse | InputHudEntryKind::Scroll => { draw_input_hud_pill( + engine, ctx, chip.x, layout.y, @@ -238,6 +289,7 @@ pub fn render_input_hud( /// keystrokes at a glance. #[allow(clippy::too_many_arguments)] fn draw_input_hud_pill( + engine: &UiTextEngine, ctx: &cairo::Context, x: f64, y: f64, @@ -252,7 +304,7 @@ fn draw_input_hud_pill( draw_rounded_rect(ctx, x, y, width, height, INPUT_HUD_PILL_RADIUS); let _ = ctx.fill(); - let layout = text_layout(ctx, keycap_text_style(font_size), text, None); + let layout = engine.layout(ctx, keycap_text_style(font_size), text, None); let extents = layout.ink_extents(); theme::set_color(ctx, text_color); layout.show_at_baseline( @@ -263,179 +315,4 @@ fn draw_input_hud_pill( } #[cfg(test)] -mod tests { - use super::*; - use crate::config::InputHudConfig; - use crate::input::state::{InputHudSettings, test_support::make_test_input_state}; - use crate::input::{Key, Modifiers}; - - fn state_with(config: InputHudConfig) -> InputState { - let mut state = make_test_input_state(); - state.init_input_hud_from_config(InputHudSettings::from(&config)); - state - } - - fn enabled_state() -> InputState { - state_with(InputHudConfig { - enabled: true, - ..InputHudConfig::default() - }) - } - - #[test] - fn hidden_hud_has_no_geometry() { - let state = state_with(InputHudConfig::default()); - assert!(input_hud_geometry(&state, 1920, 1080).is_none()); - - let empty = enabled_state(); - assert!( - input_hud_geometry(&empty, 1920, 1080).is_none(), - "an enabled but empty HUD draws nothing" - ); - } - - #[test] - fn bottom_center_row_is_centered_and_bottom_anchored() { - let mut state = enabled_state(); - state.note_input_hud_key(Key::Char('a'), Modifiers::new()); - let (x, y, width, height) = input_hud_geometry(&state, 1920, 1080).expect("row geometry"); - - assert!((x + width / 2.0 - 960.0).abs() < 1e-6, "row is centered"); - assert!( - (y + height - (1080.0 - INPUT_HUD_EDGE_INSET)).abs() < 1e-6, - "row sits one inset above the bottom edge" - ); - } - - #[test] - fn anchors_place_the_row_on_the_requested_edges() { - for position in [ - InputHudPosition::TopLeft, - InputHudPosition::TopCenter, - InputHudPosition::TopRight, - InputHudPosition::CenterLeft, - InputHudPosition::Center, - InputHudPosition::CenterRight, - InputHudPosition::BottomLeft, - InputHudPosition::BottomRight, - ] { - let mut state = state_with(InputHudConfig { - enabled: true, - position, - ..InputHudConfig::default() - }); - state.note_input_hud_key(Key::Char('a'), Modifiers::new()); - let (x, y, width, height) = - input_hud_geometry(&state, 1920, 1080).expect("row geometry"); - - if position.is_right() { - assert!((x + width - (1920.0 - INPUT_HUD_EDGE_INSET)).abs() < 1e-6); - } else if position.is_center() { - assert!((x + width / 2.0 - 960.0).abs() < 1e-6, "row is centered"); - } else { - assert!((x - INPUT_HUD_EDGE_INSET).abs() < 1e-6); - } - if position.is_top() { - assert!((y - INPUT_HUD_EDGE_INSET).abs() < 1e-6); - } else if position.is_middle() { - assert!( - (y + height / 2.0 - 540.0).abs() < 1e-6, - "middle anchors sit on the vertical center line" - ); - } else { - assert!(y > 540.0, "bottom anchors stay in the lower half"); - } - } - } - - /// A valid-but-large font on a narrow output can make a single chip wider - /// than the inset span. The newest chip must clamp to it (rendering clips - /// the label at the box edge) so the row never leaves the surface. - #[test] - fn the_newest_chip_clamps_to_the_available_width() { - let mut state = state_with(InputHudConfig { - enabled: true, - font_size: 72.0, - ..InputHudConfig::default() - }); - let mut modifiers = Modifiers::new(); - modifiers.ctrl = true; - modifiers.shift = true; - state.note_input_hud_key(Key::Backspace, modifiers); - - let screen_width = 160_u32; - let available = screen_width as f64 - INPUT_HUD_EDGE_INSET * 2.0; - let layout = compute_input_hud_layout(&state, screen_width, 1080).expect("layout"); - assert_eq!(layout.chips.len(), 1); - assert!( - layout.width <= available, - "row width {} must not exceed the available span {available}", - layout.width - ); - assert!(layout.x >= 0.0); - assert!(layout.x + layout.width <= screen_width as f64); - - // A surface no wider than its insets has no drawable span at all. - let no_span = (INPUT_HUD_EDGE_INSET * 2.0) as u32; - assert!(compute_input_hud_layout(&state, no_span, 1080).is_none()); - } - - #[test] - fn repeat_counter_is_appended_to_the_chip_text() { - let mut state = enabled_state(); - for _ in 0..7 { - state.note_input_hud_key(Key::Backspace, Modifiers::new()); - } - let layout = compute_input_hud_layout(&state, 1920, 1080).expect("layout"); - assert_eq!(layout.chips.len(), 1); - assert_eq!(layout.chips[0].text, "Backspace \u{00d7}7"); - } - - #[test] - fn mouse_chips_keep_the_pill_chrome() { - let mut state = enabled_state(); - state.note_input_hud_mouse("Click", Modifiers::new()); - state.note_input_hud_scroll(true, Modifiers::new()); - let layout = compute_input_hud_layout(&state, 1920, 1080).expect("layout"); - assert_eq!(layout.chips.len(), 2); - assert_eq!(layout.chips[0].kind, InputHudEntryKind::Mouse); - assert_eq!(layout.chips[1].kind, InputHudEntryKind::Scroll); - } - - /// The row never runs off screen: a narrow surface keeps only the newest - /// chips that fit inside the inset-reduced width. - #[test] - fn overlong_rows_drop_their_oldest_chips() { - let mut state = enabled_state(); - for label in ['a', 'b', 'c', 'd', 'e', 'f'] { - state.note_input_hud_key(Key::Char(label), Modifiers::new()); - } - let layout = compute_input_hud_layout(&state, 120, 1080).expect("layout"); - - assert!(layout.chips.len() < 6, "narrow screens shed older chips"); - assert!(layout.x >= 0.0); - assert!(layout.x + layout.width <= 120.0 + 1e-6); - assert_eq!( - layout.chips.last().map(|chip| chip.text.as_str()), - Some("F"), - "the newest chip always survives" - ); - } - - /// Chips share one row height so labels with different ascenders and - /// descenders still align. - #[test] - fn chips_share_a_single_row_height() { - let mut state = enabled_state(); - state.note_input_hud_key(Key::Backspace, Modifiers::new()); - state.note_input_hud_key(Key::Escape, Modifiers::new()); - let layout = compute_input_hud_layout(&state, 1920, 1080).expect("layout"); - - assert_eq!(layout.chips.len(), 2); - for chip in &layout.chips { - let (_, natural) = - keycap_box_size(&chip.text, state.input_hud_font_size()).expect("chip measurement"); - assert!(natural <= layout.height + 1e-6); - } - } -} +mod tests; diff --git a/src/ui/input_hud/tests.rs b/src/ui/input_hud/tests.rs new file mode 100644 index 000000000..b8b6a4e8b --- /dev/null +++ b/src/ui/input_hud/tests.rs @@ -0,0 +1,255 @@ +use super::*; +use crate::config::InputHudConfig; +use crate::input::state::{InputHudSettings, test_support::make_test_input_state}; +use crate::input::{Key, Modifiers}; + +fn state_with(config: InputHudConfig) -> InputState { + let mut state = make_test_input_state(); + state.init_input_hud_from_config(InputHudSettings::from(&config)); + state +} + +fn enabled_state() -> InputState { + state_with(InputHudConfig { + enabled: true, + ..InputHudConfig::default() + }) +} + +#[test] +fn hidden_hud_has_no_geometry() { + let state = state_with(InputHudConfig::default()); + assert!(input_hud_geometry(&state, 1920, 1080).is_none()); + + let empty = enabled_state(); + assert!( + input_hud_geometry(&empty, 1920, 1080).is_none(), + "an enabled but empty HUD draws nothing" + ); +} + +#[test] +fn bottom_center_row_is_centered_and_bottom_anchored() { + let mut state = enabled_state(); + state.note_input_hud_key(Key::Char('a'), Modifiers::new()); + let (x, y, width, height) = input_hud_geometry(&state, 1920, 1080).expect("row geometry"); + + assert!((x + width / 2.0 - 960.0).abs() < 1e-6, "row is centered"); + assert!( + (y + height - (1080.0 - INPUT_HUD_EDGE_INSET)).abs() < 1e-6, + "row sits one inset above the bottom edge" + ); +} + +#[test] +fn anchors_place_the_row_on_the_requested_edges() { + for position in [ + InputHudPosition::TopLeft, + InputHudPosition::TopCenter, + InputHudPosition::TopRight, + InputHudPosition::CenterLeft, + InputHudPosition::Center, + InputHudPosition::CenterRight, + InputHudPosition::BottomLeft, + InputHudPosition::BottomRight, + ] { + let mut state = state_with(InputHudConfig { + enabled: true, + position, + ..InputHudConfig::default() + }); + state.note_input_hud_key(Key::Char('a'), Modifiers::new()); + let (x, y, width, height) = input_hud_geometry(&state, 1920, 1080).expect("row geometry"); + + if position.is_right() { + assert!((x + width - (1920.0 - INPUT_HUD_EDGE_INSET)).abs() < 1e-6); + } else if position.is_center() { + assert!((x + width / 2.0 - 960.0).abs() < 1e-6, "row is centered"); + } else { + assert!((x - INPUT_HUD_EDGE_INSET).abs() < 1e-6); + } + if position.is_top() { + assert!((y - INPUT_HUD_EDGE_INSET).abs() < 1e-6); + } else if position.is_middle() { + assert!( + (y + height / 2.0 - 540.0).abs() < 1e-6, + "middle anchors sit on the vertical center line" + ); + } else { + assert!(y > 540.0, "bottom anchors stay in the lower half"); + } + } +} + +/// A valid-but-large font on a narrow output can make a single chip wider +/// than the inset span. The newest chip must clamp to it (rendering clips +/// the label at the box edge) so the row never leaves the surface. +#[test] +fn the_newest_chip_clamps_to_the_available_width() { + let mut state = state_with(InputHudConfig { + enabled: true, + font_size: 72.0, + ..InputHudConfig::default() + }); + let mut modifiers = Modifiers::new(); + modifiers.ctrl = true; + modifiers.shift = true; + state.note_input_hud_key(Key::Backspace, modifiers); + + let screen_width = 160_u32; + let available = screen_width as f64 - INPUT_HUD_EDGE_INSET * 2.0; + let layout = compute_input_hud_layout(&UiTextEngine::default(), &state, screen_width, 1080) + .expect("layout"); + assert_eq!(layout.chips.len(), 1); + assert!( + layout.width <= available, + "row width {} must not exceed the available span {available}", + layout.width + ); + assert!(layout.x >= 0.0); + assert!(layout.x + layout.width <= screen_width as f64); + + // A surface no wider than its insets has no drawable span at all. + let no_span = (INPUT_HUD_EDGE_INSET * 2.0) as u32; + assert!(compute_input_hud_layout(&UiTextEngine::default(), &state, no_span, 1080).is_none()); +} + +#[test] +fn repeat_counter_is_appended_to_the_chip_text() { + let mut state = enabled_state(); + for _ in 0..7 { + state.note_input_hud_key(Key::Backspace, Modifiers::new()); + } + let layout = + compute_input_hud_layout(&UiTextEngine::default(), &state, 1920, 1080).expect("layout"); + assert_eq!(layout.chips.len(), 1); + assert_eq!(layout.chips[0].text, "Backspace \u{00d7}7"); +} + +#[test] +fn mouse_chips_keep_the_pill_chrome() { + let mut state = enabled_state(); + state.note_input_hud_mouse("Click", Modifiers::new()); + state.note_input_hud_scroll(true, Modifiers::new()); + let layout = + compute_input_hud_layout(&UiTextEngine::default(), &state, 1920, 1080).expect("layout"); + assert_eq!(layout.chips.len(), 2); + assert_eq!(layout.chips[0].kind, InputHudEntryKind::Mouse); + assert_eq!(layout.chips[1].kind, InputHudEntryKind::Scroll); +} + +/// The row never runs off screen: a narrow surface keeps only the newest +/// chips that fit inside the inset-reduced width. +#[test] +fn overlong_rows_drop_their_oldest_chips() { + let mut state = enabled_state(); + for label in ['a', 'b', 'c', 'd', 'e', 'f'] { + state.note_input_hud_key(Key::Char(label), Modifiers::new()); + } + let layout = + compute_input_hud_layout(&UiTextEngine::default(), &state, 120, 1080).expect("layout"); + + assert!(layout.chips.len() < 6, "narrow screens shed older chips"); + assert!(layout.x >= 0.0); + assert!(layout.x + layout.width <= 120.0 + 1e-6); + assert_eq!( + layout.chips.last().map(|chip| chip.text.as_str()), + Some("F"), + "the newest chip always survives" + ); +} + +/// Chips share one row height so labels with different ascenders and +/// descenders still align. +#[test] +fn chips_share_a_single_row_height() { + let mut state = enabled_state(); + state.note_input_hud_key(Key::Backspace, Modifiers::new()); + state.note_input_hud_key(Key::Escape, Modifiers::new()); + let layout = + compute_input_hud_layout(&UiTextEngine::default(), &state, 1920, 1080).expect("layout"); + + assert_eq!(layout.chips.len(), 2); + for chip in &layout.chips { + let (_, natural) = keycap_box_size( + &UiTextEngine::default(), + &chip.text, + state.input_hud_font_size(), + ) + .expect("chip measurement"); + assert!(natural <= layout.height + 1e-6); + } +} + +fn paint(engine: &UiTextEngine, layout: &InputHudLayout, density: i32) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 360 * density, 180 * density).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + paint_input_hud_layout(engine, &ctx, &StatusBarStyle::default(), 18.0, layout); + } + surface.data().unwrap().to_vec() +} + +#[test] +fn retained_owner_matches_fresh_geometry_and_faded_clipped_pixels_across_densities() { + let engine = UiTextEngine::default(); + let mut state = state_with(InputHudConfig { + enabled: true, + font_size: 18.0, + ..InputHudConfig::default() + }); + state.note_input_hud_key(Key::Backspace, Modifiers::new()); + state.note_input_hud_mouse("Click", Modifiers::new()); + state.note_input_hud_scroll(true, Modifiers::new()); + for (width, density) in [(360, 1), (80, 2), (360, 1)] { + let fresh = UiTextEngine::default(); + assert_eq!( + input_hud_geometry_with_engine(&engine, &state, width, 180), + input_hud_geometry_with_engine(&fresh, &state, width, 180) + ); + let mut actual_layout = compute_input_hud_layout(&engine, &state, width, 180).unwrap(); + let mut fresh_layout = compute_input_hud_layout(&fresh, &state, width, 180).unwrap(); + assert_eq!(actual_layout.chips.len(), fresh_layout.chips.len()); + for (actual, expected) in actual_layout.chips.iter_mut().zip(&mut fresh_layout.chips) { + assert_eq!( + (&actual.text, actual.kind, actual.x, actual.width), + (&expected.text, expected.kind, expected.x, expected.width) + ); + // Resolve identical frame alpha: elapsed wall time is not part of owner parity. + actual.alpha = 0.4; + expected.alpha = 0.4; + } + let actual = paint(&engine, &actual_layout, density); + assert!(actual.iter().any(|&byte| byte != 0)); + assert!( + actual == paint(&fresh, &fresh_layout, density), + "retained HUD pixels differ" + ); + for chip in &mut fresh_layout.chips { + chip.alpha = 1.0; + } + assert!( + actual != paint(&fresh, &fresh_layout, density), + "resolved fade changes output" + ); + let stride = 360 * density as usize * 4; + for (index, _) in actual + .as_chunks::<4>() + .0 + .iter() + .enumerate() + .filter(|(_, p)| p[3] != 0) + { + let x = (index * 4 % stride) as f64 / (4.0 * f64::from(density)); + let y = (index * 4 / stride) as f64 / f64::from(density); + assert!( + x >= actual_layout.x.floor() && x < (actual_layout.x + actual_layout.width).ceil() + ); + assert!( + y >= actual_layout.y.floor() && y < (actual_layout.y + actual_layout.height).ceil() + ); + } + } +} diff --git a/src/ui/primitives.rs b/src/ui/primitives.rs index efbb7ed68..e0ad210d9 100644 --- a/src/ui/primitives.rs +++ b/src/ui/primitives.rs @@ -1,7 +1,7 @@ use std::f64::consts::{FRAC_PI_2, PI}; use crate::ui::theme::{self, Rgba}; -use crate::ui_text::{UiTextEngine, UiTextStyle, measure_text, text_layout, with_legacy_engine}; +use crate::ui_text::{UiTextEngine, UiTextStyle, with_legacy_engine}; pub(crate) fn text_extents_for( ctx: &cairo::Context, @@ -361,8 +361,12 @@ pub(crate) fn keycap_text_style(font_size: f64) -> UiTextStyle<'static> { /// [`keycap_size`] without a rendering context, for callers that lay out /// before a frame buffer exists (damage geometry). Goes through the shared /// measurement cache, so it agrees with the drawn chip exactly. -pub(crate) fn keycap_box_size(label: &str, font_size: f64) -> Option<(f64, f64)> { - let extents = measure_text(keycap_text_style(font_size), label, None)?; +pub(crate) fn keycap_box_size( + engine: &UiTextEngine, + label: &str, + font_size: f64, +) -> Option<(f64, f64)> { + let extents = engine.measure(keycap_text_style(font_size), label, None)?; Some(( extents.width() + font_size * KEYCAP_PAD_X_FACTOR * 2.0, extents.height() + font_size * KEYCAP_PAD_Y_FACTOR * 2.0, @@ -375,6 +379,7 @@ pub(crate) fn keycap_box_size(label: &str, font_size: f64) -> Option<(f64, f64)> /// shorthand over the same chrome. #[allow(clippy::too_many_arguments)] pub(crate) fn draw_keycap_in_box( + engine: &UiTextEngine, ctx: &cairo::Context, x: f64, y: f64, @@ -385,7 +390,7 @@ pub(crate) fn draw_keycap_in_box( fill: Rgba, text_color: Rgba, ) { - let layout = text_layout(ctx, keycap_text_style(font_size), label, None); + let layout = engine.layout(ctx, keycap_text_style(font_size), label, None); let extents = layout.ink_extents(); theme::set_color(ctx, fill); From b476f8ef17d00895e62ae4b4f04f1306c1e130d8 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:30:58 +0200 Subject: [PATCH 21/42] refactor(input): pass text resources through operation dependencies --- src/input/state/core/board/delete_restore.rs | 15 ++- .../state/core/board/delete_restore/page.rs | 55 +++++++-- .../state/core/board/delete_restore/tests.rs | 55 +++++---- src/input/state/core/board/lifecycle.rs | 5 +- src/input/state/core/board/pages.rs | 107 ++++++++++++++++-- src/input/state/core/board/switch.rs | 56 ++++++++- .../core/board_picker/state/lifecycle.rs | 29 ++++- .../state/core/color_picker_popup/state.rs | 28 ++++- src/input/state/core/dirty.rs | 4 - src/input/state/core/font_cycle.rs | 11 +- src/input/state/core/font_picker/mod.rs | 11 +- src/input/state/core/highlight_controls.rs | 15 ++- src/input/state/core/modal.rs | 7 +- src/input/state/core/session.rs | 8 +- src/input/state/core/text_font.rs | 13 +-- src/input/state/core/tool_controls/presets.rs | 28 +++-- .../state/core/tool_controls/settings.rs | 76 ++++++++++--- src/input/state/core/toolbar/apply/boards.rs | 46 ++++++-- src/input/state/core/toolbar/apply/pages.rs | 34 +++++- src/input/state/core/toolbar/apply/tools.rs | 107 +++++++++++++++--- src/input/state/core/tour.rs | 13 ++- src/input/state/core/utility/focus_mode.rs | 9 +- src/input/state/core/utility/interaction.rs | 10 +- src/input/state/core/utility/light_mode.rs | 82 ++++++++++---- .../state/core/utility/presenter_mode.rs | 29 ++--- src/input/state/mod.rs | 3 + src/input/state/tests/delete_restore.rs | 42 +++++-- .../tests/status_hud/engine_mutations.rs | 28 +++-- src/input/state/tests/text_input/editing.rs | 3 +- src/input/state/text_resources.rs | 30 +++++ src/input/state/text_resources/tests.rs | 91 +++++++++++++++ 31 files changed, 841 insertions(+), 209 deletions(-) create mode 100644 src/input/state/text_resources.rs create mode 100644 src/input/state/text_resources/tests.rs diff --git a/src/input/state/core/board/delete_restore.rs b/src/input/state/core/board/delete_restore.rs index ef70f1956..2a7bc0058 100644 --- a/src/input/state/core/board/delete_restore.rs +++ b/src/input/state/core/board/delete_restore.rs @@ -1,5 +1,6 @@ use super::super::base::{BOARD_DELETE_CONFIRM_MS, InputState}; use crate::domain::Action; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::boards::{ BoardDeleteOutcome, BoardDeleteRejection, BoardDeleteRequest, BoardDeleteTarget, BoardIdentityGeneration, BoardRestoreOutcome, BoardRestoreRejection, BoardRestoreRequest, @@ -111,10 +112,18 @@ impl InputState { } pub fn delete_active_board(&mut self) { - self.delete_active_board_at(Instant::now()); + with_legacy_measurer(|measurer| self.delete_active_board_with_measurer(measurer)) } - pub(crate) fn delete_active_board_at(&mut self, now: Instant) { + pub fn delete_active_board_with_measurer(&mut self, measurer: &TextMeasurer) { + self.delete_active_board_at_with_measurer(measurer, Instant::now()); + } + + pub(crate) fn delete_active_board_at_with_measurer( + &mut self, + measurer: &TextMeasurer, + now: Instant, + ) { let request = self .board_transitions .confirm_board_delete(now) @@ -145,7 +154,7 @@ impl InputState { _ => true, }; if deleting_active && matches!(request, BoardDeleteRequest::Confirm(_)) { - self.cancel_active_interaction(); + self.cancel_active_interaction_with(measurer); } match self.boards.delete_board(request) { diff --git a/src/input/state/core/board/delete_restore/page.rs b/src/input/state/core/board/delete_restore/page.rs index 88e473a59..9dc02d9ad 100644 --- a/src/input/state/core/board/delete_restore/page.rs +++ b/src/input/state/core/board/delete_restore/page.rs @@ -1,6 +1,7 @@ use super::super::super::base::{InputState, PAGE_DELETE_CONFIRM_MS}; use crate::domain::Action; use crate::draw::PageDeleteOutcome as CanvasPageDeleteOutcome; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::boards::{ PageDeleteBoardTarget, PageDeleteOutcome, PageDeleteRequest, PageDeleteTarget, PageOperationRejection, PageRestoreOutcome, PageRestorePlacement, PageRestoreRejection, @@ -15,11 +16,28 @@ impl InputState { board_index: usize, page_index: usize, ) -> CanvasPageDeleteOutcome { - self.delete_page_in_board_at(board_index, page_index, Instant::now()) + with_legacy_measurer(|measurer| { + self.delete_page_in_board_with_measurer(measurer, board_index, page_index) + }) } - pub(crate) fn delete_page_in_board_at( + pub(crate) fn delete_page_in_board_with_measurer( &mut self, + measurer: &TextMeasurer, + board_index: usize, + page_index: usize, + ) -> CanvasPageDeleteOutcome { + self.delete_page_in_board_at_with_measurer( + measurer, + board_index, + page_index, + Instant::now(), + ) + } + + pub(crate) fn delete_page_in_board_at_with_measurer( + &mut self, + measurer: &TextMeasurer, board_index: usize, page_index: usize, now: Instant, @@ -50,7 +68,7 @@ impl InputState { && ((matches!(&request, PageDeleteRequest::Request(_)) && page_count <= 1) || confirmation_is_current); if should_prepare_active { - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); } match self.boards.delete_page(request) { @@ -100,10 +118,21 @@ impl InputState { } pub fn page_delete(&mut self) -> CanvasPageDeleteOutcome { - self.delete_active_page_at(Instant::now()) + with_legacy_measurer(|measurer| self.page_delete_with_measurer(measurer)) } - pub(crate) fn delete_active_page_at(&mut self, now: Instant) -> CanvasPageDeleteOutcome { + pub fn page_delete_with_measurer( + &mut self, + measurer: &TextMeasurer, + ) -> CanvasPageDeleteOutcome { + self.delete_active_page_at_with_measurer(measurer, Instant::now()) + } + + pub(crate) fn delete_active_page_at_with_measurer( + &mut self, + measurer: &TextMeasurer, + now: Instant, + ) -> CanvasPageDeleteOutcome { let page_count = self.boards.page_count(); let page_index = self.boards.active_page_index(); @@ -128,7 +157,7 @@ impl InputState { && ((matches!(&request, PageDeleteRequest::Request(_)) && page_count <= 1) || confirmation_is_current); if should_prepare_active { - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); } match self.boards.delete_page(request) { @@ -235,14 +264,22 @@ impl InputState { /// Restore the most recently deleted page. pub fn restore_deleted_page(&mut self) { - self.restore_deleted_page_at(Instant::now()); + with_legacy_measurer(|measurer| self.restore_deleted_page_with_measurer(measurer)) } - pub(crate) fn restore_deleted_page_at(&mut self, now: Instant) { + pub fn restore_deleted_page_with_measurer(&mut self, measurer: &TextMeasurer) { + self.restore_deleted_page_at_with_measurer(measurer, Instant::now()); + } + + pub(crate) fn restore_deleted_page_at_with_measurer( + &mut self, + measurer: &TextMeasurer, + now: Instant, + ) { if let Some((request, deleted_at)) = self.board_transitions.take_restorable_page(now) { let active_target = request.board_id == self.boards.active_board_id(); if active_target { - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); } match self.boards.restore_page(request) { PageRestoreOutcome::Restored { diff --git a/src/input/state/core/board/delete_restore/tests.rs b/src/input/state/core/board/delete_restore/tests.rs index 37a2af4f8..051deba5b 100644 --- a/src/input/state/core/board/delete_restore/tests.rs +++ b/src/input/state/core/board/delete_restore/tests.rs @@ -29,27 +29,29 @@ fn set_page_count(state: &mut InputState, board_index: usize, count: usize) { #[test] fn confirmed_board_delete_uses_supplied_now_for_undo_timestamp() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); state.switch_board(BOARD_ID_BLACKBOARD); let requested_at = Instant::now(); let confirmed_at = requested_at + Duration::from_millis(1); - state.delete_active_board_at(requested_at); - state.delete_active_board_at(confirmed_at); + state.delete_active_board_at_with_measurer(&measurer, requested_at); + state.delete_active_board_at_with_measurer(&measurer, confirmed_at); assert_eq!(state.latest_deleted_board_at_for_test(), Some(confirmed_at)); } #[test] fn expired_board_delete_confirmation_is_replaced_with_supplied_now() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); state.switch_board(BOARD_ID_BLACKBOARD); let requested_at = Instant::now(); let expired_at = requested_at + Duration::from_millis(BOARD_DELETE_CONFIRM_MS + 1); let board_count = state.boards.board_count(); - state.delete_active_board_at(requested_at); - state.delete_active_board_at(expired_at); + state.delete_active_board_at_with_measurer(&measurer, requested_at); + state.delete_active_board_at_with_measurer(&measurer, expired_at); assert_eq!(state.boards.board_count(), board_count); assert_eq!( @@ -60,13 +62,14 @@ fn expired_board_delete_confirmation_is_replaced_with_supplied_now() { #[test] fn restore_deleted_board_expires_old_entries_with_supplied_now() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); state.switch_board(BOARD_ID_BLACKBOARD); let requested_at = Instant::now(); let confirmed_at = requested_at + Duration::from_millis(1); - state.delete_active_board_at(requested_at); - state.delete_active_board_at(confirmed_at); + state.delete_active_board_at_with_measurer(&measurer, requested_at); + state.delete_active_board_at_with_measurer(&measurer, confirmed_at); let actions = state.take_pending_board_runtime_ui_actions(); assert!(matches!( actions.as_slice(), @@ -88,6 +91,7 @@ fn restore_deleted_board_expires_old_entries_with_supplied_now() { #[test] fn confirmed_active_page_delete_uses_supplied_now_for_undo_timestamp() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); let board = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); @@ -96,11 +100,11 @@ fn confirmed_active_page_delete_uses_supplied_now_for_undo_timestamp() { let confirmed_at = requested_at + Duration::from_millis(1); assert_eq!( - state.delete_active_page_at(requested_at), + state.delete_active_page_at_with_measurer(&measurer, requested_at), crate::draw::PageDeleteOutcome::Pending ); assert_eq!( - state.delete_active_page_at(confirmed_at), + state.delete_active_page_at_with_measurer(&measurer, confirmed_at), crate::draw::PageDeleteOutcome::Removed ); @@ -109,6 +113,7 @@ fn confirmed_active_page_delete_uses_supplied_now_for_undo_timestamp() { #[test] fn expired_active_page_delete_confirmation_is_replaced_with_supplied_now() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); let board = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); @@ -118,11 +123,11 @@ fn expired_active_page_delete_confirmation_is_replaced_with_supplied_now() { let page_count = state.boards.page_count(); assert_eq!( - state.delete_active_page_at(requested_at), + state.delete_active_page_at_with_measurer(&measurer, requested_at), crate::draw::PageDeleteOutcome::Pending ); assert_eq!( - state.delete_active_page_at(expired_at), + state.delete_active_page_at_with_measurer(&measurer, expired_at), crate::draw::PageDeleteOutcome::Pending ); @@ -135,6 +140,7 @@ fn expired_active_page_delete_confirmation_is_replaced_with_supplied_now() { #[test] fn expired_page_in_board_delete_confirmation_is_replaced_with_supplied_now() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); let board = board_index(&state, BOARD_ID_BLACKBOARD); set_page_count(&mut state, board, 2); @@ -143,11 +149,11 @@ fn expired_page_in_board_delete_confirmation_is_replaced_with_supplied_now() { let page_count = state.boards.board_states()[board].pages.page_count(); assert_eq!( - state.delete_page_in_board_at(board, 1, requested_at), + state.delete_page_in_board_at_with_measurer(&measurer, board, 1, requested_at), crate::draw::PageDeleteOutcome::Pending ); assert_eq!( - state.delete_page_in_board_at(board, 1, expired_at), + state.delete_page_in_board_at_with_measurer(&measurer, board, 1, expired_at), crate::draw::PageDeleteOutcome::Pending ); @@ -166,6 +172,7 @@ fn expired_page_in_board_delete_confirmation_is_replaced_with_supplied_now() { /// one, so it cannot surface later against a session that no longer backs it. #[test] fn session_replacement_drops_queued_delete_undo_toast() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); state.switch_board(BOARD_ID_BLACKBOARD); @@ -173,8 +180,8 @@ fn session_replacement_drops_queued_delete_undo_toast() { // deleted-board undo entry exists. let requested_at = Instant::now(); let confirmed_at = requested_at + Duration::from_millis(1); - state.delete_active_board_at(requested_at); - state.delete_active_board_at(confirmed_at); + state.delete_active_board_at_with_measurer(&measurer, requested_at); + state.delete_active_board_at_with_measurer(&measurer, confirmed_at); assert!( state.has_deleted_boards_for_test(), "board delete recorded undo" @@ -226,6 +233,7 @@ fn session_replacement_drops_queued_delete_undo_toast() { #[test] fn restore_deleted_page_expires_old_entries_with_supplied_now() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); let board = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); @@ -234,16 +242,19 @@ fn restore_deleted_page_expires_old_entries_with_supplied_now() { let confirmed_at = requested_at + Duration::from_millis(1); assert_eq!( - state.delete_active_page_at(requested_at), + state.delete_active_page_at_with_measurer(&measurer, requested_at), crate::draw::PageDeleteOutcome::Pending ); assert_eq!( - state.delete_active_page_at(confirmed_at), + state.delete_active_page_at_with_measurer(&measurer, confirmed_at), crate::draw::PageDeleteOutcome::Removed ); let page_count_after_delete = state.boards.page_count(); - state.restore_deleted_page_at(confirmed_at + Duration::from_millis(PAGE_UNDO_EXPIRE_MS + 1)); + state.restore_deleted_page_at_with_measurer( + &measurer, + confirmed_at + Duration::from_millis(PAGE_UNDO_EXPIRE_MS + 1), + ); assert!(!state.has_deleted_pages_for_test()); assert_eq!(state.boards.page_count(), page_count_after_delete); @@ -255,14 +266,15 @@ fn restore_deleted_page_expires_old_entries_with_supplied_now() { #[test] fn delete_then_create_reused_id_tracks_distinct_identity_before_drain() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); assert!(state.create_board()); let reused_id = state.board_id().to_string(); let _ = state.take_pending_board_runtime_ui_actions(); let requested_at = Instant::now(); - state.delete_active_board_at(requested_at); - state.delete_active_board_at(requested_at + Duration::from_millis(1)); + state.delete_active_board_at_with_measurer(&measurer, requested_at); + state.delete_active_board_at_with_measurer(&measurer, requested_at + Duration::from_millis(1)); assert!(state.create_board()); assert_eq!(state.board_id(), reused_id); @@ -278,6 +290,7 @@ fn delete_then_create_reused_id_tracks_distinct_identity_before_drain() { #[test] fn delete_then_restore_same_board_cancels_pending_identity_deletion() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); assert!(state.create_board()); let restored_id = state.board_id().to_string(); @@ -285,8 +298,8 @@ fn delete_then_restore_same_board_cancels_pending_identity_deletion() { let requested_at = Instant::now(); let confirmed_at = requested_at + Duration::from_millis(1); - state.delete_active_board_at(requested_at); - state.delete_active_board_at(confirmed_at); + state.delete_active_board_at_with_measurer(&measurer, requested_at); + state.delete_active_board_at_with_measurer(&measurer, confirmed_at); state.restore_deleted_board_at(confirmed_at + Duration::from_millis(1)); let runtime_actions = state.take_pending_board_runtime_ui_actions(); diff --git a/src/input/state/core/board/lifecycle.rs b/src/input/state/core/board/lifecycle.rs index 91f764b1c..c08f24f88 100644 --- a/src/input/state/core/board/lifecycle.rs +++ b/src/input/state/core/board/lifecycle.rs @@ -1,4 +1,5 @@ use super::super::base::InputState; +use crate::draw::TextMeasurer; use crate::input::boards::PendingBoardRuntimeUiAction; impl InputState { @@ -39,8 +40,8 @@ impl InputState { }); } - pub(super) fn prepare_active_page_content_change(&mut self) { - self.cancel_active_interaction(); + pub(super) fn prepare_active_page_content_change(&mut self, measurer: &TextMeasurer) { + self.cancel_active_interaction_with(measurer); } pub(super) fn finish_active_page_content_change(&mut self) { diff --git a/src/input/state/core/board/pages.rs b/src/input/state/core/board/pages.rs index 7f11f08a6..e2a83939e 100644 --- a/src/input/state/core/board/pages.rs +++ b/src/input/state/core/board/pages.rs @@ -1,5 +1,6 @@ use super::super::base::InputState; use crate::draw::Color; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::boards::PendingBoardRuntimeUiAction; use crate::input::state::{Toast, ToastPriority}; use crate::input::{BoardBackground, runtime_contrast_pen_color}; @@ -124,6 +125,18 @@ impl InputState { board_index: usize, from: usize, to: usize, + ) -> bool { + with_legacy_measurer(|measurer| { + self.reorder_page_in_board_with_measurer(measurer, board_index, from, to) + }) + } + + pub(crate) fn reorder_page_in_board_with_measurer( + &mut self, + measurer: &TextMeasurer, + board_index: usize, + from: usize, + to: usize, ) -> bool { let is_active_board = self.boards.active_index() == board_index; let Some(board) = self.boards.board_states().get(board_index) else { @@ -134,7 +147,7 @@ impl InputState { return false; } if is_active_board { - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); } let Some(board) = self.boards.board_state_mut(board_index) else { return false; @@ -146,12 +159,20 @@ impl InputState { } pub(crate) fn add_page_in_board(&mut self, board_index: usize) -> bool { + with_legacy_measurer(|measurer| self.add_page_in_board_with_measurer(measurer, board_index)) + } + + pub(crate) fn add_page_in_board_with_measurer( + &mut self, + measurer: &TextMeasurer, + board_index: usize, + ) -> bool { let is_active_board = self.boards.active_index() == board_index; if self.boards.board_states().get(board_index).is_none() { return false; } if is_active_board { - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); } let Some(board) = self.boards.board_state_mut(board_index) else { return false; @@ -176,6 +197,17 @@ impl InputState { &mut self, board_index: usize, page_index: usize, + ) -> bool { + with_legacy_measurer(|measurer| { + self.duplicate_page_in_board_with_measurer(measurer, board_index, page_index) + }) + } + + pub(crate) fn duplicate_page_in_board_with_measurer( + &mut self, + measurer: &TextMeasurer, + board_index: usize, + page_index: usize, ) -> bool { let is_active_board = self.boards.active_index() == board_index; let Some(board) = self.boards.board_states().get(board_index) else { @@ -188,7 +220,7 @@ impl InputState { return false; } if is_active_board { - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); } let Some(board) = self.boards.board_state_mut(board_index) else { return false; @@ -216,6 +248,18 @@ impl InputState { board_index: usize, page_index: usize, name: Option, + ) -> bool { + with_legacy_measurer(|measurer| { + self.rename_page_in_board_with_measurer(measurer, board_index, page_index, name) + }) + } + + pub(crate) fn rename_page_in_board_with_measurer( + &mut self, + measurer: &TextMeasurer, + board_index: usize, + page_index: usize, + name: Option, ) -> bool { let is_active_board = self.boards.active_index() == board_index; let Some(board) = self.boards.board_states().get(board_index) else { @@ -225,7 +269,7 @@ impl InputState { return false; } if is_active_board { - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); } let Some(board) = self.boards.board_state_mut(board_index) else { return false; @@ -249,6 +293,27 @@ impl InputState { target_board: usize, copy: bool, activate_target: bool, + ) -> bool { + with_legacy_measurer(|measurer| { + self.move_page_between_boards_with_activation_with_measurer( + measurer, + source_board, + page_index, + target_board, + copy, + activate_target, + ) + }) + } + + pub(crate) fn move_page_between_boards_with_activation_with_measurer( + &mut self, + measurer: &TextMeasurer, + source_board: usize, + page_index: usize, + target_board: usize, + copy: bool, + activate_target: bool, ) -> bool { if source_board == target_board { return false; @@ -271,7 +336,7 @@ impl InputState { let active_board = self.boards.active_index(); let active_involved = source_board == active_board || target_board == active_board; if active_involved { - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); } let (new_index, target_name, target_id, target_count) = { let (source, target) = if source_board < target_board { @@ -319,7 +384,7 @@ impl InputState { ); self.mark_session_dirty(); if activate_target { - self.switch_board_slot(target_board); + self.switch_board_slot_with_measurer(measurer, target_board); if let Some(row) = self.board_picker_row_for_board(target_board) { self.board_picker_set_selected(row); } @@ -328,10 +393,14 @@ impl InputState { } pub fn page_prev(&mut self) -> bool { + with_legacy_measurer(|measurer| self.page_prev_with_measurer(measurer)) + } + + pub fn page_prev_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { if self.boards.active_page_index() == 0 { return false; } - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); let switched = self.boards.prev_page(); debug_assert!(switched, "preflighted previous page failed on apply"); self.finish_active_page_content_change(); @@ -339,10 +408,14 @@ impl InputState { } pub fn page_next(&mut self) -> bool { + with_legacy_measurer(|measurer| self.page_next_with_measurer(measurer)) + } + + pub fn page_next_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { if self.boards.active_page_index() + 1 >= self.boards.page_count() { return false; } - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); let switched = self.boards.next_page(); debug_assert!(switched, "preflighted next page failed on apply"); self.finish_active_page_content_change(); @@ -350,10 +423,14 @@ impl InputState { } pub fn switch_to_page(&mut self, index: usize) -> bool { + with_legacy_measurer(|measurer| self.switch_to_page_with_measurer(measurer, index)) + } + + pub fn switch_to_page_with_measurer(&mut self, measurer: &TextMeasurer, index: usize) -> bool { if index >= self.boards.page_count() || index == self.boards.active_page_index() { return false; } - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); let switched = self.boards.active_pages_mut().switch_to_page(index); debug_assert!(switched, "preflighted page switch failed on apply"); self.finish_active_page_content_change(); @@ -361,7 +438,11 @@ impl InputState { } pub fn page_new(&mut self) { - self.prepare_active_page_content_change(); + with_legacy_measurer(|measurer| self.page_new_with_measurer(measurer)) + } + + pub fn page_new_with_measurer(&mut self, measurer: &TextMeasurer) { + self.prepare_active_page_content_change(measurer); self.boards.new_page(); self.finish_active_page_content_change(); let page_num = self.boards.active_page_index() + 1; @@ -374,11 +455,15 @@ impl InputState { } pub fn page_duplicate(&mut self) { + with_legacy_measurer(|measurer| self.page_duplicate_with_measurer(measurer)) + } + + pub fn page_duplicate_with_measurer(&mut self, measurer: &TextMeasurer) { let before_page = self.boards.active_page_index(); if !self.session_allows_page_duplicate(self.boards.active_index(), before_page) { return; } - self.prepare_active_page_content_change(); + self.prepare_active_page_content_change(measurer); self.boards.duplicate_page(); self.finish_active_page_content_change(); let page_num = self.boards.active_page_index() + 1; diff --git a/src/input/state/core/board/switch.rs b/src/input/state/core/board/switch.rs index 06da884ec..8e35b8a81 100644 --- a/src/input/state/core/board/switch.rs +++ b/src/input/state/core/board/switch.rs @@ -1,4 +1,5 @@ use super::super::base::InputState; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::state::{Toast, ToastPriority}; use crate::input::{BOARD_ID_TRANSPARENT, BoardSpec}; @@ -44,15 +45,28 @@ impl InputState { /// /// Also resets drawing state to prevent partial shapes crossing modes. pub fn switch_board(&mut self, target_id: &str) { - self.switch_board_internal(target_id, true); + with_legacy_measurer(|measurer| self.switch_board_with_measurer(measurer, target_id)) + } + + pub fn switch_board_with_measurer(&mut self, measurer: &TextMeasurer, target_id: &str) { + self.switch_board_internal(measurer, target_id, true); } /// Switches to a different board without toggle semantics. pub fn switch_board_force(&mut self, target_id: &str) { - self.switch_board_internal(target_id, false); + with_legacy_measurer(|measurer| self.switch_board_force_with_measurer(measurer, target_id)) } - fn switch_board_internal(&mut self, target_id: &str, allow_toggle: bool) { + pub fn switch_board_force_with_measurer(&mut self, measurer: &TextMeasurer, target_id: &str) { + self.switch_board_internal(measurer, target_id, false); + } + + fn switch_board_internal( + &mut self, + measurer: &TextMeasurer, + target_id: &str, + allow_toggle: bool, + ) { let current_id = self.boards.active_board_id().to_string(); // Toggle behavior: if already in target board, return to transparent. @@ -66,6 +80,7 @@ impl InputState { } self.switch_board_with( + measurer, |boards| boards.can_switch_to_id(&target_id), |boards| boards.switch_to_id(&target_id), ¤t_id, @@ -73,8 +88,13 @@ impl InputState { } pub fn create_board(&mut self) -> bool { + with_legacy_measurer(|measurer| self.create_board_with_measurer(measurer)) + } + + pub fn create_board_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { let current_id = self.boards.active_board_id().to_string(); let created = self.switch_board_with( + measurer, |boards| boards.board_count() < boards.max_count(), |boards| boards.create_board(), ¤t_id, @@ -91,8 +111,13 @@ impl InputState { } pub fn switch_board_slot(&mut self, slot: usize) { + with_legacy_measurer(|measurer| self.switch_board_slot_with_measurer(measurer, slot)) + } + + pub fn switch_board_slot_with_measurer(&mut self, measurer: &TextMeasurer, slot: usize) { let current_id = self.boards.active_board_id().to_string(); self.switch_board_with( + measurer, |boards| boards.can_switch_to_slot(slot), |boards| boards.switch_to_slot(slot), ¤t_id, @@ -100,8 +125,13 @@ impl InputState { } pub fn switch_board_next(&mut self) { + with_legacy_measurer(|measurer| self.switch_board_next_with_measurer(measurer)) + } + + pub fn switch_board_next_with_measurer(&mut self, measurer: &TextMeasurer) { let current_id = self.boards.active_board_id().to_string(); self.switch_board_with( + measurer, |boards| boards.board_count() > 1, |boards| boards.next_board(), ¤t_id, @@ -109,8 +139,13 @@ impl InputState { } pub fn switch_board_prev(&mut self) { + with_legacy_measurer(|measurer| self.switch_board_prev_with_measurer(measurer)) + } + + pub fn switch_board_prev_with_measurer(&mut self, measurer: &TextMeasurer) { let current_id = self.boards.active_board_id().to_string(); self.switch_board_with( + measurer, |boards| boards.board_count() > 1, |boards| boards.prev_board(), ¤t_id, @@ -119,6 +154,10 @@ impl InputState { /// Duplicate the active board. pub fn duplicate_board(&mut self) { + with_legacy_measurer(|measurer| self.duplicate_board_with_measurer(measurer)) + } + + pub fn duplicate_board_with_measurer(&mut self, measurer: &TextMeasurer) { if self.board_is_transparent() { self.push_toast( ToastPriority::Info, @@ -141,7 +180,7 @@ impl InputState { return; } - self.cancel_active_interaction(); + self.cancel_active_interaction_with(measurer); let generation_before = self.boards.board_identity_generation(); if let Some(new_id) = self.boards.duplicate_active_board() { self.clear_pending_deletes_after_board_generation_change(generation_before); @@ -176,6 +215,10 @@ impl InputState { /// Switch to the most recently used board (other than the current one). pub fn switch_board_recent(&mut self) { + with_legacy_measurer(|measurer| self.switch_board_recent_with_measurer(measurer)) + } + + pub fn switch_board_recent_with_measurer(&mut self, measurer: &TextMeasurer) { // Find the first recent board that isn't the current one let current_id = self.boards.active_board_id(); let target = self @@ -186,7 +229,7 @@ impl InputState { .cloned(); if let Some(target_id) = target { - self.switch_board_force(&target_id); + self.switch_board_force_with_measurer(measurer, &target_id); } else { self.push_toast( ToastPriority::Info, @@ -198,6 +241,7 @@ impl InputState { pub(super) fn switch_board_with( &mut self, + measurer: &TextMeasurer, can_switch: impl FnOnce(&crate::input::BoardManager) -> bool, switch: impl FnOnce(&mut crate::input::BoardManager) -> bool, current_id: &str, @@ -214,7 +258,7 @@ impl InputState { .map(|board| board.spec.id.clone()) .collect::>(); let generation_before = self.boards.board_identity_generation(); - self.cancel_active_interaction(); + self.cancel_active_interaction_with(measurer); let switched = switch(&mut self.boards); debug_assert!(switched, "preflighted board transition failed on apply"); if !switched { diff --git a/src/input/state/core/board_picker/state/lifecycle.rs b/src/input/state/core/board_picker/state/lifecycle.rs index acc12abd5..33476c17c 100644 --- a/src/input/state/core/board_picker/state/lifecycle.rs +++ b/src/input/state/core/board_picker/state/lifecycle.rs @@ -1,5 +1,6 @@ use super::super::super::base::InputState; use super::super::{BoardPickerFocus, BoardPickerMode, BoardPickerPageNavMode, BoardPickerState}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; impl InputState { pub(crate) fn is_board_picker_open(&self) -> bool { @@ -15,18 +16,26 @@ impl InputState { } pub(crate) fn open_board_picker(&mut self) { - self.open_board_picker_with(BoardPickerMode::Full); + with_legacy_measurer(|measurer| self.open_board_picker_with_measurer(measurer)) + } + + pub(crate) fn open_board_picker_with_measurer(&mut self, measurer: &TextMeasurer) { + self.open_board_picker_in_mode(measurer, BoardPickerMode::Full); } pub(crate) fn open_board_picker_quick(&mut self) { - self.open_board_picker_with(BoardPickerMode::Quick); + with_legacy_measurer(|measurer| self.open_board_picker_quick_with_measurer(measurer)) + } + + pub(crate) fn open_board_picker_quick_with_measurer(&mut self, measurer: &TextMeasurer) { + self.open_board_picker_in_mode(measurer, BoardPickerMode::Quick); self.board_picker_select_recent(); } - fn open_board_picker_with(&mut self, mode: BoardPickerMode) { + fn open_board_picker_in_mode(&mut self, measurer: &TextMeasurer, mode: BoardPickerMode) { self.pending_onboarding_usage.used_board_picker = true; self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::BoardPicker); - self.cancel_active_interaction(); + self.cancel_active_interaction_with(measurer); let active_index = self.boards.active_index(); let active_page = self.boards.active_page_index(); let selected_row = self.board_picker_row_for_board(active_index); @@ -46,18 +55,26 @@ impl InputState { } pub(crate) fn toggle_board_picker(&mut self) { + with_legacy_measurer(|measurer| self.toggle_board_picker_with_measurer(measurer)) + } + + pub(crate) fn toggle_board_picker_with_measurer(&mut self, measurer: &TextMeasurer) { if self.is_board_picker_open() { self.close_board_picker(); } else { - self.open_board_picker(); + self.open_board_picker_with_measurer(measurer); } } pub(crate) fn toggle_board_picker_quick(&mut self) { + with_legacy_measurer(|measurer| self.toggle_board_picker_quick_with(measurer)) + } + + pub(crate) fn toggle_board_picker_quick_with(&mut self, measurer: &TextMeasurer) { if self.is_board_picker_open() { self.close_board_picker(); } else { - self.open_board_picker_quick(); + self.open_board_picker_quick_with_measurer(measurer); } } diff --git a/src/input/state/core/color_picker_popup/state.rs b/src/input/state/core/color_picker_popup/state.rs index e291054ec..68e803221 100644 --- a/src/input/state/core/color_picker_popup/state.rs +++ b/src/input/state/core/color_picker_popup/state.rs @@ -1,5 +1,6 @@ //! Color picker popup state methods for InputState. +use crate::draw::{TextMeasurer, with_legacy_measurer}; use std::borrow::Cow; use crate::draw::Color; @@ -46,9 +47,13 @@ impl InputState { /// Opens the color picker popup with the current color. pub fn open_color_picker_popup(&mut self) { + with_legacy_measurer(|measurer| self.open_color_picker_popup_with_measurer(measurer)) + } + + pub fn open_color_picker_popup_with_measurer(&mut self, measurer: &TextMeasurer) { self.discard_open_color_picker_recolor(); let color = self.color_for_tool(self.active_tool()); - self.open_color_picker_popup_for(None, color); + self.open_color_picker_popup_for(measurer, None, color); } /// Opens the color picker popup bound to a quick-color slot, so editing it @@ -56,6 +61,16 @@ impl InputState { /// index is past the palette — a stale click on a snapshot rendered before /// the palette shrank, which must open nothing. pub fn open_color_picker_popup_for_quick_color(&mut self, index: usize) -> bool { + with_legacy_measurer(|measurer| { + self.open_color_picker_popup_for_quick_color_with_measurer(measurer, index) + }) + } + + pub fn open_color_picker_popup_for_quick_color_with_measurer( + &mut self, + measurer: &TextMeasurer, + index: usize, + ) -> bool { if self.style.quick_colors.entry(index).is_none() { return false; } @@ -65,7 +80,7 @@ impl InputState { let Some(color) = self.style.quick_colors.color_for_index(index) else { return false; }; - self.open_color_picker_popup_for(Some(index), color); + self.open_color_picker_popup_for(measurer, Some(index), color); true } @@ -79,10 +94,15 @@ impl InputState { } } - fn open_color_picker_popup_for(&mut self, slot: Option, color: Color) { + fn open_color_picker_popup_for( + &mut self, + measurer: &TextMeasurer, + slot: Option, + color: Color, + ) { self.cancel_pending_color_picker_paste(); self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::ColorPicker); - self.cancel_active_interaction(); + self.cancel_active_interaction_with(measurer); let tool = self.active_tool(); self.color_picker_popup.open(tool, slot, color); diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs index 6b2ff2ceb..1e7d93eda 100644 --- a/src/input/state/core/dirty.rs +++ b/src/input/state/core/dirty.rs @@ -76,10 +76,6 @@ impl InputState { /// /// This is needed when existing provisional geometry changes in place, for /// example when the first tablet pressure sample backfills previous widths. - pub(crate) fn mark_current_provisional_dirty_full(&mut self) { - with_legacy_measurer(|measurer| self.mark_current_provisional_dirty_full_with(measurer)) - } - pub(crate) fn mark_current_provisional_dirty_full_with(&mut self, measurer: &TextMeasurer) { let (current_x, current_y) = self.pointer.canvas(); if let Some(bounds) = self.compute_provisional_bounds(measurer, current_x, current_y) { diff --git a/src/input/state/core/font_cycle.rs b/src/input/state/core/font_cycle.rs index 27137af34..61159a0e0 100644 --- a/src/input/state/core/font_cycle.rs +++ b/src/input/state/core/font_cycle.rs @@ -11,6 +11,7 @@ use super::InputState; use crate::draw::{FontDescriptor, families_match}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; impl InputState { /// Install the configured list. Blank and repeated names are the config @@ -51,6 +52,10 @@ impl InputState { /// until something is typed, and because a family name is the only way to /// tell two similar faces apart at a glance. pub(crate) fn cycle_font_family(&mut self) -> bool { + with_legacy_measurer(|measurer| self.cycle_font_family_with_measurer(measurer)) + } + + pub(crate) fn cycle_font_family_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { if self.style.font_cycle.is_empty() { self.push_toast( super::ToastPriority::Info, @@ -63,7 +68,7 @@ impl InputState { // A selection takes the step, so the gesture edits what the user is // looking at rather than a setting they cannot see. if self.selection_has_text() { - return self.cycle_selected_font_family(); + return self.cycle_selected_font_family(measurer); } let Some(next) = self.next_font_family(&self.style.font_descriptor.family) else { @@ -90,7 +95,7 @@ impl InputState { /// /// The step is decided once, from the first selected text shape, so a mixed /// selection converges on one family instead of fanning out further. - fn cycle_selected_font_family(&mut self) -> bool { + fn cycle_selected_font_family(&mut self, measurer: &TextMeasurer) -> bool { let Some(next) = self .first_selected_text_family() .and_then(|family| self.next_font_family(&family)) @@ -98,7 +103,7 @@ impl InputState { return false; }; - let changed = self.apply_family_to_selected_text(&next); + let changed = self.apply_family_to_selected_text_with(measurer, &next); if changed { log::info!("Selected text font family set to {next}"); } diff --git a/src/input/state/core/font_picker/mod.rs b/src/input/state/core/font_picker/mod.rs index 074c5b6b2..2d8a3c9cb 100644 --- a/src/input/state/core/font_picker/mod.rs +++ b/src/input/state/core/font_picker/mod.rs @@ -26,6 +26,7 @@ pub use layout::{FontPickerLayout, FontPickerRow, font_picker_layout, font_picke use super::InputState; use crate::draw::{FontDescriptor, families_match, system_font_catalog_is_ready}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; /// The picker's memoized result list, keyed by what produced it. pub type FontPickerResults = Option<((String, FontPickerFilter), Vec)>; @@ -221,12 +222,16 @@ impl InputState { /// Apply the highlighted family and close. pub(crate) fn commit_font_picker(&mut self) -> bool { + with_legacy_measurer(|measurer| self.commit_font_picker_with_measurer(measurer)) + } + + pub(crate) fn commit_font_picker_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { let families = self.font_picker_families(); let Some(family) = families.get(self.font_picker.selected).cloned() else { self.close_font_picker(); return false; }; - let applied = self.apply_font_family(&family); + let applied = self.apply_font_family(measurer, &family); if applied { self.font_picker.remember_choice(&family); log::info!("Font picker applied {family}"); @@ -241,9 +246,9 @@ impl InputState { } /// Set `family` on the selection, or on the tool when nothing is selected. - fn apply_font_family(&mut self, family: &str) -> bool { + fn apply_font_family(&mut self, measurer: &TextMeasurer, family: &str) -> bool { if self.font_picker.target == FontPickerTarget::Selection && self.selection_has_text() { - return self.apply_family_to_selected_text(family); + return self.apply_family_to_selected_text_with(measurer, family); } self.set_font_descriptor(FontDescriptor::new( diff --git a/src/input/state/core/highlight_controls.rs b/src/input/state/core/highlight_controls.rs index 4d1aed5b4..9c1b155ca 100644 --- a/src/input/state/core/highlight_controls.rs +++ b/src/input/state/core/highlight_controls.rs @@ -1,5 +1,6 @@ use super::base::{DrawingState, InputState}; use super::history_limits::HistoryMode; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::tool::Tool; use cairo::Context as CairoContext; use std::time::Instant; @@ -132,12 +133,16 @@ impl InputState { /// Sets highlight-only tool mode on/off and keeps click highlight in sync. pub fn set_highlight_tool(&mut self, enable: bool) { + with_legacy_measurer(|measurer| self.set_highlight_tool_with_measurer(measurer, enable)) + } + + pub fn set_highlight_tool_with_measurer(&mut self, measurer: &TextMeasurer, enable: bool) { let currently_on = self.highlight_tool_active(); if enable != currently_on { if enable { - self.set_tool_override(Some(Tool::Highlight)); + self.set_tool_override_with(measurer, Some(Tool::Highlight)); } else { - self.set_tool_override(None); + self.set_tool_override_with(measurer, None); } } @@ -157,6 +162,10 @@ impl InputState { /// Toggles the combined highlight tool and click highlight together. pub fn toggle_all_highlights(&mut self) -> bool { + with_legacy_measurer(|measurer| self.toggle_all_highlights_with_measurer(measurer)) + } + + pub fn toggle_all_highlights_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { let force_click = self.presenter_mode_active() && self.presenter_mode_config().enable_click_highlight; let enable = if force_click { @@ -164,7 +173,7 @@ impl InputState { } else { !(self.highlight_tool_active() || self.click_highlight_enabled()) }; - self.set_highlight_tool(enable); + self.set_highlight_tool_with_measurer(measurer, enable); self.highlight_tool_active() } diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index df40a23c0..dcde1e89f 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -9,6 +9,7 @@ //! this module says the pair deliberately coexists. use super::DrawingState; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::state::InputState; /// Every popup surface that participates in modal mutual exclusion, in the @@ -173,7 +174,11 @@ impl InputState { /// each surface is dismissed by its own closer — the tour used to be a bare /// flag clear here, which left the toolbar chrome it hides still hidden. pub(crate) fn prepare_for_screen_modal(&mut self) { - self.cancel_active_interaction(); + with_legacy_measurer(|measurer| self.prepare_for_screen_modal_with_measurer(measurer)) + } + + pub(crate) fn prepare_for_screen_modal_with_measurer(&mut self, measurer: &TextMeasurer) { + self.cancel_active_interaction_with(measurer); for surface in ModalSurface::ALL { if self.modal_is_open(surface) { self.close_modal(surface); diff --git a/src/input/state/core/session.rs b/src/input/state/core/session.rs index 6edf0b276..5612c559c 100644 --- a/src/input/state/core/session.rs +++ b/src/input/state/core/session.rs @@ -294,12 +294,13 @@ mod tests { #[test] fn session_capture_rollback_preserves_board_delete_confirmation_identity() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); state.switch_board(BOARD_ID_BLACKBOARD); assert_eq!(state.board_id(), BOARD_ID_BLACKBOARD); let requested_at = Instant::now(); - state.delete_active_board_at(requested_at); + state.delete_active_board_at_with_measurer(&measurer, requested_at); assert!(state.has_pending_board_delete()); state.begin_pointer_drag(MouseButton::Left, None); @@ -308,7 +309,10 @@ mod tests { }); assert!(state.has_active_pointer_interaction()); - state.delete_active_board_at(requested_at + Duration::from_millis(1)); + state.delete_active_board_at_with_measurer( + &measurer, + requested_at + Duration::from_millis(1), + ); assert!(!state.boards.has_board(BOARD_ID_BLACKBOARD)); assert!(!state.has_pending_board_delete()); diff --git a/src/input/state/core/text_font.rs b/src/input/state/core/text_font.rs index 9ab880ddd..33d101072 100644 --- a/src/input/state/core/text_font.rs +++ b/src/input/state/core/text_font.rs @@ -7,8 +7,8 @@ //! applied change reports. use super::InputState; +use crate::draw::TextMeasurer; use crate::draw::{FontDescriptor, Shape, families_match}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; fn text_font_descriptor(shape: &Shape) -> Option<&FontDescriptor> { match shape { @@ -94,10 +94,6 @@ impl InputState { /// numeric weight is asking for something a two-state control cannot say, /// so turning bold off from here lands on `normal` rather than restoring /// whatever number was there. - pub(crate) fn set_font_bold(&mut self, bold: bool) -> bool { - with_legacy_measurer(|measurer| self.set_font_bold_with(measurer, bold)) - } - pub(crate) fn set_font_bold_with(&mut self, measurer: &TextMeasurer, bold: bool) -> bool { let weight = if bold { "bold" } else { "normal" }; if self.selection_has_text() { @@ -129,13 +125,6 @@ impl InputState { /// Returns whether anything changed. A shape already in that family is left /// alone — matched without case, because fontconfig resolves names that way /// and rewriting `Sans` as `sans` is not an edit. - pub(in crate::input::state::core) fn apply_family_to_selected_text( - &mut self, - family: &str, - ) -> bool { - with_legacy_measurer(|measurer| self.apply_family_to_selected_text_with(measurer, family)) - } - pub(in crate::input::state::core) fn apply_family_to_selected_text_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/tool_controls/presets.rs b/src/input/state/core/tool_controls/presets.rs index 5814c1634..c3f8bcfa9 100644 --- a/src/input/state/core/tool_controls/presets.rs +++ b/src/input/state/core/tool_controls/presets.rs @@ -4,6 +4,7 @@ use super::super::base::{ }; use super::super::default_step_marker_size; use crate::config::{PresetSlotsConfig, PresetToolStatesConfig, ToolPresetConfig}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::{DragModifier, tool::Tool}; use std::time::{Duration, Instant}; @@ -13,13 +14,17 @@ impl InputState { } pub fn apply_preset(&mut self, slot: usize) -> bool { + with_legacy_measurer(|measurer| self.apply_preset_with(measurer, slot)) + } + + pub fn apply_preset_with(&mut self, measurer: &TextMeasurer, slot: usize) -> bool { let Some(preset) = self.preset_slots.preset(slot) else { return false; }; match self.state { - DrawingState::TextInput { .. } => self.cancel_text_input(), - DrawingState::BuildingPolygon { .. } => self.cancel_active_interaction(), + DrawingState::TextInput { .. } => self.cancel_text_input_with(measurer), + DrawingState::BuildingPolygon { .. } => self.cancel_active_interaction_with(measurer), _ => {} } @@ -28,15 +33,15 @@ impl InputState { if let Some(tool_settings) = preset.tool_settings.as_ref() { self.apply_full_preset_tool_settings(tool_settings); - self.activate_preset_tool(preset.tool); + self.activate_preset_tool_with(measurer, preset.tool); self.sync_current_settings_from_active_tool(); } else { - self.activate_preset_tool(preset.tool); + self.activate_preset_tool_with(measurer, preset.tool); let _ = self.set_color(preset.color.to_color()); if preset.tool.uses_eraser_size() { - let _ = self.set_eraser_size(preset.size); + let _ = self.set_eraser_size_with(measurer, preset.size); } else if !legacy_step_marker_preset { - let _ = self.set_thickness(preset.size); + let _ = self.set_thickness_with(measurer, preset.size); } } @@ -61,7 +66,10 @@ impl InputState { let _ = self.set_font_size(font_size); } if legacy_step_marker_preset { - let _ = self.set_thickness(default_step_marker_size(self.style.current_font_size)); + let _ = self.set_thickness_with( + measurer, + default_step_marker_size(self.style.current_font_size), + ); } if let Some(text_background_enabled) = preset.text_background_enabled && self.style.text_background_enabled != text_background_enabled @@ -150,11 +158,11 @@ impl InputState { self.needs_redraw = true; } - fn activate_preset_tool(&mut self, tool: Tool) { + fn activate_preset_tool_with(&mut self, measurer: &TextMeasurer, tool: Tool) { if tool == Tool::Highlight { - self.set_highlight_tool(true); + self.set_highlight_tool_with_measurer(measurer, true); } else { - self.set_tool_override(Some(tool)); + self.set_tool_override_with(measurer, Some(tool)); } } diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index ed0ef352a..45631a279 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -1,5 +1,6 @@ use super::super::base::{DrawingState, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use crate::draw::{ArrowStyle, BlurStyle, Color, FontDescriptor}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::state::{Toast, ToastPriority}; use crate::input::{ DragBinding, MouseButton, @@ -59,16 +60,26 @@ impl InputState { /// treating every pressure sample as a persisted user preference edit. #[cfg_attr(not(feature = "tablet-input"), allow(dead_code))] pub(crate) fn set_pressure_thickness_for_active_tool(&mut self, thickness: f64) -> f64 { + with_legacy_measurer(|measurer| { + self.set_pressure_thickness_for_active_tool_with(measurer, thickness) + }) + } + + pub(crate) fn set_pressure_thickness_for_active_tool_with( + &mut self, + measurer: &TextMeasurer, + thickness: f64, + ) -> f64 { let tool = self.active_tool(); let clamped = self.style.set_pressure_thickness(tool, thickness); let initial_pressure_sample_changes = self.active_initial_pressure_sample_changes(clamped as f32); if initial_pressure_sample_changes { - self.mark_current_provisional_dirty_full(); + self.mark_current_provisional_dirty_full_with(measurer); } self.update_initial_pressure_sample(clamped); if initial_pressure_sample_changes { - self.mark_current_provisional_dirty_full(); + self.mark_current_provisional_dirty_full_with(measurer); } self.needs_redraw = true; clamped @@ -76,6 +87,17 @@ impl InputState { #[cfg(feature = "tablet-input")] pub(crate) fn replace_active_drawing_pressure_samples(&mut self, thickness: f64) -> bool { + with_legacy_measurer(|measurer| { + self.replace_active_drawing_pressure_samples_with(measurer, thickness) + }) + } + + #[cfg(feature = "tablet-input")] + pub(crate) fn replace_active_drawing_pressure_samples_with( + &mut self, + measurer: &TextMeasurer, + thickness: f64, + ) -> bool { let clamped = thickness.clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS) as f32; let DrawingState::Drawing { point_thicknesses, .. @@ -87,7 +109,7 @@ impl InputState { return false; } - self.mark_current_provisional_dirty_full(); + self.mark_current_provisional_dirty_full_with(measurer); let DrawingState::Drawing { point_thicknesses, .. @@ -97,7 +119,7 @@ impl InputState { }; point_thicknesses.fill(clamped); - self.mark_current_provisional_dirty_full(); + self.mark_current_provisional_dirty_full_with(measurer); self.needs_redraw = true; true } @@ -133,6 +155,10 @@ impl InputState { /// Sets or clears an explicit tool override. Returns true if the tool changed. pub fn set_tool_override(&mut self, tool: Option) -> bool { + with_legacy_measurer(|measurer| self.set_tool_override_with(measurer, tool)) + } + + pub fn set_tool_override_with(&mut self, measurer: &TextMeasurer, tool: Option) -> bool { if self.presenter_mode_active() && matches!( self.presenter_mode_config().tool_behavior, @@ -165,7 +191,7 @@ impl InputState { self.state, DrawingState::Idle | DrawingState::TextInput { .. } ) { - self.cancel_active_interaction(); + self.cancel_active_interaction_with(measurer); } self.sync_current_settings_from_active_tool(); @@ -280,10 +306,18 @@ impl InputState { /// Sets thickness or eraser size depending on the active tool. pub fn set_thickness_for_active_tool(&mut self, value: f64) -> bool { + with_legacy_measurer(|measurer| self.set_thickness_for_active_tool_with(measurer, value)) + } + + pub fn set_thickness_for_active_tool_with( + &mut self, + measurer: &TextMeasurer, + value: f64, + ) -> bool { let changed = if self.active_tool().uses_eraser_size() { - self.set_eraser_size(value) + self.set_eraser_size_with(measurer, value) } else { - self.set_thickness(value) + self.set_thickness_with(measurer, value) }; if changed { self.pending_onboarding_usage.used_thickness_change = true; @@ -293,11 +327,19 @@ impl InputState { /// Nudges thickness or eraser size depending on the active tool. pub fn nudge_thickness_for_active_tool(&mut self, delta: f64) -> bool { + with_legacy_measurer(|measurer| self.nudge_thickness_for_active_tool_with(measurer, delta)) + } + + pub fn nudge_thickness_for_active_tool_with( + &mut self, + measurer: &TextMeasurer, + delta: f64, + ) -> bool { let tool = self.active_tool(); let changed = if tool.uses_eraser_size() { - self.set_eraser_size(self.style.eraser_size + delta) + self.set_eraser_size_with(measurer, self.style.eraser_size + delta) } else { - self.set_thickness(self.thickness_for_tool(tool) + delta) + self.set_thickness_with(measurer, self.thickness_for_tool(tool) + delta) }; if changed { self.pending_onboarding_usage.used_thickness_change = true; @@ -326,6 +368,10 @@ impl InputState { /// Sets the absolute thickness (px), clamped to valid bounds. Returns true if changed. pub fn set_thickness(&mut self, thickness: f64) -> bool { + with_legacy_measurer(|measurer| self.set_thickness_with(measurer, thickness)) + } + + pub fn set_thickness_with(&mut self, measurer: &TextMeasurer, thickness: f64) -> bool { let clamped = thickness.clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); let tool = self.active_tool(); let current = self.style.tool_settings.get(tool).thickness; @@ -333,10 +379,10 @@ impl InputState { return false; } - self.mark_current_provisional_dirty_full(); + self.mark_current_provisional_dirty_full_with(measurer); let changed = self.style.set_thickness(tool, clamped); debug_assert!(changed); - self.mark_current_provisional_dirty_full(); + self.mark_current_provisional_dirty_full_with(measurer); self.preset_slots.clear_active(); self.dirty_tracker.mark_full(); self.needs_redraw = true; @@ -346,14 +392,18 @@ impl InputState { /// Sets the absolute eraser size (px), clamped to valid bounds. Returns true if changed. pub fn set_eraser_size(&mut self, size: f64) -> bool { + with_legacy_measurer(|measurer| self.set_eraser_size_with(measurer, size)) + } + + pub fn set_eraser_size_with(&mut self, measurer: &TextMeasurer, size: f64) -> bool { let clamped = size.clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); if (clamped - self.style.eraser_size).abs() < f64::EPSILON { return false; } - self.mark_current_provisional_dirty_full(); + self.mark_current_provisional_dirty_full_with(measurer); let changed = self.style.set_eraser_size(clamped); debug_assert!(changed); - self.mark_current_provisional_dirty_full(); + self.mark_current_provisional_dirty_full_with(measurer); self.preset_slots.clear_active(); self.dirty_tracker.mark_full(); self.needs_redraw = true; diff --git a/src/input/state/core/toolbar/apply/boards.rs b/src/input/state/core/toolbar/apply/boards.rs index ab0513608..15ae32fde 100644 --- a/src/input/state/core/toolbar/apply/boards.rs +++ b/src/input/state/core/toolbar/apply/boards.rs @@ -1,19 +1,32 @@ +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; use crate::input::state::{Toast, ToastPriority}; impl InputState { pub(super) fn apply_toolbar_board_prev(&mut self) -> bool { - self.switch_board_prev(); + with_legacy_measurer(|measurer| self.apply_toolbar_board_prev_with(measurer)) + } + + pub(super) fn apply_toolbar_board_prev_with(&mut self, measurer: &TextMeasurer) -> bool { + self.switch_board_prev_with_measurer(measurer); true } pub(super) fn apply_toolbar_board_next(&mut self) -> bool { - self.switch_board_next(); + with_legacy_measurer(|measurer| self.apply_toolbar_board_next_with(measurer)) + } + + pub(super) fn apply_toolbar_board_next_with(&mut self, measurer: &TextMeasurer) -> bool { + self.switch_board_next_with_measurer(measurer); true } pub(super) fn apply_toolbar_board_new(&mut self) -> bool { - if self.create_board() { + with_legacy_measurer(|measurer| self.apply_toolbar_board_new_with(measurer)) + } + + pub(super) fn apply_toolbar_board_new_with(&mut self, measurer: &TextMeasurer) -> bool { + if self.create_board_with_measurer(measurer) { true } else { self.push_toast( @@ -26,23 +39,42 @@ impl InputState { } pub(super) fn apply_toolbar_board_delete(&mut self) -> bool { - self.delete_active_board(); + with_legacy_measurer(|measurer| self.apply_toolbar_board_delete_with(measurer)) + } + + pub(super) fn apply_toolbar_board_delete_with(&mut self, measurer: &TextMeasurer) -> bool { + self.delete_active_board_with_measurer(measurer); true } pub(super) fn apply_toolbar_toggle_board_picker(&mut self) -> bool { - self.toggle_board_picker(); + with_legacy_measurer(|measurer| self.apply_toolbar_toggle_board_picker_with(measurer)) + } + + pub(super) fn apply_toolbar_toggle_board_picker_with( + &mut self, + measurer: &TextMeasurer, + ) -> bool { + self.toggle_board_picker_with_measurer(measurer); true } pub(super) fn apply_toolbar_board_duplicate(&mut self) -> bool { - self.duplicate_board(); + with_legacy_measurer(|measurer| self.apply_toolbar_board_duplicate_with(measurer)) + } + + pub(super) fn apply_toolbar_board_duplicate_with(&mut self, measurer: &TextMeasurer) -> bool { + self.duplicate_board_with_measurer(measurer); true } pub(super) fn apply_toolbar_board_rename(&mut self) -> bool { + with_legacy_measurer(|measurer| self.apply_toolbar_board_rename_with(measurer)) + } + + pub(super) fn apply_toolbar_board_rename_with(&mut self, measurer: &TextMeasurer) -> bool { // Open board picker in rename mode for active board - self.toggle_board_picker_quick(); + self.toggle_board_picker_quick_with(measurer); // The board picker handles rename mode internally via its UI true } diff --git a/src/input/state/core/toolbar/apply/pages.rs b/src/input/state/core/toolbar/apply/pages.rs index 32599fc83..043feb10f 100644 --- a/src/input/state/core/toolbar/apply/pages.rs +++ b/src/input/state/core/toolbar/apply/pages.rs @@ -1,10 +1,15 @@ use crate::draw::PageDeleteOutcome; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; use crate::input::state::{Toast, ToastPriority}; impl InputState { pub(super) fn apply_toolbar_page_prev(&mut self) -> bool { - if self.page_prev() { + with_legacy_measurer(|measurer| self.apply_toolbar_page_prev_with(measurer)) + } + + pub(super) fn apply_toolbar_page_prev_with(&mut self, measurer: &TextMeasurer) -> bool { + if self.page_prev_with_measurer(measurer) { true } else { self.push_toast( @@ -17,7 +22,11 @@ impl InputState { } pub(super) fn apply_toolbar_page_next(&mut self) -> bool { - if self.page_next() { + with_legacy_measurer(|measurer| self.apply_toolbar_page_next_with(measurer)) + } + + pub(super) fn apply_toolbar_page_next_with(&mut self, measurer: &TextMeasurer) -> bool { + if self.page_next_with_measurer(measurer) { true } else { self.push_toast( @@ -30,17 +39,32 @@ impl InputState { } pub(super) fn apply_toolbar_page_new(&mut self) -> bool { - self.page_new(); + with_legacy_measurer(|measurer| self.apply_toolbar_page_new_with(measurer)) + } + + pub(super) fn apply_toolbar_page_new_with(&mut self, measurer: &TextMeasurer) -> bool { + self.page_new_with_measurer(measurer); true } pub(super) fn apply_toolbar_page_duplicate(&mut self) -> bool { - self.page_duplicate(); + with_legacy_measurer(|measurer| self.apply_toolbar_page_duplicate_with(measurer)) + } + + pub(super) fn apply_toolbar_page_duplicate_with(&mut self, measurer: &TextMeasurer) -> bool { + self.page_duplicate_with_measurer(measurer); true } pub(super) fn apply_toolbar_page_delete(&mut self) -> bool { - if matches!(self.page_delete(), PageDeleteOutcome::Cleared) { + with_legacy_measurer(|measurer| self.apply_toolbar_page_delete_with(measurer)) + } + + pub(super) fn apply_toolbar_page_delete_with(&mut self, measurer: &TextMeasurer) -> bool { + if matches!( + self.page_delete_with_measurer(measurer), + PageDeleteOutcome::Cleared + ) { self.push_toast( ToastPriority::Info, "page.nav", diff --git a/src/input/state/core/toolbar/apply/tools.rs b/src/input/state/core/toolbar/apply/tools.rs index 13d61ebc2..4369623a9 100644 --- a/src/input/state/core/toolbar/apply/tools.rs +++ b/src/input/state/core/toolbar/apply/tools.rs @@ -1,4 +1,5 @@ use crate::draw::{Color, FontDescriptor}; +use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::{DrawingState, EraserMode, InputState, Tool}; use crate::ui::toolbar::PrecisionEntryTarget; @@ -20,6 +21,17 @@ impl InputState { &mut self, target: PrecisionEntryTarget, value: f64, + ) -> bool { + with_legacy_measurer(|measurer| { + self.apply_toolbar_commit_precision_entry_with(measurer, target, value) + }) + } + + pub(super) fn apply_toolbar_commit_precision_entry_with( + &mut self, + measurer: &TextMeasurer, + target: PrecisionEntryTarget, + value: f64, ) -> bool { let _ = self.cancel_precision_entry(); if !value.is_finite() { @@ -28,7 +40,7 @@ impl InputState { match target { PrecisionEntryTarget::Thickness => { let spec = ToolbarSliderSpec::THICKNESS; - self.apply_toolbar_set_thickness(value.clamp(spec.min, spec.max)) + self.apply_toolbar_set_thickness_with(measurer, value.clamp(spec.min, spec.max)) } PrecisionEntryTarget::FontSize => { let spec = ToolbarSliderSpec::FONT_SIZE; @@ -38,19 +50,27 @@ impl InputState { } pub(super) fn apply_toolbar_select_tool(&mut self, tool: Tool) -> bool { + with_legacy_measurer(|measurer| self.apply_toolbar_select_tool_with(measurer, tool)) + } + + pub(super) fn apply_toolbar_select_tool_with( + &mut self, + measurer: &TextMeasurer, + tool: Tool, + ) -> bool { if matches!(self.state, DrawingState::TextInput { .. }) { - self.cancel_text_input(); + self.cancel_text_input_with(measurer); } let mut changed = if tool == Tool::Highlight { let was_highlight_active = self.highlight_tool_active(); let was_click_highlight_enabled = self.click_highlight_enabled(); - self.set_highlight_tool(true); - let override_changed = self.set_tool_override(Some(tool)); + self.set_highlight_tool_with_measurer(measurer, true); + let override_changed = self.set_tool_override_with(measurer, Some(tool)); override_changed || was_highlight_active != self.highlight_tool_active() || was_click_highlight_enabled != self.click_highlight_enabled() } else { - self.set_tool_override(Some(tool)) + self.set_tool_override_with(measurer, Some(tool)) }; if self.toolbar.top_menu_flyout_open() { changed |= self.toolbar.close_top_menu(); @@ -63,7 +83,15 @@ impl InputState { } pub(super) fn apply_toolbar_set_thickness(&mut self, value: f64) -> bool { - self.set_thickness_for_active_tool(value) + with_legacy_measurer(|measurer| self.apply_toolbar_set_thickness_with(measurer, value)) + } + + pub(super) fn apply_toolbar_set_thickness_with( + &mut self, + measurer: &TextMeasurer, + value: f64, + ) -> bool { + self.set_thickness_for_active_tool_with(measurer, value) } pub(super) fn apply_toolbar_set_marker_opacity(&mut self, value: f64) -> bool { @@ -96,7 +124,15 @@ impl InputState { } pub(super) fn apply_toolbar_set_font_bold(&mut self, bold: bool) -> bool { - self.set_font_bold(bold) + with_legacy_measurer(|measurer| self.apply_toolbar_set_font_bold_with(measurer, bold)) + } + + pub(super) fn apply_toolbar_set_font_bold_with( + &mut self, + measurer: &TextMeasurer, + bold: bool, + ) -> bool { + self.set_font_bold_with(measurer, bold) } pub(super) fn apply_toolbar_set_font_size(&mut self, size: f64) -> bool { @@ -135,7 +171,15 @@ impl InputState { } pub(super) fn apply_toolbar_nudge_thickness(&mut self, delta: f64) -> bool { - self.nudge_thickness_for_active_tool(delta) + with_legacy_measurer(|measurer| self.apply_toolbar_nudge_thickness_with(measurer, delta)) + } + + pub(super) fn apply_toolbar_nudge_thickness_with( + &mut self, + measurer: &TextMeasurer, + delta: f64, + ) -> bool { + self.nudge_thickness_for_active_tool_with(measurer, delta) } pub(super) fn apply_toolbar_nudge_marker_opacity(&mut self, delta: f64) -> bool { @@ -157,11 +201,21 @@ impl InputState { } pub(super) fn apply_toolbar_toggle_all_highlight(&mut self, enable: bool) -> bool { + with_legacy_measurer(|measurer| { + self.apply_toolbar_toggle_all_highlight_with(measurer, enable) + }) + } + + pub(super) fn apply_toolbar_toggle_all_highlight_with( + &mut self, + measurer: &TextMeasurer, + enable: bool, + ) -> bool { // set_highlight_tool already handles both highlight tool and click highlight let currently_active = self.highlight_tool_active() || self.click_highlight_enabled(); let mut changed = false; if currently_active != enable { - self.set_highlight_tool(enable); + self.set_highlight_tool_with_measurer(measurer, enable); self.needs_redraw = true; changed = true; } @@ -182,7 +236,15 @@ impl InputState { } pub(super) fn apply_toolbar_apply_preset(&mut self, slot: usize) -> bool { - self.apply_preset(slot) + with_legacy_measurer(|measurer| self.apply_toolbar_apply_preset_with(measurer, slot)) + } + + pub(super) fn apply_toolbar_apply_preset_with( + &mut self, + measurer: &TextMeasurer, + slot: usize, + ) -> bool { + self.apply_preset_with(measurer, slot) } pub(super) fn apply_toolbar_save_preset(&mut self, slot: usize) -> bool { @@ -204,7 +266,14 @@ impl InputState { } pub(super) fn apply_toolbar_open_color_picker_popup(&mut self) -> bool { - self.open_color_picker_popup(); + with_legacy_measurer(|measurer| self.apply_toolbar_open_color_picker_popup_with(measurer)) + } + + pub(super) fn apply_toolbar_open_color_picker_popup_with( + &mut self, + measurer: &TextMeasurer, + ) -> bool { + self.open_color_picker_popup_with_measurer(measurer); true } @@ -212,13 +281,25 @@ impl InputState { /// it recolors that swatch. An index past the palette is a stale snapshot /// (the palette shrank between render and click) and opens nothing. pub(super) fn apply_toolbar_edit_quick_color(&mut self, index: usize) -> bool { - self.open_color_picker_popup_for_quick_color(index) + with_legacy_measurer(|measurer| self.apply_toolbar_edit_quick_color_with(measurer, index)) + } + + pub(super) fn apply_toolbar_edit_quick_color_with( + &mut self, + measurer: &TextMeasurer, + index: usize, + ) -> bool { + self.open_color_picker_popup_for_quick_color_with_measurer(measurer, index) } /// Open the color picker popup ready for typing: the hex field is /// focused and its content selected, so the first keystroke replaces it. pub(super) fn apply_toolbar_edit_hex_color(&mut self) -> bool { - self.open_color_picker_popup(); + with_legacy_measurer(|measurer| self.apply_toolbar_edit_hex_color_with(measurer)) + } + + pub(super) fn apply_toolbar_edit_hex_color_with(&mut self, measurer: &TextMeasurer) -> bool { + self.open_color_picker_popup_with_measurer(measurer); self.color_picker_popup_set_hex_editing(true); true } diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs index 922792057..ab85e198d 100644 --- a/src/input/state/core/tour.rs +++ b/src/input/state/core/tour.rs @@ -2,6 +2,7 @@ use crate::domain::Action; use crate::input::events::Key; +use crate::input::state::{InputTextResources, with_legacy_text_resources}; use super::base::InputState; @@ -296,15 +297,15 @@ impl InputState { /// Start the guided tour. pub fn start_tour(&mut self) { - crate::ui_text::with_legacy_engine(|engine| self.start_tour_with_engine(engine)) + with_legacy_text_resources(|resources| self.start_tour_with_resources(resources)) } - pub(crate) fn start_tour_with_engine(&mut self, engine: &crate::ui_text::UiTextEngine) { + pub(crate) fn start_tour_with_resources(&mut self, resources: InputTextResources<'_>) { if self.focus_mode_active() { // The tour restores pinned chrome when it ends, so it must begin // from Focus Mode's real baseline rather than nesting underneath // that transient snapshot owner. - self.toggle_focus_mode_with_engine(engine); + self.toggle_focus_mode_with_resources(resources); } self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::Tour); self.tour.start(); @@ -317,11 +318,11 @@ impl InputState { /// starts the overlay regardless of the persisted `tour_shown` flag — and /// so a future replay-specific behavior has a single call site to hang on. pub fn start_tour_replay(&mut self) { - crate::ui_text::with_legacy_engine(|engine| self.start_tour_replay_with_engine(engine)) + with_legacy_text_resources(|resources| self.start_tour_replay_with_resources(resources)) } - pub(crate) fn start_tour_replay_with_engine(&mut self, engine: &crate::ui_text::UiTextEngine) { - self.start_tour_with_engine(engine); + pub(crate) fn start_tour_replay_with_resources(&mut self, resources: InputTextResources<'_>) { + self.start_tour_with_resources(resources); } /// End the tour (skip or complete). diff --git a/src/input/state/core/utility/focus_mode.rs b/src/input/state/core/utility/focus_mode.rs index 65557788f..519d07c51 100644 --- a/src/input/state/core/utility/focus_mode.rs +++ b/src/input/state/core/utility/focus_mode.rs @@ -15,6 +15,7 @@ use super::super::base::InputState; use super::super::modes::FocusModeRestore; use crate::domain::Action; +use crate::input::state::{InputTextResources, with_legacy_text_resources}; use crate::input::state::{Toast, ToastPriority}; const FOCUS_MODE_TOAST_KEY: &str = "focus.mode"; @@ -95,12 +96,12 @@ impl InputState { /// - nothing visible and no snapshot → show everything (rescue arm, so /// the action always has a visible effect). pub(crate) fn toggle_focus_mode(&mut self) { - crate::ui_text::with_legacy_engine(|engine| self.toggle_focus_mode_with_engine(engine)) + with_legacy_text_resources(|resources| self.toggle_focus_mode_with_resources(resources)) } - pub(crate) fn toggle_focus_mode_with_engine(&mut self, engine: &crate::ui_text::UiTextEngine) { + pub(crate) fn toggle_focus_mode_with_resources(&mut self, resources: InputTextResources<'_>) { if self.light_mode_active() { - self.exit_light_mode(); + self.exit_light_mode_with(resources.measurer); } if let Some(restore) = self.modes.end_focus() { self.clear_focus_mode_toast(); @@ -123,7 +124,7 @@ impl InputState { || self.fallback_mode_badge_may_be_active(); if !anything_to_hide { self.clear_all_chrome_recovery_toast(); - self.set_toolbar_visible_with_engine(engine, true); + self.set_toolbar_visible_with_engine(resources.ui_engine, true); self.ui_visibility.show_status_bar = true; self.ui_visibility.show_floating_badge = true; self.ui_visibility.show_zoom_chip = true; diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index cd624bc98..20694ee4f 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -150,10 +150,6 @@ impl InputState { } /// Cancels the current text input session and restores any edited shape. - pub(crate) fn cancel_text_input(&mut self) { - with_legacy_measurer(|measurer| self.cancel_text_input_with(measurer)) - } - pub(crate) fn cancel_text_input_with(&mut self, measurer: &TextMeasurer) { self.cancel_text_edit_with(measurer); self.end_text_input_session(); @@ -338,12 +334,13 @@ mod tests { #[test] fn cancel_text_input_clears_wrap_width_and_returns_to_idle() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); state.style.text_wrap_width = Some(240); state.state = DrawingState::text_input(10, 20, "hello".to_string()); state.needs_redraw = false; - state.cancel_text_input(); + state.cancel_text_input_with(&measurer); assert!(matches!(state.state, DrawingState::Idle)); assert!(state.style.text_wrap_width.is_none()); @@ -352,6 +349,7 @@ mod tests { #[test] fn cancel_text_input_releases_an_active_block_drag() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); state.state = DrawingState::text_input(10, 20, "hello".to_string()); state.modifiers.alt = true; @@ -359,7 +357,7 @@ mod tests { assert!(state.text_block_drag_active()); assert!(state.has_active_pointer_interaction()); - state.cancel_text_input(); + state.cancel_text_input_with(&measurer); assert!(!state.text_block_drag_active()); assert!(!state.has_active_pointer_interaction()); diff --git a/src/input/state/core/utility/light_mode.rs b/src/input/state/core/utility/light_mode.rs index 12322b04e..41d618eb4 100644 --- a/src/input/state/core/utility/light_mode.rs +++ b/src/input/state/core/utility/light_mode.rs @@ -1,6 +1,8 @@ use super::super::base::{DesktopEnvironment, InputState, ShellMode}; use super::super::modes::LightModeRestore; use crate::domain::Action; +use crate::draw::TextMeasurer; +use crate::input::state::{InputTextResources, with_legacy_text_resources}; use crate::input::state::{Toast, ToastPriority}; use crate::input::tool::Tool; @@ -53,15 +55,27 @@ impl InputState { } pub(crate) fn toggle_light_mode(&mut self) -> bool { - crate::ui_text::with_legacy_engine(|engine| self.toggle_light_mode_with_engine(engine)) + with_legacy_text_resources(|resources| self.toggle_light_mode_with_resources(resources)) } pub(crate) fn toggle_light_mode_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, + ) -> bool { + crate::draw::with_legacy_measurer(|measurer| { + self.toggle_light_mode_with_resources(InputTextResources { + measurer, + ui_engine: engine, + }) + }) + } + + pub(crate) fn toggle_light_mode_with_resources( + &mut self, + resources: InputTextResources<'_>, ) -> bool { if self.light_mode_active() { - self.exit_light_mode(); + self.exit_light_mode_with(resources.measurer); } else { if !self.light_mode_supported() { self.push_toast( @@ -72,32 +86,44 @@ impl InputState { self.needs_redraw = true; return false; } - self.enter_light_mode_with_engine(engine, false); + self.enter_light_mode_with_resources(resources, false); } self.light_mode_active() } pub fn toggle_light_mode_drawing(&mut self) -> bool { - crate::ui_text::with_legacy_engine(|engine| { - self.toggle_light_mode_drawing_with_engine(engine) + with_legacy_text_resources(|resources| { + self.toggle_light_mode_drawing_with_resources(resources) }) } pub(crate) fn toggle_light_mode_drawing_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, + ) -> bool { + crate::draw::with_legacy_measurer(|measurer| { + self.toggle_light_mode_drawing_with_resources(InputTextResources { + measurer, + ui_engine: engine, + }) + }) + } + + pub(crate) fn toggle_light_mode_drawing_with_resources( + &mut self, + resources: InputTextResources<'_>, ) -> bool { let drawing = if self.light_mode_active() { !self.light_mode_drawing_active() } else { true }; - self.set_light_mode_drawing_with_engine(engine, drawing) + self.set_light_mode_drawing_with_resources(resources, drawing) } pub fn set_light_mode_drawing(&mut self, drawing: bool) -> bool { - crate::ui_text::with_legacy_engine(|engine| { - self.set_light_mode_drawing_with_engine(engine, drawing) + with_legacy_text_resources(|resources| { + self.set_light_mode_drawing_with_resources(resources, drawing) }) } @@ -105,6 +131,22 @@ impl InputState { &mut self, engine: &crate::ui_text::UiTextEngine, drawing: bool, + ) -> bool { + crate::draw::with_legacy_measurer(|measurer| { + self.set_light_mode_drawing_with_resources( + InputTextResources { + measurer, + ui_engine: engine, + }, + drawing, + ) + }) + } + + pub(crate) fn set_light_mode_drawing_with_resources( + &mut self, + resources: InputTextResources<'_>, + drawing: bool, ) -> bool { if !self.light_mode_active() { if drawing { @@ -117,7 +159,7 @@ impl InputState { self.needs_redraw = true; return false; } - self.enter_light_mode_with_engine(engine, true); + self.enter_light_mode_with_resources(resources, true); } return self.light_mode_drawing_active(); } @@ -126,7 +168,7 @@ impl InputState { return self.light_mode_drawing_active(); } - self.cancel_active_interaction(); + self.cancel_active_interaction_with(resources.measurer); self.modes.set_light_drawing(drawing); let message = if drawing { "Light Mode drawing" @@ -139,18 +181,18 @@ impl InputState { self.light_mode_drawing_active() } - pub(crate) fn exit_light_mode(&mut self) { + pub(crate) fn exit_light_mode_with(&mut self, measurer: &TextMeasurer) { if !self.light_mode_active() { return; } - self.cancel_active_interaction(); + self.cancel_active_interaction_with(measurer); if let Some(restore) = self.modes.end_light() { self.ui_visibility.show_status_bar = restore.show_status_bar(); self.ui_visibility.show_tool_preview = restore.show_tool_preview(); self.restore_toolbar_visibility(restore.toolbar_visibility()); - self.set_tool_override(restore.tool_override()); + self.set_tool_override_with(measurer, restore.tool_override()); if self.click_highlight_enabled() != restore.click_highlight_enabled() { self.toggle_click_highlight(); } @@ -165,26 +207,26 @@ impl InputState { self.needs_redraw = true; } - fn enter_light_mode_with_engine( + fn enter_light_mode_with_resources( &mut self, - engine: &crate::ui_text::UiTextEngine, + resources: InputTextResources<'_>, drawing: bool, ) { if self.focus_mode_active() { - self.toggle_focus_mode_with_engine(engine); + self.toggle_focus_mode_with_resources(resources); } if self.presenter_mode_active() { - self.toggle_presenter_mode_with_engine(engine); + self.toggle_presenter_mode_with_resources(resources); } - self.cancel_active_interaction(); + self.cancel_active_interaction_with(resources.measurer); self.close_context_menu(); self.close_properties_panel(); self.close_radial_menu(); self.close_board_picker(); self.close_color_picker_popup(false); if self.help_overlay.visible { - self.toggle_help_overlay(); + self.close_help_overlay(); } let restore = LightModeRestore::capture( @@ -198,7 +240,7 @@ impl InputState { self.ui_visibility.show_status_bar = false; self.ui_visibility.show_tool_preview = false; self.hide_toolbar_visibility(); - self.set_tool_override(Some(Tool::Pen)); + self.set_tool_override_with(resources.measurer, Some(Tool::Pen)); if self.click_highlight_forced_in_light_mode() && !self.click_highlight_enabled() { self.toggle_click_highlight(); } diff --git a/src/input/state/core/utility/presenter_mode.rs b/src/input/state/core/utility/presenter_mode.rs index d3c365e50..74fa00637 100644 --- a/src/input/state/core/utility/presenter_mode.rs +++ b/src/input/state/core/utility/presenter_mode.rs @@ -10,6 +10,7 @@ use super::super::base::InputState; use super::super::modes::PresenterRestore; use crate::domain::Action; +use crate::input::state::{InputTextResources, with_legacy_text_resources}; use crate::input::state::{Toast, ToastPriority}; use crate::input::tool::Tool; @@ -65,21 +66,21 @@ impl InputState { } pub(crate) fn toggle_presenter_mode(&mut self) -> bool { - crate::ui_text::with_legacy_engine(|engine| self.toggle_presenter_mode_with_engine(engine)) + with_legacy_text_resources(|resources| self.toggle_presenter_mode_with_resources(resources)) } - pub(crate) fn toggle_presenter_mode_with_engine( + pub(crate) fn toggle_presenter_mode_with_resources( &mut self, - engine: &crate::ui_text::UiTextEngine, + resources: InputTextResources<'_>, ) -> bool { if self.presenter_mode_active() { - self.stop_presenter_mode() + self.stop_presenter_mode(resources) } else { - self.start_presenter_mode(engine) + self.start_presenter_mode(resources) } } - fn stop_presenter_mode(&mut self) -> bool { + fn stop_presenter_mode(&mut self, resources: InputTextResources<'_>) -> bool { let config = self.presenter_mode_config().clone(); if let Some(restore) = self.modes.end_presenter() { if let Some(value) = restore.show_status_bar() { @@ -92,7 +93,7 @@ impl InputState { self.restore_toolbar_visibility(snapshot); } if let Some(value) = restore.tool_override() { - self.set_tool_override(value); + self.set_tool_override_with(resources.measurer, value); } if let Some(value) = restore.click_highlight_enabled() && self.click_highlight_enabled() != value @@ -115,24 +116,24 @@ impl InputState { self.presenter_mode_active() } - fn start_presenter_mode(&mut self, engine: &crate::ui_text::UiTextEngine) -> bool { + fn start_presenter_mode(&mut self, resources: InputTextResources<'_>) -> bool { let config = self.presenter_mode_config().clone(); if self.light_mode_active() { - self.exit_light_mode(); + self.exit_light_mode_with(resources.measurer); } if self.focus_mode_active() { // Restore Focus Mode's snapshot before Presenter Mode captures its // own chrome baseline. This keeps the two transient owners from // nesting and lets micro-toolbar presenter policy operate on the // real pre-Focus visibility. - self.toggle_focus_mode_with_engine(engine); + self.toggle_focus_mode_with_resources(resources); } if config.close_help_overlay && self.help_overlay.visible { - self.toggle_help_overlay(); + self.close_help_overlay(); } - self.cancel_active_interaction(); + self.cancel_active_interaction_with(resources.measurer); let restore = PresenterRestore::capture( &config, self.ui_visibility.show_status_bar, @@ -156,7 +157,7 @@ impl InputState { crate::config::PresenterToolbarMode::Micro => { // The top strip stays up as the micro chip. self.set_top_display_mode_with_engine( - engine, + resources.ui_engine, crate::config::TopDisplayMode::Micro, ); } @@ -166,7 +167,7 @@ impl InputState { config.tool_behavior, crate::config::PresenterToolBehavior::Keep ) { - self.set_tool_override(Some(Tool::Highlight)); + self.set_tool_override_with(resources.measurer, Some(Tool::Highlight)); } if config.enable_click_highlight && !self.click_highlight_enabled() { self.toggle_click_highlight(); diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 0f6a17165..8cb6ddf4c 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -7,12 +7,15 @@ pub(crate) mod interaction; mod mouse; mod render; mod spotlight; +mod text_resources; pub(crate) use core::{ DrawingStyle, HistoryLimits, IdleHandle, SpotlightMagnificationTrack, TopMenuState, }; pub(crate) use core::{InputEffect, InputEffectDrain}; pub(in crate::input::state) use spotlight::SpotlightWheelGesture; pub(crate) use spotlight::{SpotlightFrameRegions, SpotlightWheelClaim, SpotlightWheelOutcome}; +pub(crate) use text_resources::InputTextResources; +pub(in crate::input::state) use text_resources::with_legacy_text_resources; #[cfg(test)] mod tests; diff --git a/src/input/state/tests/delete_restore.rs b/src/input/state/tests/delete_restore.rs index 89dc29714..d5f1885ef 100644 --- a/src/input/state/tests/delete_restore.rs +++ b/src/input/state/tests/delete_restore.rs @@ -284,13 +284,14 @@ fn page_delete_on_last_page_clears_shapes_without_removing_page() { #[test] fn pending_board_delete_survives_active_drift_and_deletes_original_board() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.switch_board(BOARD_ID_BLACKBOARD); let requested_at = Instant::now(); - state.delete_active_board_at(requested_at); + state.delete_active_board_at_with_measurer(&measurer, requested_at); state.switch_board("whiteboard"); - state.delete_active_board_at(requested_at + Duration::from_millis(1)); + state.delete_active_board_at_with_measurer(&measurer, requested_at + Duration::from_millis(1)); assert_eq!(state.board_id(), "whiteboard"); assert!(!state.boards.has_board(BOARD_ID_BLACKBOARD)); @@ -299,14 +300,15 @@ fn pending_board_delete_survives_active_drift_and_deletes_original_board() { #[test] fn board_rename_does_not_stale_pending_board_delete() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.switch_board(BOARD_ID_BLACKBOARD); let requested_at = Instant::now(); - state.delete_active_board_at(requested_at); + state.delete_active_board_at_with_measurer(&measurer, requested_at); let index = board_index(&state, BOARD_ID_BLACKBOARD); assert!(state.set_board_name(index, "Renamed Board".to_string())); - state.delete_active_board_at(requested_at + Duration::from_millis(1)); + state.delete_active_board_at_with_measurer(&measurer, requested_at + Duration::from_millis(1)); assert!(!state.boards.has_board(BOARD_ID_BLACKBOARD)); assert_eq!( @@ -317,6 +319,7 @@ fn board_rename_does_not_stale_pending_board_delete() { #[test] fn page_content_edit_does_not_stale_pending_page_delete() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let board = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); @@ -334,7 +337,10 @@ fn page_content_edit_does_not_stale_pending_page_delete() { thick: state.style.current_thickness, }); assert_eq!( - state.delete_active_page_at(requested_at + Duration::from_millis(1)), + state.delete_active_page_at_with_measurer( + &measurer, + requested_at + Duration::from_millis(1) + ), PageDeleteOutcome::Removed ); @@ -343,6 +349,7 @@ fn page_content_edit_does_not_stale_pending_page_delete() { #[test] fn pending_page_delete_survives_active_board_drift_and_deletes_original_page() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); @@ -350,12 +357,15 @@ fn pending_page_delete_survives_active_board_drift_and_deletes_original_page() { let requested_at = Instant::now(); assert_eq!( - state.delete_active_page_at(requested_at), + state.delete_active_page_at_with_measurer(&measurer, requested_at), PageDeleteOutcome::Pending ); state.switch_board(BOARD_ID_TRANSPARENT); assert_eq!( - state.delete_active_page_at(requested_at + Duration::from_millis(1)), + state.delete_active_page_at_with_measurer( + &measurer, + requested_at + Duration::from_millis(1) + ), PageDeleteOutcome::Removed ); @@ -391,6 +401,7 @@ fn pending_page_delete_survives_active_board_drift_and_deletes_original_page() { #[test] fn stale_active_page_delete_confirmation_does_not_cancel_active_interaction() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let board = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); @@ -398,7 +409,7 @@ fn stale_active_page_delete_confirmation_does_not_cancel_active_interaction() { let requested_at = Instant::now(); assert_eq!( - state.delete_active_page_at(requested_at), + state.delete_active_page_at_with_measurer(&measurer, requested_at), PageDeleteOutcome::Pending ); assert_eq!( @@ -415,7 +426,10 @@ fn stale_active_page_delete_confirmation_does_not_cancel_active_interaction() { state.begin_pointer_drag(MouseButton::Left, None); assert_eq!( - state.delete_active_page_at(requested_at + Duration::from_millis(1)), + state.delete_active_page_at_with_measurer( + &measurer, + requested_at + Duration::from_millis(1) + ), PageDeleteOutcome::Pending ); @@ -426,6 +440,7 @@ fn stale_active_page_delete_confirmation_does_not_cancel_active_interaction() { #[test] fn stale_board_panel_page_delete_confirmation_does_not_cancel_active_interaction() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let board = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); @@ -433,7 +448,7 @@ fn stale_board_panel_page_delete_confirmation_does_not_cancel_active_interaction let requested_at = Instant::now(); assert_eq!( - state.delete_page_in_board_at(board, 0, requested_at), + state.delete_page_in_board_at_with_measurer(&measurer, board, 0, requested_at), PageDeleteOutcome::Pending ); assert_eq!( @@ -452,7 +467,12 @@ fn stale_board_panel_page_delete_confirmation_does_not_cancel_active_interaction state.begin_pointer_drag(MouseButton::Left, None); assert_eq!( - state.delete_page_in_board_at(board, 0, requested_at + Duration::from_millis(1)), + state.delete_page_in_board_at_with_measurer( + &measurer, + board, + 0, + requested_at + Duration::from_millis(1) + ), PageDeleteOutcome::Pending ); diff --git a/src/input/state/tests/status_hud/engine_mutations.rs b/src/input/state/tests/status_hud/engine_mutations.rs index 81f7bcb2d..fd1b88db8 100644 --- a/src/input/state/tests/status_hud/engine_mutations.rs +++ b/src/input/state/tests/status_hud/engine_mutations.rs @@ -92,46 +92,51 @@ fn assert_same_chrome(actual: &InputState, expected: &InputState) { #[test] fn explicit_mode_cycles_match_legacy_without_an_intervening_frame() { let engine = UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }; let mut explicit = seeded(&engine); let mut legacy = seeded(&UiTextEngine::default()); // Enter/leave Focus, then make Light's drawing-entry path leave Focus. - explicit.toggle_focus_mode_with_engine(&engine); + explicit.toggle_focus_mode_with_resources(resources); legacy.toggle_focus_mode(); assert_same_chrome(&explicit, &legacy); assert!(explicit.focus_mode_active()); - assert!(explicit.set_light_mode_drawing_with_engine(&engine, true)); + assert!(explicit.set_light_mode_drawing_with_resources(resources, true)); assert!(legacy.set_light_mode_drawing(true)); assert_same_chrome(&explicit, &legacy); assert!(explicit.light_mode_active()); assert!(!explicit.focus_mode_active()); assert_eq!( - explicit.toggle_light_mode_drawing_with_engine(&engine), + explicit.toggle_light_mode_drawing_with_resources(resources), legacy.toggle_light_mode_drawing() ); assert_same_chrome(&explicit, &legacy); assert_eq!( - explicit.toggle_light_mode_with_engine(&engine), + explicit.toggle_light_mode_with_resources(resources), legacy.toggle_light_mode() ); assert_same_chrome(&explicit, &legacy); assert!(!explicit.light_mode_active()); // Presenter and Light replace each other's numeric visibility snapshots. assert_eq!( - explicit.toggle_presenter_mode_with_engine(&engine), + explicit.toggle_presenter_mode_with_resources(resources), legacy.toggle_presenter_mode() ); assert_same_chrome(&explicit, &legacy); assert!(explicit.presenter_mode_active()); assert_eq!( - explicit.toggle_light_mode_with_engine(&engine), + explicit.toggle_light_mode_with_resources(resources), legacy.toggle_light_mode() ); assert_same_chrome(&explicit, &legacy); assert!(!explicit.presenter_mode_active()); - explicit.toggle_focus_mode_with_engine(&engine); + explicit.toggle_focus_mode_with_resources(resources); legacy.toggle_focus_mode(); assert_same_chrome(&explicit, &legacy); - explicit.start_tour_replay_with_engine(&engine); + explicit.start_tour_replay_with_resources(resources); legacy.start_tour_replay(); assert_same_chrome(&explicit, &legacy); assert!(explicit.tour.is_active()); @@ -141,6 +146,11 @@ fn explicit_mode_cycles_match_legacy_without_an_intervening_frame() { #[test] fn explicit_focus_rescue_and_display_cycle_refresh_saved_geometry() { let engine = UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }; let mut input = seeded(&engine); input.set_toolbar_visible_with_engine(&engine, false); input.ui_visibility.show_status_bar = false; @@ -148,7 +158,7 @@ fn explicit_focus_rescue_and_display_cycle_refresh_saved_geometry() { input.ui_visibility.show_zoom_chip = false; input.refresh_status_hud_layout_with_engine(&engine); assert!(!input.focus_mode_active()); - input.toggle_focus_mode_with_engine(&engine); + input.toggle_focus_mode_with_resources(resources); assert!(input.toolbar_visible(), "Focus rescues fully hidden chrome"); assert!(input.ui_visibility.show_status_bar); assert!(!input.focus_mode_active()); diff --git a/src/input/state/tests/text_input/editing.rs b/src/input/state/tests/text_input/editing.rs index 555458893..755e8c523 100644 --- a/src/input/state/tests/text_input/editing.rs +++ b/src/input/state/tests/text_input/editing.rs @@ -423,6 +423,7 @@ fn delayed_paste_replaces_the_selection_captured_at_invocation() { #[test] fn paste_generation_does_not_match_a_later_text_edit() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.handle_action(Action::EnterTextMode); let first = state @@ -430,7 +431,7 @@ fn paste_generation_does_not_match_a_later_text_edit() { .generation(&state.state) .expect("text edit is active"); - state.cancel_text_input(); + state.cancel_text_input_with(&measurer); state.handle_action(Action::EnterTextMode); assert!( diff --git a/src/input/state/text_resources.rs b/src/input/state/text_resources.rs new file mode 100644 index 000000000..f252362a1 --- /dev/null +++ b/src/input/state/text_resources.rs @@ -0,0 +1,30 @@ +//! Text services borrowed for one input operation. + +use crate::draw::TextMeasurer; +use crate::ui_text::UiTextEngine; + +/// Keeps shape geometry and UI sizing on their respective persistent owners. +/// This borrow is operation-local and must not be retained in input values, +/// editor state, shapes, or snapshots. +#[derive(Clone, Copy)] +pub(crate) struct InputTextResources<'a> { + pub(crate) measurer: &'a TextMeasurer, + pub(crate) ui_engine: &'a UiTextEngine, +} + +/// Temporary adapter for callers whose input roots have not migrated yet. +pub(in crate::input::state) fn with_legacy_text_resources( + operation: impl FnOnce(InputTextResources<'_>) -> R, +) -> R { + crate::draw::with_legacy_measurer(|measurer| { + crate::ui_text::with_legacy_engine(|ui_engine| { + operation(InputTextResources { + measurer, + ui_engine, + }) + }) + }) +} + +#[cfg(test)] +mod tests; diff --git a/src/input/state/text_resources/tests.rs b/src/input/state/text_resources/tests.rs new file mode 100644 index 000000000..df285a6f9 --- /dev/null +++ b/src/input/state/text_resources/tests.rs @@ -0,0 +1,91 @@ +use super::*; +use crate::draw::{FontDescriptor, Shape, ShapeId}; +use crate::input::{DrawingState, InputState, Tool}; + +fn editing_state(measurer: &TextMeasurer) -> (InputState, ShapeId, Shape) { + let mut state = crate::input::state::test_support::make_test_input_state(); + state.view.set_screen_dimensions(1000, 800); + let original = Shape::Text { + x: 100, + y: 200, + text: "Wrapped original שלום".into(), + size: 24.0, + color: crate::draw::RED, + font_descriptor: FontDescriptor::default(), + background_enabled: true, + wrap_width: Some(140), + }; + let id = state.boards.active_frame_mut().add_shape(original.clone()); + state.set_selection(vec![id]); + assert!(state.edit_selected_text_with(measurer)); + assert!( + matches!(&state.boards.active_frame().shape(id).unwrap().shape, Shape::Text { text, .. } if text.is_empty()) + ); + (state, id, original) +} + +fn assert_restored(state: &InputState, id: ShapeId, original: &Shape, measurer: &TextMeasurer) { + assert!(matches!(state.state, DrawingState::Idle)); + let shape = &state.boards.active_frame().shape(id).unwrap().shape; + assert_eq!( + serde_json::to_value(shape).unwrap(), + serde_json::to_value(original).unwrap() + ); + assert_eq!( + shape.bounding_box_with(measurer), + original.bounding_box_with(measurer) + ); +} + +#[test] +fn explicit_page_transition_restores_text_before_changing_the_active_frame() { + let measurer = TextMeasurer::default(); + let (mut state, id, original) = editing_state(&measurer); + state.page_new_with_measurer(&measurer); + assert!(matches!(state.state, DrawingState::Idle)); + assert_eq!(state.boards.active_page_index(), 1); + assert!(state.boards.active_frame().shapes.is_empty()); + assert!(state.switch_to_page_with_measurer(&measurer, 0)); + assert_restored(&state, id, &original, &measurer); + assert!(state.is_session_dirty()); + assert!(!state.take_dirty_regions().is_empty()); +} + +#[test] +fn explicit_popup_openers_and_screen_modal_cancel_the_live_editor() { + let measurer = TextMeasurer::default(); + for kind in 0..3 { + let (mut state, id, original) = editing_state(&measurer); + match kind { + 0 => state.open_color_picker_popup_with_measurer(&measurer), + 1 => state.open_board_picker_with_measurer(&measurer), + _ => state.prepare_for_screen_modal_with_measurer(&measurer), + } + assert_restored(&state, id, &original, &measurer); + assert_eq!(state.is_color_picker_popup_open(), kind == 0); + assert_eq!(state.is_board_picker_open(), kind == 1); + } +} + +#[test] +fn explicit_light_and_presenter_switches_keep_restored_text_and_mode_policy() { + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let (mut state, id, original) = editing_state(&measurer); + state.compositor_capabilities.layer_shell = true; + assert!(state.toggle_light_mode_with_resources(resources)); + assert_restored(&state, id, &original, &measurer); + assert!(state.light_mode_passthrough()); + assert_eq!(state.tool_override(), Some(Tool::Pen)); + assert!(state.toggle_presenter_mode_with_resources(resources)); + assert!(!state.light_mode_active()); + assert!(state.presenter_mode_active()); + assert_restored(&state, id, &original, &measurer); + assert!(!state.toggle_presenter_mode_with_resources(resources)); + assert!(!state.presenter_mode_active()); + assert_restored(&state, id, &original, &measurer); +} From 1318ec7442325cdd8ec56d7f347c16e64c67a7bd Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:31:16 +0200 Subject: [PATCH 22/42] refactor(text): share resources across guidance overlays --- .../wayland/state/render/canvas/overlays.rs | 1 + src/backend/wayland/state/render/ui.rs | 25 ++++++- src/capture/tests/fixtures.rs | 5 +- src/ui.rs | 4 +- src/ui/onboarding_card.rs | 43 +++++++---- src/ui/precision_entry.rs | 34 +++++++-- src/ui/precision_entry/tests.rs | 74 +++++++++++++++++++ src/ui/spotlight_control.rs | 55 ++++++++++++-- src/ui/tour.rs | 20 +++-- src/ui_text.rs | 11 --- 10 files changed, 226 insertions(+), 46 deletions(-) create mode 100644 src/ui/precision_entry/tests.rs diff --git a/src/backend/wayland/state/render/canvas/overlays.rs b/src/backend/wayland/state/render/canvas/overlays.rs index 14a98b1a1..eaa8ee2de 100644 --- a/src/backend/wayland/state/render/canvas/overlays.rs +++ b/src/backend/wayland/state/render/canvas/overlays.rs @@ -96,6 +96,7 @@ impl WaylandState { }) .flatten(); crate::ui::render_spotlight_magnification_control( + self.render.ui_text(), ctx, control.track, control.magnification, diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index bb2fb3be6..b4f89d739 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -282,7 +282,14 @@ impl WaylandState { self.inline_top_base_x() + top_offset.0, self.inline_top_base_y() + top_offset.1 + top_h as f64 + 8.0, ); - crate::ui::render_precision_entry_popup(ctx, &self.input_state, width, height, anchor); + crate::ui::render_precision_entry_popup_with_engine( + self.render.ui_text(), + ctx, + &self.input_state, + width, + height, + anchor, + ); } fn render_radial_menu_and_feedback( @@ -412,7 +419,13 @@ impl WaylandState { return; } if let Some(card) = self.first_run_onboarding_card() { - crate::ui::render_onboarding_card(ctx, width, height, &card); + crate::ui::render_onboarding_card_with_engine( + self.render.ui_text(), + ctx, + width, + height, + &card, + ); } crate::ui::render_command_palette_with_engine( self.render.ui_text(), @@ -421,7 +434,13 @@ impl WaylandState { width, height, ); - crate::ui::render_tour(ctx, &self.input_state, width, height); + crate::ui::render_tour_with_engine( + self.render.ui_text(), + ctx, + &self.input_state, + width, + height, + ); } /// The scan band while recognition runs, then the outcome card. The card diff --git a/src/capture/tests/fixtures.rs b/src/capture/tests/fixtures.rs index c15e5e523..4145cbea4 100644 --- a/src/capture/tests/fixtures.rs +++ b/src/capture/tests/fixtures.rs @@ -78,9 +78,10 @@ impl CaptureClipboard for MockClipboard { } pub(super) fn create_placeholder_image() -> Vec { - use crate::ui_text::{UiTextStyle, draw_text_baseline}; + use crate::ui_text::{UiTextEngine, UiTextStyle}; use cairo::{Context, FontSlant, FontWeight, Format, ImageSurface}; + let engine = UiTextEngine::default(); let surface = ImageSurface::create(Format::ARgb32, 100, 100).unwrap(); let ctx = Context::new(&surface).unwrap(); @@ -88,7 +89,7 @@ pub(super) fn create_placeholder_image() -> Vec { ctx.paint().unwrap(); ctx.set_source_rgb(1.0, 1.0, 1.0); - draw_text_baseline( + engine.draw_baseline( &ctx, UiTextStyle { family: "Sans", diff --git a/src/ui.rs b/src/ui.rs index 4d10bb010..0540ad767 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -16,7 +16,6 @@ mod ocr_scan; mod onboarding_card; mod precision_entry; mod primitives; -pub(crate) use primitives::{draw_rounded_rect, text_extents_for}; mod properties_panel; mod radial_menu; mod render_context; @@ -62,8 +61,10 @@ pub(crate) use measure_badge::{ pub(crate) use ocr_scan::{ ocr_scan_geometry, render_ocr_scan_result, render_ocr_scan_still, render_ocr_scan_sweep, }; +pub(crate) use onboarding_card::render_onboarding_card_with_engine; pub use onboarding_card::{OnboardingCard, OnboardingChecklistItem, render_onboarding_card}; pub use precision_entry::render_precision_entry_popup; +pub(crate) use precision_entry::render_precision_entry_popup_with_engine; /// Shared measured-text trimming, also used by the standalone about dialog. pub(crate) use primitives::ellipsize_to_fit_with_engine; pub(crate) use primitives::{checkerboard_behind, draw_pill}; @@ -103,6 +104,7 @@ pub(crate) use toasts::{ render_ui_toast_with_engine, ui_toast_geometry_with_engine, }; pub use tour::render_tour; +pub(crate) use tour::render_tour_with_engine; #[cfg(test)] #[path = "ui/tests/theme_compatibility.rs"] diff --git a/src/ui/onboarding_card.rs b/src/ui/onboarding_card.rs index 3fcae4789..9f65e75d9 100644 --- a/src/ui/onboarding_card.rs +++ b/src/ui/onboarding_card.rs @@ -1,6 +1,6 @@ use super::primitives::draw_rounded_rect; use super::theme::{self, Rgba, overlay}; -use crate::ui_text::{UiTextStyle, draw_text_baseline, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; pub struct OnboardingChecklistItem { pub label: String, @@ -60,6 +60,16 @@ pub fn render_onboarding_card( width: u32, height: u32, card: &OnboardingCard, +) { + render_onboarding_card_with_engine(&UiTextEngine::default(), ctx, width, height, card); +} + +pub(crate) fn render_onboarding_card_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + width: u32, + height: u32, + card: &OnboardingCard, ) { let margin = CARD_MARGIN * CARD_TYPE_SCALE; let card_max_width = CARD_MAX_WIDTH * CARD_TYPE_SCALE; @@ -112,7 +122,8 @@ pub fn render_onboarding_card( size: 11.0 * CARD_TYPE_SCALE, }; - let body_height = text_layout(ctx, body_style, &card.body, Some(content_w)) + let body_height = engine + .layout(ctx, body_style, &card.body, Some(content_w)) .ink_extents() .height() .max(body_style.size); @@ -133,10 +144,10 @@ pub fn render_onboarding_card( let mut cursor_y = y + card_padding; theme::set_color(ctx, TEXT_EYEBROW); - draw_text_baseline( + engine.draw_baseline( ctx, eyebrow_style, - &fit_text(ctx, &card.eyebrow, eyebrow_style, content_w), + &fit_text(engine, ctx, &card.eyebrow, eyebrow_style, content_w), content_x, cursor_y + 12.0 * CARD_TYPE_SCALE, None, @@ -144,10 +155,10 @@ pub fn render_onboarding_card( cursor_y += EYEBROW_CONTENT_HEIGHT * CARD_TYPE_SCALE; theme::set_color(ctx, overlay::TEXT_ACTIVE); - draw_text_baseline( + engine.draw_baseline( ctx, title_style, - &fit_text(ctx, &card.title, title_style, content_w), + &fit_text(engine, ctx, &card.title, title_style, content_w), content_x, cursor_y + 20.0 * CARD_TYPE_SCALE, None, @@ -155,7 +166,7 @@ pub fn render_onboarding_card( cursor_y += TITLE_CONTENT_HEIGHT * CARD_TYPE_SCALE; theme::set_color(ctx, TEXT_BODY); - draw_text_baseline( + engine.draw_baseline( ctx, body_style, &card.body, @@ -190,10 +201,10 @@ pub fn render_onboarding_card( theme::set_color(ctx, overlay::TEXT_SECONDARY); let item_x = content_x + item_dot_size + 8.0 * CARD_TYPE_SCALE; let item_w = content_w - item_dot_size - 8.0 * CARD_TYPE_SCALE; - draw_text_baseline( + engine.draw_baseline( ctx, item_style, - &fit_text(ctx, &item.label, item_style, item_w), + &fit_text(engine, ctx, &item.label, item_style, item_w), item_x, cursor_y + text_offset_y + item_style.size, None, @@ -202,21 +213,27 @@ pub fn render_onboarding_card( } theme::set_color(ctx, TEXT_FOOTER); - draw_text_baseline( + engine.draw_baseline( ctx, footer_style, - &fit_text(ctx, &card.footer, footer_style, content_w), + &fit_text(engine, ctx, &card.footer, footer_style, content_w), content_x, y + card_height - card_padding + 2.0 * CARD_TYPE_SCALE, None, ); } -fn fit_text(ctx: &cairo::Context, text: &str, style: UiTextStyle<'_>, max_width: f64) -> String { +fn fit_text( + engine: &UiTextEngine, + ctx: &cairo::Context, + text: &str, + style: UiTextStyle<'_>, + max_width: f64, +) -> String { if text.is_empty() || max_width <= 0.0 { return String::new(); } - let text_width = |s: &str| text_layout(ctx, style, s, None).ink_extents().width(); + let text_width = |s: &str| engine.layout(ctx, style, s, None).ink_extents().width(); if text_width(text) <= max_width { return text.to_string(); } diff --git a/src/ui/precision_entry.rs b/src/ui/precision_entry.rs index c69bd710a..4ce6a80a3 100644 --- a/src/ui/precision_entry.rs +++ b/src/ui/precision_entry.rs @@ -7,8 +7,8 @@ //! is keyboard-only, so it renders no buttons. use crate::input::InputState; -use crate::ui::primitives::{draw_rounded_rect, text_extents_for}; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::theme::overlay::{ BG_INPUT_SELECTION, INPUT_BG, INPUT_BORDER_FOCUSED, INPUT_CARET, RADIUS_MD, RADIUS_STD, @@ -39,6 +39,24 @@ pub fn render_precision_entry_popup( screen_width: u32, screen_height: u32, anchor: (f64, f64), +) { + render_precision_entry_popup_with_engine( + &UiTextEngine::default(), + ctx, + input_state, + screen_width, + screen_height, + anchor, + ); +} + +pub(crate) fn render_precision_entry_popup_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + input_state: &InputState, + screen_width: u32, + screen_height: u32, + anchor: (f64, f64), ) { let Some(entry) = input_state.precision_entry() else { return; @@ -67,7 +85,7 @@ pub fn render_precision_entry_popup( size: 13.0, }; set_rgba(ctx, TEXT_PRIMARY); - let _ = draw_text_baseline( + let _ = engine.draw_baseline( ctx, title_style, entry.target.label(), @@ -90,7 +108,8 @@ pub fn render_precision_entry_popup( let text_x = x + PAD + 10.0; let baseline = field_y + FIELD_H / 2.0 + 5.0; let buffer_advance = |text: &str| { - text_extents_for( + text_extents_for_with_engine( + engine, ctx, VALUE_STYLE.family, VALUE_STYLE.slant, @@ -108,7 +127,7 @@ pub fn render_precision_entry_popup( let _ = ctx.fill(); } set_rgba(ctx, TEXT_PRIMARY); - let _ = draw_text_baseline(ctx, VALUE_STYLE, &text, text_x, baseline, None); + let _ = engine.draw_baseline(ctx, VALUE_STYLE, &text, text_x, baseline, None); // Caret after the buffer (before the unit suffix). if !entry.selected { @@ -120,7 +139,7 @@ pub fn render_precision_entry_popup( // Hint line. set_rgba(ctx, TEXT_HINT_DIM); - let _ = draw_text_baseline( + let _ = engine.draw_baseline( ctx, UiTextStyle { family: "Sans", @@ -134,3 +153,6 @@ pub fn render_precision_entry_popup( None, ); } + +#[cfg(test)] +mod tests; diff --git a/src/ui/precision_entry/tests.rs b/src/ui/precision_entry/tests.rs new file mode 100644 index 000000000..232a8205f --- /dev/null +++ b/src/ui/precision_entry/tests.rs @@ -0,0 +1,74 @@ +use super::*; +use crate::draw::TextMeasurer; +use crate::input::state::InputTextResources; +use crate::ui::onboarding_card::{ + OnboardingCard, OnboardingChecklistItem, render_onboarding_card_with_engine, +}; +use crate::ui::tour::render_tour_with_engine; + +fn pixels(density: i32, paint: impl FnOnce(&cairo::Context)) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 420 * density, 360 * density).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + paint(&ctx); + } + surface.data().unwrap().to_vec() +} + +fn assert_owner_parity( + engine: &UiTextEngine, + density: i32, + paint: impl Fn(&UiTextEngine, &cairo::Context), +) -> Vec { + let actual = pixels(density, |ctx| paint(engine, ctx)); + let fresh = pixels(density, |ctx| paint(&UiTextEngine::default(), ctx)); + assert!(actual.iter().any(|&byte| byte != 0)); + assert!(actual == fresh, "retained overlay text pixels differ"); + actual +} + +#[test] +fn retained_overlay_owner_matches_fresh_across_density_and_visible_state_changes() { + let engine = UiTextEngine::default(); + let measurer = TextMeasurer::default(); + let mut state = crate::input::state::test_support::make_test_input_state(); + let mut card = OnboardingCard { + eyebrow: "你好 Καλημέρα onboarding".into(), + title: "A long title that needs fitting on a small output".into(), + body: "Wrapped body text with café and שלום repeated across the narrow card. More words to occupy another line.".into(), + items: vec![OnboardingChecklistItem { label: "A long Unicode checklist label 你好 café".into(), done: false }], + footer: "Long footer explaining the next action without changing layout policy".into(), + }; + for density in [1, 2, 1] { + state.open_precision_entry(crate::ui::toolbar::PrecisionEntryTarget::Thickness); + assert_owner_parity(&engine, density, |engine, ctx| { + render_precision_entry_popup_with_engine(engine, ctx, &state, 420, 360, (400.0, 350.0)) + }); + let before = assert_owner_parity(&engine, density, |engine, ctx| { + render_onboarding_card_with_engine(engine, ctx, 420, 360, &card) + }); + card.items[0].done = !card.items[0].done; + let after = assert_owner_parity(&engine, density, |engine, ctx| { + render_onboarding_card_with_engine(engine, ctx, 420, 360, &card) + }); + assert!(before != after, "checklist completion must remain visible"); + state.start_tour_with_resources(InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }); + let first = assert_owner_parity(&engine, density, |engine, ctx| { + render_tour_with_engine(engine, ctx, &state, 420, 360) + }); + state.tour_next(); + let next = assert_owner_parity(&engine, density, |engine, ctx| { + render_tour_with_engine(engine, ctx, &state, 420, 360) + }); + assert!( + first != next, + "tour navigation must update the painted step" + ); + state.end_tour(); + } +} diff --git a/src/ui/spotlight_control.rs b/src/ui/spotlight_control.rs index d7693bc08..e6e3c25c5 100644 --- a/src/ui/spotlight_control.rs +++ b/src/ui/spotlight_control.rs @@ -5,9 +5,9 @@ //! when the control is visible. use crate::input::state::SpotlightMagnificationTrack; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use crate::ui::theme::{self, overlay}; -use crate::ui::{draw_rounded_rect, text_extents_for}; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; /// Height of the drawn track bar, which is thinner than the knob it carries. const TRACK_BAR_HEIGHT: f64 = 4.0; @@ -27,6 +27,7 @@ const READOUT_GAP: f64 = 4.0; /// be clipped at a screen edge; it is clamped into `visible` independently of /// the track it is centred on. pub(crate) fn render_spotlight_magnification_control( + engine: &UiTextEngine, ctx: &cairo::Context, track: SpotlightMagnificationTrack, magnification: f64, @@ -93,7 +94,8 @@ pub(crate) fn render_spotlight_magnification_control( weight: cairo::FontWeight::Bold, size: 12.0, }; - let extents = text_extents_for( + let extents = text_extents_for_with_engine( + engine, ctx, style.family, style.slant, @@ -128,7 +130,7 @@ pub(crate) fn render_spotlight_magnification_control( overlay::TEXT_PRIMARY }, ); - draw_text_baseline( + engine.draw_baseline( ctx, style, &label, @@ -182,7 +184,14 @@ mod tests { cairo::ImageSurface::create(cairo::Format::ARgb32, 320, 120).expect("surface"); { let ctx = cairo::Context::new(&surface).expect("context"); - render_spotlight_magnification_control(&ctx, track, factor, reason, visible); + render_spotlight_magnification_control( + &UiTextEngine::default(), + &ctx, + track, + factor, + reason, + visible, + ); } let mut surface = surface; surface.flush(); @@ -312,4 +321,40 @@ mod tests { // damage region the control needs is this box, not the shape's bounds. assert!(x0 <= TRACK.x && x1 >= TRACK.x + TRACK.width - 1); } + #[test] + fn retained_readout_owner_matches_fresh_across_density_and_reason_changes() { + let engine = UiTextEngine::default(); + for density in [1, 2, 1] { + let paint = |engine: &UiTextEngine, reason| { + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + 320 * density, + 120 * density, + ) + .unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + render_spotlight_magnification_control( + engine, + &ctx, + track_with_knob_at(45), + 2.25, + reason, + VISIBLE, + ); + } + surface.data().unwrap().to_vec() + }; + let plain = paint(&engine, None); + let reason = paint(&engine, Some("Freeze screen to preview")); + assert!(plain.iter().any(|&byte| byte != 0)); + assert!( + plain != reason, + "unavailable reason must change the readout" + ); + assert!(plain == paint(&UiTextEngine::default(), None)); + assert!(reason == paint(&UiTextEngine::default(), Some("Freeze screen to preview"))); + } + } } diff --git a/src/ui/tour.rs b/src/ui/tour.rs index dd2a1f751..61d27b38d 100644 --- a/src/ui/tour.rs +++ b/src/ui/tour.rs @@ -1,7 +1,7 @@ //! Tour overlay rendering. use crate::input::state::{InputState, TourStep}; -use crate::ui_text::{UiTextStyle, draw_text_baseline}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ self, OVERLAY_DIM_HEAVY, PROGRESS_FILL, PROGRESS_TRACK, RADIUS_PANEL, SPACING_PANEL, @@ -11,6 +11,16 @@ use super::primitives::draw_rounded_rect; /// Render the guided tour overlay. pub fn render_tour(ctx: &cairo::Context, input_state: &InputState, width: u32, height: u32) { + render_tour_with_engine(&UiTextEngine::default(), ctx, input_state, width, height); +} + +pub(crate) fn render_tour_with_engine( + engine: &UiTextEngine, + ctx: &cairo::Context, + input_state: &InputState, + width: u32, + height: u32, +) { let Some(step) = input_state.current_tour_step() else { return; }; @@ -95,18 +105,18 @@ pub fn render_tour(ctx: &cairo::Context, input_state: &InputState, width: u32, h input_state.tour.step() + 1, TourStep::COUNT ); - draw_text_baseline(ctx, step_style, &step_text, content_x, y + 12.0, None); + engine.draw_baseline(ctx, step_style, &step_text, content_x, y + 12.0, None); y += 24.0; // Title constants::set_color(ctx, TEXT_WHITE); - draw_text_baseline(ctx, title_style, step.title(), content_x, y + 24.0, None); + engine.draw_baseline(ctx, title_style, step.title(), content_x, y + 24.0, None); y += title_height + 16.0; // Description constants::set_color(ctx, TEXT_DESCRIPTION); for line in description.lines() { - draw_text_baseline(ctx, desc_style, line, content_x, y + 18.0, None); + engine.draw_baseline(ctx, desc_style, line, content_x, y + 18.0, None); y += desc_line_height; } y += 24.0; @@ -127,5 +137,5 @@ pub fn render_tour(ctx: &cairo::Context, input_state: &InputState, width: u32, h // Navigation hint constants::set_color(ctx, constants::with_alpha(TEXT_HINT, 0.8)); - draw_text_baseline(ctx, nav_style, step.nav_hint(), content_x, y + 13.0, None); + engine.draw_baseline(ctx, nav_style, step.nav_hint(), content_x, y + 13.0, None); } diff --git a/src/ui_text.rs b/src/ui_text.rs index c58e5d43f..a2c16f44b 100644 --- a/src/ui_text.rs +++ b/src/ui_text.rs @@ -214,17 +214,6 @@ pub(crate) fn text_layout( with_legacy_engine(|engine| engine.layout(ctx, style, text, wrap_width)) } -pub(crate) fn draw_text_baseline( - ctx: &cairo::Context, - style: UiTextStyle<'_>, - text: &str, - x: f64, - y: f64, - wrap_width: Option, -) -> UiTextExtents { - with_legacy_engine(|engine| engine.draw_baseline(ctx, style, text, x, y, wrap_width)) -} - impl UiTextEngine { pub(crate) fn layout( &self, From bf7df556758a7c50a42fe812b6accd995e2b4a48 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:31:28 +0200 Subject: [PATCH 23/42] refactor(toolbar): retain text resources across layout planning --- src/backend/wayland/state/render/ui.rs | 3 +- .../wayland/state/toolbar/drag/clamp.rs | 2 +- src/backend/wayland/state/toolbar/geometry.rs | 6 +- .../wayland/state/toolbar/inline/render.rs | 3 +- src/backend/wayland/state/toolbar/scroll.rs | 3 +- .../wayland/state/toolbar/visibility/sync.rs | 10 +- src/backend/wayland/toolbar/layout/mod.rs | 12 +- .../wayland/toolbar/layout/spec/top.rs | 9 +- .../wayland/toolbar/layout/tests/mod.rs | 162 ++++++---- src/backend/wayland/toolbar/main/lifecycle.rs | 5 +- src/backend/wayland/toolbar/main/render.rs | 6 +- .../wayland/toolbar/render/top_strip/mod.rs | 6 +- src/backend/wayland/toolbar/surfaces/state.rs | 8 +- src/backend/wayland/toolbar/view/top.rs | 60 ++-- src/backend/wayland/toolbar/view/top/build.rs | 12 +- src/backend/wayland/toolbar/view/top/menus.rs | 35 ++- src/backend/wayland/toolbar/view/top/tests.rs | 277 ++++++++++++++---- .../wayland/toolbar/view/top/tests/engine.rs | 105 +++++++ src/toolbar_gtk/view/top_bar.rs | 8 +- src/toolbar_gtk/view/top_bar/strip.rs | 2 +- src/toolbar_gtk/view/top_bar/tests.rs | 102 +++++-- 21 files changed, 648 insertions(+), 188 deletions(-) create mode 100644 src/backend/wayland/toolbar/view/top/tests/engine.rs diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index b4f89d739..c958ae798 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -276,7 +276,8 @@ impl WaylandState { fn render_precision_entry(&mut self, ctx: &cairo::Context, width: u32, height: u32) { let snapshot = self.toolbar_snapshot(); - let (_, top_h) = crate::backend::wayland::toolbar::top_size(&snapshot); + let (_, top_h) = + crate::backend::wayland::toolbar::top_size(self.render.ui_text(), &snapshot); let top_offset = self.toolbar_chrome.top_offset(); let anchor = ( self.inline_top_base_x() + top_offset.0, diff --git a/src/backend/wayland/state/toolbar/drag/clamp.rs b/src/backend/wayland/state/toolbar/drag/clamp.rs index ed5bb9bbb..3b3724884 100644 --- a/src/backend/wayland/state/toolbar/drag/clamp.rs +++ b/src/backend/wayland/state/toolbar/drag/clamp.rs @@ -36,7 +36,7 @@ impl WaylandState { }); return false; } - let (top_w, top_h) = top_size(snapshot); + let (top_w, top_h) = top_size(self.render.ui_text(), snapshot); let top_base_x = self.inline_top_base_x(); let top_base_y = self.inline_top_base_y(); diff --git a/src/backend/wayland/state/toolbar/geometry.rs b/src/backend/wayland/state/toolbar/geometry.rs index 6d38fd3cc..011d7ac3f 100644 --- a/src/backend/wayland/state/toolbar/geometry.rs +++ b/src/backend/wayland/state/toolbar/geometry.rs @@ -132,7 +132,11 @@ mod tests { crate::ui::toolbar::ToolbarBindingHints::default(), ); snapshot.top_viewport_max = Some(budget); - let planned = crate::backend::wayland::toolbar::layout::top_size(&snapshot).0 as f64; + let planned = crate::backend::wayland::toolbar::layout::top_size( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + ) + .0 as f64; assert!(planned <= budget, "planned={planned}, budget={budget}"); } diff --git a/src/backend/wayland/state/toolbar/inline/render.rs b/src/backend/wayland/state/toolbar/inline/render.rs index fc8668302..9e3e0b66b 100644 --- a/src/backend/wayland/state/toolbar/inline/render.rs +++ b/src/backend/wayland/state/toolbar/inline/render.rs @@ -27,7 +27,7 @@ impl WaylandState { self.inline_top_base_y() + authored_offset.1, ); - let top_size = top_size(snapshot); + let top_size = top_size(self.render.ui_text(), snapshot); let top_base_w = top_size.0 as f64 / ui_scale; let top_base_h = top_size.1 as f64 / ui_scale; let top_hover_local = self @@ -43,6 +43,7 @@ impl WaylandState { } let mut hits = Vec::new(); if let Err(err) = render_top_strip( + self.render.ui_text(), ctx, top_base_w, top_base_h, diff --git a/src/backend/wayland/state/toolbar/scroll.rs b/src/backend/wayland/state/toolbar/scroll.rs index 2e168ca1c..b14bf299a 100644 --- a/src/backend/wayland/state/toolbar/scroll.rs +++ b/src/backend/wayland/state/toolbar/scroll.rs @@ -41,7 +41,8 @@ impl WaylandState { return false; } let snapshot = self.toolbar_snapshot(); - let Some((natural, viewport)) = top_popover_scroll_bounds(&snapshot) else { + let Some((natural, viewport)) = top_popover_scroll_bounds(self.render.ui_text(), &snapshot) + else { return false; }; let max_scroll = (natural - viewport).max(0.0); diff --git a/src/backend/wayland/state/toolbar/visibility/sync.rs b/src/backend/wayland/state/toolbar/visibility/sync.rs index 68b814eea..7a055bb5e 100644 --- a/src/backend/wayland/state/toolbar/visibility/sync.rs +++ b/src/backend/wayland/state/toolbar/visibility/sync.rs @@ -186,6 +186,7 @@ impl WaylandState { let scale = self.surface.scale(); let output = self.surface.current_output(); self.toolbar.ensure_created( + self.render.ui_text(), qh, self.protocol.compositor(), layer_shell, @@ -211,8 +212,13 @@ impl WaylandState { // No hover tracking yet; pass None. Can be updated when we record pointer positions per surface. let render_profile = self.input_state.active_ui_render_profile().cloned(); - self.toolbar - .render(self.protocol.shm(), snapshot, None, render_profile.as_ref()); + self.toolbar.render( + self.render.ui_text(), + self.protocol.shm(), + snapshot, + None, + render_profile.as_ref(), + ); self.toolbar.apply_input_regions(self.protocol.compositor()); } diff --git a/src/backend/wayland/toolbar/layout/mod.rs b/src/backend/wayland/toolbar/layout/mod.rs index 5f79582c9..5d2804cdc 100644 --- a/src/backend/wayland/toolbar/layout/mod.rs +++ b/src/backend/wayland/toolbar/layout/mod.rs @@ -1,3 +1,4 @@ +use crate::ui_text::UiTextEngine; mod spec; #[cfg(test)] @@ -8,16 +9,19 @@ use crate::ui::toolbar::ToolbarSnapshot; pub(super) use spec::ToolbarLayoutSpec; /// Compute the target logical size for the top toolbar given snapshot state. -pub fn top_size(snapshot: &ToolbarSnapshot) -> (u32, u32) { - let base = ToolbarLayoutSpec::new(snapshot).top_size(snapshot); +pub fn top_size(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> (u32, u32) { + let base = ToolbarLayoutSpec::new(snapshot).top_size(engine, snapshot); scale_size(base, snapshot.toolbar_scale) } /// Scroll bounds for the open Canvas/Session/Settings popover on the top strip as /// (natural_height, viewport_height), both in pre-scale spec units; `None` /// while no menu popover is open. -pub fn top_popover_scroll_bounds(snapshot: &ToolbarSnapshot) -> Option<(f64, f64)> { - super::view::top::top_popover_scroll_bounds(snapshot) +pub fn top_popover_scroll_bounds( + engine: &UiTextEngine, + snapshot: &ToolbarSnapshot, +) -> Option<(f64, f64)> { + super::view::top::top_popover_scroll_bounds(engine, snapshot) } fn scale_size(size: (u32, u32), scale: f64) -> (u32, u32) { diff --git a/src/backend/wayland/toolbar/layout/spec/top.rs b/src/backend/wayland/toolbar/layout/spec/top.rs index 8b636a9f2..7ec0ef57e 100644 --- a/src/backend/wayland/toolbar/layout/spec/top.rs +++ b/src/backend/wayland/toolbar/layout/spec/top.rs @@ -1,4 +1,5 @@ use crate::ui::toolbar::ToolbarSnapshot; +use crate::ui_text::UiTextEngine; use super::ToolbarLayoutSpec; @@ -78,6 +79,7 @@ impl ToolbarLayoutSpec { pub(in crate::backend::wayland::toolbar) fn top_size( &self, + engine: &UiTextEngine, snapshot: &ToolbarSnapshot, ) -> (u32, u32) { if snapshot.top_minimized { @@ -94,12 +96,13 @@ impl ToolbarLayoutSpec { let mut height = base_height as f64; // Popovers (shapes grid + options, overflow) and the contextual // highlight-ring row grow the surface below the bar. - height += crate::backend::wayland::toolbar::view::top::top_extra_height(snapshot); + height += crate::backend::wayland::toolbar::view::top::top_extra_height(engine, snapshot); // Width comes from the same tree walk the builder performs, so the // size math and the builder cannot drift apart. - let width = - crate::backend::wayland::toolbar::view::top::top_natural_width(snapshot, height); + let width = crate::backend::wayland::toolbar::view::top::top_natural_width( + engine, snapshot, height, + ); (width.ceil() as u32, height.ceil() as u32) } diff --git a/src/backend/wayland/toolbar/layout/tests/mod.rs b/src/backend/wayland/toolbar/layout/tests/mod.rs index b6bf82717..86be6aebc 100644 --- a/src/backend/wayland/toolbar/layout/tests/mod.rs +++ b/src/backend/wayland/toolbar/layout/tests/mod.rs @@ -32,11 +32,17 @@ fn top_size_respects_icon_mode() { // cycle and About alongside pin and minimize. Height adds the contextual // style pill under the 58px island band (6px gap + 40px pill) while a // drawing tool is active. - assert_eq!(top_size(&snapshot), (1227, 104)); + assert_eq!( + top_size(&crate::ui_text::UiTextEngine::default(), &snapshot), + (1227, 104) + ); state.set_toolbar_use_icons(false); let snapshot = snapshot_from_state(&state); - assert_eq!(top_size(&snapshot).1, 106); + assert_eq!( + top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).1, + 106 + ); } #[test] @@ -47,31 +53,47 @@ fn narrow_viewports_drop_presets_then_overflow_items() { // Unconstrained: presets shown, the pill's eight swatches available, // nothing dropped into the overflow. - let full = crate::backend::wayland::toolbar::view::top::plan_top_strip(&snapshot); + let full = crate::backend::wayland::toolbar::view::top::plan_top_strip( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + ); assert!(!full.drop_presets); assert_eq!(full.swatch_count, 8); assert!(full.dropped_tools.is_empty() && full.dropped_utilities.is_empty()); - let full_width = top_size(&snapshot).0; + let full_width = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).0; // Slightly narrow: the non-essential presets island yields first, before // any tool or utility is dropped (M7-C2). snapshot.top_viewport_max = Some(full_width as f64 - 60.0); - let degraded = crate::backend::wayland::toolbar::view::top::plan_top_strip(&snapshot); + let degraded = crate::backend::wayland::toolbar::view::top::plan_top_strip( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + ); assert!(degraded.drop_presets); assert!(degraded.dropped_tools.is_empty() && degraded.dropped_utilities.is_empty()); - assert!(top_size(&snapshot).0 as f64 <= full_width as f64 - 60.0); + assert!( + top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).0 as f64 + <= full_width as f64 - 60.0 + ); // Very narrow: droppable items move into the overflow menu; the protected // core (Pen, Eraser, Undo/Redo, Clear) stays. Colors and presets have // already left the strip. snapshot.top_viewport_max = Some(700.0); - let tight = crate::backend::wayland::toolbar::view::top::plan_top_strip(&snapshot); + let tight = crate::backend::wayland::toolbar::view::top::plan_top_strip( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + ); assert!(tight.drop_presets); assert!(!tight.dropped_utilities.is_empty()); - assert!(top_size(&snapshot).0 as f64 <= 700.0); - let (w, h) = top_size(&snapshot); - let tree = - crate::backend::wayland::toolbar::view::top::build_top_view(&snapshot, w as f64, h as f64); + assert!(top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).0 as f64 <= 700.0); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = crate::backend::wayland::toolbar::view::top::build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); for id in [ "top.tool.pen", "top.tool.eraser", @@ -89,9 +111,13 @@ fn narrow_viewports_drop_presets_then_overflow_items() { // Opening the overflow reveals Clear first, then the dropped items. snapshot.top_overflow_open = true; - let (w, h) = top_size(&snapshot); - let tree = - crate::backend::wayland::toolbar::view::top::build_top_view(&snapshot, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = crate::backend::wayland::toolbar::view::top::build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let overflow_ids: Vec<&str> = tree .nodes() .iter() @@ -123,7 +149,10 @@ fn overflow_contains_only_visible_items_and_is_structural() { let mut snapshot = snapshot_from_state(&state); snapshot.top_viewport_max = Some(700.0); - let plan = crate::backend::wayland::toolbar::view::top::plan_top_strip(&snapshot); + let plan = crate::backend::wayland::toolbar::view::top::plan_top_strip( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + ); assert!( !plan.dropped_tools.is_empty() || !plan.dropped_utilities.is_empty(), "the 700px budget must force items into the overflow: {plan:?}" @@ -140,9 +169,13 @@ fn overflow_contains_only_visible_items_and_is_structural() { ); snapshot.top_overflow_open = true; - let (w, h) = top_size(&snapshot); - let tree = - crate::backend::wayland::toolbar::view::top::build_top_view(&snapshot, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = crate::backend::wayland::toolbar::view::top::build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); assert!(tree.node_by_id(&"top.chrome.overflow".into()).is_some()); assert!( tree.node_by_id(&"top.overflow.top.utility.screenshot".into()) @@ -161,7 +194,7 @@ fn top_strip_fits_480_pixels_in_icon_and_text_modes() { state.set_toolbar_use_icons(use_icons); let mut snapshot = snapshot_from_state(&state); snapshot.top_viewport_max = Some(480.0); - let (width, _) = top_size(&snapshot); + let (width, _) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert!( width <= 480, "{} mode planned width {width} exceeds 480", @@ -178,9 +211,9 @@ fn compact_top_strip_respects_budget_without_the_old_floor() { for budget in [376, 320, 300] { snapshot.top_viewport_max = Some(budget as f64); assert!( - top_size(&snapshot).0 <= budget, + top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).0 <= budget, "planned width {} exceeds {budget}", - top_size(&snapshot).0 + top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).0 ); } } @@ -208,7 +241,10 @@ fn reordered_overflow_items_keep_visual_order() { let mut snapshot = snapshot_from_state(&state); snapshot.top_viewport_max = Some(560.0); - let plan = crate::backend::wayland::toolbar::view::top::plan_top_strip(&snapshot); + let plan = crate::backend::wayland::toolbar::view::top::plan_top_strip( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + ); let highlight = plan .dropped_utilities .iter() @@ -234,10 +270,14 @@ fn shapes_popover_hosts_the_relocated_tool_options() { let snapshot = snapshot_from_state(&state); assert!(snapshot.shape_picker_open); - let (w, h) = top_size(&snapshot); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert!(h > 58, "open popover grows the surface: {h}"); - let tree = - crate::backend::wayland::toolbar::view::top::build_top_view(&snapshot, w as f64, h as f64); + let tree = crate::backend::wayland::toolbar::view::top::build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); // The grid renders inside popover chrome with a caret. let panel = tree @@ -277,10 +317,14 @@ fn shapes_popover_hosts_the_relocated_tool_options() { // is gone. The pill carries its own Fill toggle for shape tools. state.test_set_toolbar_menu_state(TopMenuState::Closed, state.toolbar_top_popover_scroll()); let snapshot = snapshot_from_state(&state); - let (w, h) = top_size(&snapshot); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert_eq!(h, 104); - let tree = - crate::backend::wayland::toolbar::view::top::build_top_view(&snapshot, w as f64, h as f64); + let tree = crate::backend::wayland::toolbar::view::top::build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); assert!(tree.node_by_id(&"top.utility.fill".into()).is_none()); } @@ -290,17 +334,24 @@ fn highlight_ring_row_grows_the_bar_only_while_active() { state.set_toolbar_use_icons(true); let snapshot = snapshot_from_state(&state); // Band (58) plus the contextual style pill (6 + 40) — no ring lane yet. - assert_eq!(top_size(&snapshot).1, 104); + assert_eq!( + top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).1, + 104 + ); state.set_highlight_tool(true); let snapshot = snapshot_from_state(&state); assert!(snapshot.highlight_tool_active); - let (w, h) = top_size(&snapshot); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); // The highlight tool has no style properties, so the pill yields and // only the ring lane grows the 58px band. assert!(h > 58, "ring row grows the bar: {h}"); - let tree = - crate::backend::wayland::toolbar::view::top::build_top_view(&snapshot, w as f64, h as f64); + let tree = crate::backend::wayland::toolbar::view::top::build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); assert!( tree.node_by_id(&"top.island.style".into()).is_none(), "no style pill while the highlight tool is active" @@ -324,9 +375,13 @@ fn highlight_ring_and_top_popovers_use_separate_lanes() { state.toolbar_top_popover_scroll(), ); let snapshot = snapshot_from_state(&state); - let (w, h) = top_size(&snapshot); - let tree = - crate::backend::wayland::toolbar::view::top::build_top_view(&snapshot, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = crate::backend::wayland::toolbar::view::top::build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let ring = tree .node_by_id(&"top.utility.highlight-ring".into()) .expect("ring row"); @@ -348,7 +403,10 @@ fn highlight_ring_and_top_popovers_use_separate_lanes() { let mut snapshot = snapshot_from_state(&state); snapshot.top_viewport_max = (480..=1120).rev().find_map(|budget| { snapshot.top_viewport_max = Some(budget as f64); - let plan = crate::backend::wayland::toolbar::view::top::plan_top_strip(&snapshot); + let plan = crate::backend::wayland::toolbar::view::top::plan_top_strip( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + ); let has_dropped_items = !plan.dropped_tools.is_empty() || !plan.dropped_utilities.is_empty(); (has_dropped_items @@ -361,9 +419,13 @@ fn highlight_ring_and_top_popovers_use_separate_lanes() { snapshot.top_viewport_max.is_some(), "overflow budget retaining highlight" ); - let (w, h) = top_size(&snapshot); - let tree = - crate::backend::wayland::toolbar::view::top::build_top_view(&snapshot, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = crate::backend::wayland::toolbar::view::top::build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let ring = tree .node_by_id(&"top.utility.highlight-ring".into()) .expect("ring row"); @@ -385,12 +447,12 @@ fn top_size_scales_with_toolbar_scale() { state.set_toolbar_use_icons(true); state.test_set_toolbar_appearance(state.toolbar_use_icons(), 1.0); let snapshot = snapshot_from_state(&state); - let base_size = top_size(&snapshot); + let base_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); // Scale 1.5x should increase size proportionally state.test_set_toolbar_appearance(state.toolbar_use_icons(), 1.5); let snapshot = snapshot_from_state(&state); - let scaled_size = top_size(&snapshot); + let scaled_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert_eq!( scaled_size.0, (base_size.0 as f64 * 1.5).ceil() as u32, @@ -405,7 +467,7 @@ fn top_size_scales_with_toolbar_scale() { // Scale 0.75x should decrease size state.test_set_toolbar_appearance(state.toolbar_use_icons(), 0.75); let snapshot = snapshot_from_state(&state); - let small_size = top_size(&snapshot); + let small_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert!( small_size.0 < base_size.0, "Scaled down width should be smaller" @@ -422,18 +484,18 @@ fn scale_size_handles_non_finite_values() { state.set_toolbar_use_icons(true); state.test_set_toolbar_appearance(state.toolbar_use_icons(), 1.0); let snapshot = snapshot_from_state(&state); - let base_size = top_size(&snapshot); + let base_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); // NaN should fall back to 1.0 state.test_set_toolbar_appearance(state.toolbar_use_icons(), f64::NAN); let snapshot = snapshot_from_state(&state); - let nan_size = top_size(&snapshot); + let nan_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert_eq!(nan_size, base_size, "NaN scale should fall back to 1.0"); // Infinity should fall back to 1.0 state.test_set_toolbar_appearance(state.toolbar_use_icons(), f64::INFINITY); let snapshot = snapshot_from_state(&state); - let inf_size = top_size(&snapshot); + let inf_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert_eq!( inf_size, base_size, "Infinity scale should fall back to 1.0" @@ -442,7 +504,7 @@ fn scale_size_handles_non_finite_values() { // Negative infinity should fall back to 1.0 state.test_set_toolbar_appearance(state.toolbar_use_icons(), f64::NEG_INFINITY); let snapshot = snapshot_from_state(&state); - let neg_inf_size = top_size(&snapshot); + let neg_inf_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert_eq!( neg_inf_size, base_size, "Neg infinity scale should fall back to 1.0" @@ -457,20 +519,20 @@ fn scale_size_clamps_extreme_values() { // Test upper bound clamping (max 3.0) state.test_set_toolbar_appearance(state.toolbar_use_icons(), 10.0); let snapshot = snapshot_from_state(&state); - let huge_size = top_size(&snapshot); + let huge_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); state.test_set_toolbar_appearance(state.toolbar_use_icons(), 3.0); let snapshot = snapshot_from_state(&state); - let max_size = top_size(&snapshot); + let max_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert_eq!(huge_size, max_size, "Scale > 3.0 should clamp to 3.0"); // Test lower bound clamping (min 0.5) state.test_set_toolbar_appearance(state.toolbar_use_icons(), 0.1); let snapshot = snapshot_from_state(&state); - let tiny_size = top_size(&snapshot); + let tiny_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); state.test_set_toolbar_appearance(state.toolbar_use_icons(), 0.5); let snapshot = snapshot_from_state(&state); - let min_size = top_size(&snapshot); + let min_size = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert_eq!(tiny_size, min_size, "Scale < 0.5 should clamp to 0.5"); } diff --git a/src/backend/wayland/toolbar/main/lifecycle.rs b/src/backend/wayland/toolbar/main/lifecycle.rs index fc838ca57..285236b18 100644 --- a/src/backend/wayland/toolbar/main/lifecycle.rs +++ b/src/backend/wayland/toolbar/main/lifecycle.rs @@ -1,3 +1,4 @@ +use crate::ui_text::UiTextEngine; use log::info; use smithay_client_toolkit::{ compositor::CompositorState, @@ -10,8 +11,10 @@ use crate::backend::wayland::state::WaylandState; use crate::ui::toolbar::ToolbarSnapshot; impl ToolbarSurfaceManager { + #[allow(clippy::too_many_arguments)] pub fn ensure_created( &mut self, + engine: &UiTextEngine, qh: &QueueHandle, compositor: &CompositorState, layer_shell: &LayerShell, @@ -19,7 +22,7 @@ impl ToolbarSurfaceManager { output: Option<&wl_output::WlOutput>, snapshot: &ToolbarSnapshot, ) { - let top_size = crate::backend::wayland::toolbar::top_size(snapshot); + let top_size = crate::backend::wayland::toolbar::top_size(engine, snapshot); if self.is_top_visible() { if self.top.layer_surface.is_none() { diff --git a/src/backend/wayland/toolbar/main/render.rs b/src/backend/wayland/toolbar/main/render.rs index 3f7bf7a0e..f5964d6fc 100644 --- a/src/backend/wayland/toolbar/main/render.rs +++ b/src/backend/wayland/toolbar/main/render.rs @@ -1,3 +1,4 @@ +use crate::ui_text::UiTextEngine; use smithay_client_toolkit::shm::Shm; use super::structs::ToolbarSurfaceManager; @@ -7,6 +8,7 @@ use crate::ui::toolbar::ToolbarSnapshot; impl ToolbarSurfaceManager { pub fn render( &mut self, + engine: &UiTextEngine, shm: &Shm, snapshot: &ToolbarSnapshot, hover: Option<(f64, f64)>, @@ -28,13 +30,13 @@ impl ToolbarSurfaceManager { render_profile, |ctx, w, h, snap, hits, hov, hov_start| { crate::backend::wayland::toolbar::render_top_strip( - ctx, w, h, snap, hits, hov, hov_start, + engine, ctx, w, h, snap, hits, hov, hov_start, ) }, ) { self.top.report_render_failure(&err); } - self.top.sync_top_input_region(snapshot); + self.top.sync_top_input_region(engine, snapshot); } pub fn mark_dirty(&mut self) { diff --git a/src/backend/wayland/toolbar/render/top_strip/mod.rs b/src/backend/wayland/toolbar/render/top_strip/mod.rs index 8fd2addd4..5dcda36a9 100644 --- a/src/backend/wayland/toolbar/render/top_strip/mod.rs +++ b/src/backend/wayland/toolbar/render/top_strip/mod.rs @@ -3,6 +3,8 @@ //! All geometry lives in the tree builder (`view::top`); this module only //! connects it to the Cairo context and the legacy hit-region consumers. +use crate::ui_text::UiTextEngine; + use std::time::Instant; use anyhow::Result; @@ -14,7 +16,9 @@ use crate::ui::toolbar::ToolbarSnapshot; use super::paint::paint_tree; use super::widgets::draw_tooltip_with_delay; +#[allow(clippy::too_many_arguments)] pub fn render_top_strip( + engine: &UiTextEngine, ctx: &cairo::Context, width: f64, height: f64, @@ -23,7 +27,7 @@ pub fn render_top_strip( hover: Option<(f64, f64)>, hover_start: Option, ) -> Result<()> { - let tree = view::top::build_top_view(snapshot, width, height); + let tree = view::top::build_top_view(engine, snapshot, width, height); // Idle fade: the backend fade engine publishes `top_fade` on the // snapshot (forced to 1.0 while menus are open, the pointer is near, or // the strip is minimized/micro). Painting through a group keeps the diff --git a/src/backend/wayland/toolbar/surfaces/state.rs b/src/backend/wayland/toolbar/surfaces/state.rs index 98de0a751..33b4a9bca 100644 --- a/src/backend/wayland/toolbar/surfaces/state.rs +++ b/src/backend/wayland/toolbar/surfaces/state.rs @@ -1,3 +1,4 @@ +use crate::ui_text::UiTextEngine; use smithay_client_toolkit::{compositor::CompositorState, shell::WaylandSurface}; use wayland_client::protocol::wl_output; @@ -112,7 +113,11 @@ impl ToolbarSurface { /// Restrict the top surface's input region to the bar band plus any /// open popover panels, in surface coordinates; full-surface otherwise. - pub fn sync_top_input_region(&mut self, snapshot: &crate::ui::toolbar::ToolbarSnapshot) { + pub fn sync_top_input_region( + &mut self, + engine: &UiTextEngine, + snapshot: &crate::ui::toolbar::ToolbarSnapshot, + ) { if self.width == 0 || self.height == 0 { return; } @@ -122,6 +127,7 @@ impl ToolbarSurface { 1.0 }; let rects = crate::backend::wayland::toolbar::view::top::top_input_rects( + engine, snapshot, self.width as f64 / ui_scale, self.height as f64 / ui_scale, diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs index 8a886e71d..eb1ec4552 100644 --- a/src/backend/wayland/toolbar/view/top.rs +++ b/src/backend/wayland/toolbar/view/top.rs @@ -13,6 +13,8 @@ //! color chip, sizes; `model::StylePillSpec`). Blue is reserved for the active //! tool; disabled history buttons are dimmed and not interactive. +use crate::ui_text::UiTextEngine; + use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; use crate::input::Tool; use crate::ui::toolbar::{ToolbarSnapshot, model}; @@ -47,7 +49,7 @@ pub(crate) use model::TopStripPlan; /// Degrade the strip until it fits the viewport: quick swatches shrink /// 8→6→4→0 first, then droppable items move into the overflow menu. -pub fn plan_top_strip(snapshot: &ToolbarSnapshot) -> TopStripPlan { +pub fn plan_top_strip(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> TopStripPlan { let mut plan = TopStripPlan::unconstrained(); if snapshot.top_minimized || snapshot.top_micro_active() { return plan; @@ -55,7 +57,7 @@ pub fn plan_top_strip(snapshot: &ToolbarSnapshot) -> TopStripPlan { let Some(budget) = snapshot.top_viewport_max else { return plan; }; - let fits = |plan: &TopStripPlan| natural_width_planned(snapshot, plan) <= budget; + let fits = |plan: &TopStripPlan| natural_width_planned(engine, snapshot, plan) <= budget; if fits(&plan) { return plan; } @@ -243,9 +245,14 @@ fn bar_band_height(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> f64 { } /// Build the complete top-strip tree for the given logical surface size. -pub fn build_top_view(snapshot: &ToolbarSnapshot, width: f64, height: f64) -> WidgetTree { - let plan = plan_top_strip(snapshot); - build::build_top_view_planned(snapshot, &plan, width, height) +pub fn build_top_view( + engine: &UiTextEngine, + snapshot: &ToolbarSnapshot, + width: f64, + height: f64, +) -> WidgetTree { + let plan = plan_top_strip(engine, snapshot); + build::build_top_view_planned(engine, snapshot, &plan, width, height) } /// Input rects for the top surface in tree-logical coordinates, or None @@ -255,6 +262,7 @@ pub fn build_top_view(snapshot: &ToolbarSnapshot, width: f64, height: f64) -> Wi /// transparent inter-island gaps consistently stay click-through to the /// canvas whether or not a popover is up. pub fn top_input_rects( + engine: &UiTextEngine, snapshot: &ToolbarSnapshot, width: f64, height: f64, @@ -262,9 +270,9 @@ pub fn top_input_rects( if snapshot.top_minimized || snapshot.top_micro_active() { return None; } - let plan = plan_top_strip(snapshot); + let plan = plan_top_strip(engine, snapshot); let bar_h = bar_band_height(snapshot, &plan); - let tree = build_top_view(snapshot, width, height); + let tree = build_top_view(engine, snapshot, width, height); let mut rects: Vec<_> = tree .nodes() .iter() @@ -293,11 +301,11 @@ pub fn top_input_rects( /// Everything that grows the surface below the base bar: the shapes/options /// popover, the contextual highlight-ring row, the style pill, the overflow /// popover, and the Canvas/Session/Settings popovers. -pub fn top_extra_height(snapshot: &ToolbarSnapshot) -> f64 { +pub fn top_extra_height(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> f64 { if snapshot.top_minimized || snapshot.top_micro_active() { return 0.0; } - let plan = plan_top_strip(snapshot); + let plan = plan_top_strip(engine, snapshot); let contextual_stack = build::shape_popover_height_planned(snapshot, &plan) + build::ring_row_height_planned(snapshot, &plan) + build::style_pill_height_planned(snapshot, &plan); @@ -306,33 +314,45 @@ pub fn top_extra_height(snapshot: &ToolbarSnapshot) -> f64 { // Size the shared surface to whichever path reaches farther down. contextual_stack .max(build::overflow_height_planned(snapshot, &plan)) - .max(menus::menu_popover_height_planned(snapshot, &plan)) + .max(menus::menu_popover_height_planned(engine, snapshot, &plan)) } /// Scroll bounds for the open Canvas/Session/Settings popover as /// (natural_height, viewport_height), both in pre-scale spec units; `None` /// while no menu popover is open. The wheel path and scrollbar drag use these /// same bounds. -pub fn top_popover_scroll_bounds(snapshot: &ToolbarSnapshot) -> Option<(f64, f64)> { - let plan = plan_top_strip(snapshot); - menus::menu_scroll_bounds_planned(snapshot, &plan) +pub fn top_popover_scroll_bounds( + engine: &UiTextEngine, + snapshot: &ToolbarSnapshot, +) -> Option<(f64, f64)> { + let plan = plan_top_strip(engine, snapshot); + menus::menu_scroll_bounds_planned(engine, snapshot, &plan) } /// Natural width of the strip: the left-to-right content walk plus the /// right-aligned chrome block. Computed from a build against a sentinel /// width so the size math and the builder can never drift apart. -pub fn top_natural_width(snapshot: &ToolbarSnapshot, height: f64) -> f64 { - let plan = plan_top_strip(snapshot); - natural_width_planned_at(snapshot, &plan, height) +pub fn top_natural_width(engine: &UiTextEngine, snapshot: &ToolbarSnapshot, height: f64) -> f64 { + let plan = plan_top_strip(engine, snapshot); + natural_width_planned_at(engine, snapshot, &plan, height) } -fn natural_width_planned(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> f64 { +fn natural_width_planned( + engine: &UiTextEngine, + snapshot: &ToolbarSnapshot, + plan: &TopStripPlan, +) -> f64 { let base_height = base_bar_height(snapshot); - natural_width_planned_at(snapshot, plan, base_height) + natural_width_planned_at(engine, snapshot, plan, base_height) } -fn natural_width_planned_at(snapshot: &ToolbarSnapshot, plan: &TopStripPlan, height: f64) -> f64 { - let tree = build::build_top_view_planned(snapshot, plan, 0.0, height); +fn natural_width_planned_at( + engine: &UiTextEngine, + snapshot: &ToolbarSnapshot, + plan: &TopStripPlan, + height: f64, +) -> f64 { + let tree = build::build_top_view_planned(engine, snapshot, plan, 0.0, height); // The tools/history island cards already include their trailing padding, // so the max right edge of the left-hand content is the pill edge. The // right-anchored chrome (island card and buttons) is excluded because it diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index 3e8d6870a..240e6421b 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -3,6 +3,8 @@ //! This module owns the implementation behind `top`'s stable planning, //! building, sizing, and input-region interface. +use crate::ui_text::UiTextEngine; + use crate::backend::wayland::toolbar::events::HitKind; use crate::backend::wayland::toolbar::format_binding_label; use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; @@ -25,6 +27,7 @@ pub(super) const OVERFLOW_ANCHOR_GAP: f64 = 6.0; pub(super) const OVERFLOW_BOTTOM_MARGIN: f64 = 4.0; pub(super) fn build_top_view_planned( + engine: &UiTextEngine, snapshot: &ToolbarSnapshot, plan: &TopStripPlan, width: f64, @@ -298,7 +301,14 @@ pub(super) fn build_top_view_planned( // The highlight ring remains visible because it extends the button's // own band rather than forming a detached row. let popover_anchor = overflow_family_anchor(anchor, snapshot, plan); - super::menus::push_menu_popover(&mut tree, snapshot, plan, popover_anchor, (width, height)); + super::menus::push_menu_popover( + engine, + &mut tree, + snapshot, + plan, + popover_anchor, + (width, height), + ); } tree diff --git a/src/backend/wayland/toolbar/view/top/menus.rs b/src/backend/wayland/toolbar/view/top/menus.rs index af53dc531..79fa85f2d 100644 --- a/src/backend/wayland/toolbar/view/top/menus.rs +++ b/src/backend/wayland/toolbar/view/top/menus.rs @@ -8,6 +8,8 @@ //! proportional scrollbar. The tree painter has no clip, so rows are either //! fully inside the viewport or withheld entirely (paint and hits alike). +use crate::ui_text::UiTextEngine; + use crate::backend::wayland::toolbar::events::HitKind; use crate::backend::wayland::toolbar::format_binding_label; use crate::backend::wayland::toolbar::rows::{grid_layout, row_item_width}; @@ -15,7 +17,7 @@ use crate::ui::toolbar::session_format::{ strip_session_extension, truncate_middle, truncate_start, }; use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot, model}; -use crate::ui_text::{UiTextStyle, measure_text}; +use crate::ui_text::UiTextStyle; use super::super::node::{ButtonStyle, Interaction, LabelSpec, WidgetKind, WidgetNode}; use super::super::popover; @@ -73,7 +75,10 @@ const CANVAS_SLIDER_H: f64 = 18.0; /// natural (unclamped) content height. Canvas wins over Session over /// Settings if several flags are somehow set — the apply layer keeps them /// mutually exclusive. -fn open_menu_content(snapshot: &ToolbarSnapshot) -> Option<(&'static str, Vec)> { +fn open_menu_content( + engine: &UiTextEngine, + snapshot: &ToolbarSnapshot, +) -> Option<(&'static str, Vec)> { if snapshot.canvas_popover_open { return canvas_menu_content(snapshot).map(|nodes| ("canvas", nodes)); } @@ -81,7 +86,7 @@ fn open_menu_content(snapshot: &ToolbarSnapshot) -> Option<(&'static str, Vec /// (natural_height, viewport_height), both in pre-scale spec units; `None` /// while no menu popover is open. Max scroll = (natural - viewport).max(0). pub(super) fn menu_scroll_bounds_planned( + engine: &UiTextEngine, snapshot: &ToolbarSnapshot, plan: &super::TopStripPlan, ) -> Option<(f64, f64)> { - let (_, nodes) = open_menu_content(snapshot)?; + let (_, nodes) = open_menu_content(engine, snapshot)?; let natural = content_height(&nodes); Some((natural, natural.min(menu_viewport_cap(snapshot, plan)))) } @@ -132,10 +138,11 @@ pub(super) fn menu_scroll_bounds_planned( /// Extra surface height the open Canvas/Session/Settings popover needs below /// the base band when anchored directly to the overflow button. pub(super) fn menu_popover_height_planned( + engine: &UiTextEngine, snapshot: &ToolbarSnapshot, plan: &super::TopStripPlan, ) -> f64 { - let Some((_, nodes)) = open_menu_content(snapshot) else { + let Some((_, nodes)) = open_menu_content(engine, snapshot) else { return 0.0; }; let viewport = content_height(&nodes).min(menu_viewport_cap(snapshot, plan)); @@ -145,13 +152,14 @@ pub(super) fn menu_popover_height_planned( /// Build the open popover into the tree, anchored like the overflow menu. pub(super) fn push_menu_popover( + engine: &UiTextEngine, tree: &mut WidgetTree, snapshot: &ToolbarSnapshot, plan: &super::TopStripPlan, anchor: (f64, f64, f64, f64), bounds: (f64, f64), ) { - let Some((key, nodes)) = open_menu_content(snapshot) else { + let Some((key, nodes)) = open_menu_content(engine, snapshot) else { return; }; let natural = content_height(&nodes); @@ -384,7 +392,10 @@ fn session_menu_content(snapshot: &ToolbarSnapshot) -> Option> { /// grid, the settings/customize button grid, and the customization /// sub-panel (group chooser plus per-item show/hide, reorder, and drag /// rows) — the Settings pane minus its collapsible-card chrome. -fn settings_menu_content(snapshot: &ToolbarSnapshot) -> Option> { +fn settings_menu_content( + engine: &UiTextEngine, + snapshot: &ToolbarSnapshot, +) -> Option> { let model = model::ToolbarSettingsModel::for_popover(snapshot)?; let customizing = snapshot.customize_items_open; let mut nodes = Vec::new(); @@ -419,7 +430,7 @@ fn settings_menu_content(snapshot: &ToolbarSnapshot) -> Option> y += MENU_TOGGLE_H + MENU_TOGGLE_GAP; } - append_settings_notices(&model, &mut nodes, &mut y); + append_settings_notices(engine, &model, &mut nodes, &mut y); let buttons = model.buttons(); if !buttons.is_empty() { @@ -643,6 +654,7 @@ fn append_layout_mode_control( } fn append_settings_notices( + engine: &UiTextEngine, model: &model::ToolbarSettingsModel, nodes: &mut Vec, y: &mut f64, @@ -653,7 +665,7 @@ fn append_settings_notices( model::ToolbarSettingsNoticeSeverity::Warning | model::ToolbarSettingsNoticeSeverity::Error ); - let notice_h = settings_notice_height(notice.text.as_ref(), bold); + let notice_h = settings_notice_height(engine, notice.text.as_ref(), bold); nodes.push(WidgetNode::decor( format!("top.menu.settings.notice.{index}"), (0.0, *y, MENU_CONTENT_W, notice_h), @@ -663,7 +675,7 @@ fn append_settings_notices( } } -fn settings_notice_height(text: &str, bold: bool) -> f64 { +fn settings_notice_height(engine: &UiTextEngine, text: &str, bold: bool) -> f64 { let style = UiTextStyle { family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT, slant: cairo::FontSlant::Normal, @@ -674,7 +686,8 @@ fn settings_notice_height(text: &str, bold: bool) -> f64 { }, size: MENU_META_FONT, }; - measure_text(style, text, Some(MENU_CONTENT_W)) + engine + .measure(style, text, Some(MENU_CONTENT_W)) .map(|extents| extents.height() + MENU_NOTICE_PADDING_Y) .unwrap_or(MENU_TOGGLE_H) .max(MENU_TOGGLE_H) diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index bfab56536..896ac1040 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -11,8 +11,13 @@ fn snapshot() -> ToolbarSnapshot { } fn build(snapshot: &ToolbarSnapshot) -> WidgetTree { - let (w, h) = top_size(snapshot); - build_top_view(snapshot, w as f64, h as f64) + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), snapshot); + build_top_view( + &crate::ui_text::UiTextEngine::default(), + snapshot, + w as f64, + h as f64, + ) } fn node_id_list(tree: &WidgetTree) -> Vec<&str> { @@ -236,8 +241,14 @@ fn presets_render_as_slot_buttons_in_the_presets_island() { )); // The presets island joins the surface input region as its own rect. - let (w, h) = top_size(&snapshot); - let rects = top_input_rects(&snapshot, w as f64, h as f64).expect("input rects"); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let rects = top_input_rects( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ) + .expect("input rects"); assert!(rects.contains(&island.rect)); } @@ -409,9 +420,14 @@ fn input_rects_cover_islands_and_open_popovers_only() { state.test_set_toolbar_menu_state(TopMenuState::Closed, state.toolbar_top_popover_scroll()); let snapshot = ToolbarSnapshot::from_input_with_bindings(&state, ToolbarBindingHints::default()); - let (w, h) = top_size(&snapshot); - let rects = top_input_rects(&snapshot, w as f64, h as f64) - .expect("no popover: the islands still restrict input"); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let rects = top_input_rects( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ) + .expect("no popover: the islands still restrict input"); assert_eq!( rects.len(), 5, @@ -424,7 +440,12 @@ fn input_rects_cover_islands_and_open_popovers_only() { assert!(rects[2].0 + rects[2].2 < rects[3].0); // The style pill is the fifth rect, detached below the band. assert!(rects[4].1 > rects[0].1 + rects[0].3); - let tree = build_top_view(&snapshot, w as f64, h as f64); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let islands: Vec<_> = tree .nodes() .iter() @@ -441,8 +462,14 @@ fn input_rects_cover_islands_and_open_popovers_only() { ); let snapshot = ToolbarSnapshot::from_input_with_bindings(&state, ToolbarBindingHints::default()); - let (w, h) = top_size(&snapshot); - let rects = top_input_rects(&snapshot, w as f64, h as f64).expect("partial input region"); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let rects = top_input_rects( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ) + .expect("partial input region"); assert_eq!(rects.len(), 6, "five islands + shapes panel: {rects:?}"); assert_eq!(rects[0].0, 0.0); assert_eq!(rects[0].1, 0.0); @@ -457,7 +484,12 @@ fn input_rects_cover_islands_and_open_popovers_only() { assert!(rects[0].0 + rects[0].2 < rects[1].0); assert!(rects[1].0 + rects[1].2 < rects[2].0); assert!(rects[2].0 + rects[2].2 < rects[3].0); - let tree = build_top_view(&snapshot, w as f64, h as f64); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let panel = tree .node_by_id(&"top.shapes.panel".into()) .expect("panel node"); @@ -478,9 +510,20 @@ fn island_backgrounds_stop_at_bar_band_when_popover_is_open() { ); let snapshot = ToolbarSnapshot::from_input_with_bindings(&state, ToolbarBindingHints::default()); - let (w, h) = top_size(&snapshot); - let tree = build_top_view(&snapshot, w as f64, h as f64); - let input_rects = top_input_rects(&snapshot, w as f64, h as f64).expect("partial input region"); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); + let input_rects = top_input_rects( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ) + .expect("partial input region"); let islands: Vec<_> = tree .nodes() @@ -502,10 +545,15 @@ fn minimized_strip_is_a_single_restore_tab() { let mut snapshot = snapshot(); snapshot.top_minimized = true; - let (w, h) = top_size(&snapshot); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert_eq!((w, h), (64, 24)); - let tree = build_top_view(&snapshot, w as f64, h as f64); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let interactive: Vec<_> = tree .nodes() .iter() @@ -524,14 +572,19 @@ fn micro_strip_is_a_single_round_chip() { let mut snapshot = snapshot(); snapshot.top_display_mode = crate::config::TopDisplayMode::Micro; - let (w, h) = top_size(&snapshot); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); assert_eq!( (w, h), ToolbarLayoutSpec::TOP_MICRO_SIZE, "micro mode is one 44px chip" ); - let tree = build_top_view(&snapshot, w as f64, h as f64); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let interactive: Vec<_> = tree .nodes() .iter() @@ -576,12 +629,26 @@ fn micro_strip_is_a_single_round_chip() { // No popovers, ring rows, or partial input regions in micro mode: the // whole 44px surface takes input. - assert!(top_input_rects(&snapshot, w as f64, h as f64).is_none()); - assert_eq!(top_extra_height(&snapshot), 0.0); + assert!( + top_input_rects( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64 + ) + .is_none() + ); + assert_eq!( + top_extra_height(&crate::ui_text::UiTextEngine::default(), &snapshot), + 0.0 + ); // Minimized wins if both states are somehow set. snapshot.top_minimized = true; - assert_eq!(top_size(&snapshot), ToolbarLayoutSpec::TOP_MINIMIZED_SIZE); + assert_eq!( + top_size(&crate::ui_text::UiTextEngine::default(), &snapshot), + ToolbarLayoutSpec::TOP_MINIMIZED_SIZE + ); } #[test] @@ -589,7 +656,13 @@ fn compact_shape_picker_preserves_its_full_semantic_icon_size() { let snapshot = snapshot(); let mut plan = TopStripPlan::unconstrained(); plan.compact = true; - let tree = super::build::build_top_view_planned(&snapshot, &plan, 800.0, 100.0); + let tree = super::build::build_top_view_planned( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + &plan, + 800.0, + 100.0, + ); let picker = tree .node_by_id(&ids::TOP_UTILITY_SHAPE_PICKER.as_str().into()) .expect("shape picker"); @@ -613,7 +686,13 @@ fn overflow_utility_tooltips_remain_bare_action_labels() { plan.swatch_count = 0; plan.dropped_utilities = vec![model::TopUtilityButton::Text]; - let tree = super::build::build_top_view_planned(&snapshot, &plan, 800.0, 160.0); + let tree = super::build::build_top_view_planned( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + &plan, + 800.0, + 160.0, + ); let text = tree .node_by_id(&"top.overflow.top.utility.text".into()) .expect("overflow text control"); @@ -966,11 +1045,22 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() { // Select without a selection: the pill yields entirely — no pill node, // no fourth input rect, no extra height for it. let select = snapshot_for_tool(Tool::Select); - let (w, h) = top_size(&select); - let tree = build_top_view(&select, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &select); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &select, + w as f64, + h as f64, + ); assert!(tree.node_by_id(&"top.island.style".into()).is_none()); assert!(style_ids(&tree).is_empty()); - let rects = top_input_rects(&select, w as f64, h as f64).expect("island input rects"); + let rects = top_input_rects( + &crate::ui_text::UiTextEngine::default(), + &select, + w as f64, + h as f64, + ) + .expect("island input rects"); assert_eq!( rects.len(), 4, @@ -996,8 +1086,13 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() { ]; selection.selection_has_text = true; selection.selected_text_bold = Some(false); - let (w, h) = top_size(&selection); - let tree = build_top_view(&selection, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &selection); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &selection, + w as f64, + h as f64, + ); let style = tree .node_by_id(&"top.island.style".into()) .expect("selection pill card"); @@ -1016,7 +1111,13 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() { "top.style.font-bold", ] ); - let rects = top_input_rects(&selection, w as f64, h as f64).expect("island input rects"); + let rects = top_input_rects( + &crate::ui_text::UiTextEngine::default(), + &selection, + w as f64, + h as f64, + ) + .expect("island input rects"); assert_eq!(rects.len(), 5, "selection pill rect: {rects:?}"); let pill_rect = rects[4]; assert_eq!( @@ -1034,8 +1135,13 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() { Tool::StepMarker, ] { let snapshot = snapshot_for_tool(tool); - let (w, h) = top_size(&snapshot); - let tree = build_top_view(&snapshot, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let tools = tree .node_by_id(&"top.island.tools".into()) .expect("tools island"); @@ -1076,7 +1182,13 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() { // The pill joins the surface input region as the fifth rect (after // the tools, presets, history, and chrome band islands). - let rects = top_input_rects(&snapshot, w as f64, h as f64).expect("island input rects"); + let rects = top_input_rects( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ) + .expect("island input rects"); assert_eq!(rects.len(), 5, "{tool:?} rects: {rects:?}"); assert_eq!(rects[4], style.rect, "{tool:?} pill input rect"); } @@ -1156,8 +1268,13 @@ fn runtime_sized_menu_popovers_do_not_reenter_strip_planning() { _ => unreachable!(), } - let (width, height) = top_size(&snapshot); - let tree = build_top_view(&snapshot, width as f64, height as f64); + let (width, height) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + width as f64, + height as f64, + ); assert!( tree.node_by_id(&format!("top.menu.{name}.panel").into()) .is_some(), @@ -1470,7 +1587,8 @@ fn canvas_popover_scrolls_when_all_sections_exceed_the_viewport() { snapshot.show_delay_sliders = true; snapshot.delay_actions_enabled = true; - let bounds = top_popover_scroll_bounds(&snapshot).expect("canvas scroll bounds"); + let bounds = top_popover_scroll_bounds(&crate::ui_text::UiTextEngine::default(), &snapshot) + .expect("canvas scroll bounds"); assert!( bounds.0 > bounds.1, "the full section set is taller than the viewport: {bounds:?}" @@ -1484,11 +1602,17 @@ fn canvas_popover_scrolls_when_all_sections_exceed_the_viewport() { ); // The Canvas popover joins the input region as an extra rect. - let (w, h) = top_size(&snapshot); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); let panel = tree .node_by_id(&"top.menu.canvas.panel".into()) .expect("canvas panel"); - let rects = top_input_rects(&snapshot, w as f64, h as f64).expect("input rects"); + let rects = top_input_rects( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ) + .expect("input rects"); assert!( rects .iter() @@ -1514,8 +1638,13 @@ fn session_popover_re_hosts_the_session_pane_content() { }) .collect(); - let (w, h) = top_size(&snapshot); - let tree = build_top_view(&snapshot, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let panel = tree .node_by_id(&"top.menu.session.panel".into()) .expect("session popover panel"); @@ -1604,7 +1733,13 @@ fn assert_session_popover_input_rect( .node_by_id(&"top.menu.session.panel".into()) .expect("session popover panel"); // The popover joins the input region as an extra rect below the band. - let rects = top_input_rects(snapshot, w as f64, h as f64).expect("input rects"); + let rects = top_input_rects( + &crate::ui_text::UiTextEngine::default(), + snapshot, + w as f64, + h as f64, + ) + .expect("input rects"); assert!( rects .iter() @@ -1813,11 +1948,18 @@ fn tall_popover_content_caps_the_panel_and_scrolls_internally() { // of how much content fits under the normal-screen fallback cap. snapshot.top_available_height = Some(360.0); - let (natural, viewport) = top_popover_scroll_bounds(&snapshot).expect("settings scroll bounds"); + let (natural, viewport) = + top_popover_scroll_bounds(&crate::ui_text::UiTextEngine::default(), &snapshot) + .expect("settings scroll bounds"); assert!(natural > viewport, "short output must cap tall content"); - let (w, h) = top_size(&snapshot); - let tree = build_top_view(&snapshot, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let panel = tree .node_by_id(&"top.menu.settings.panel".into()) .expect("settings popover panel"); @@ -1861,7 +2003,12 @@ fn tall_popover_content_caps_the_panel_and_scrolls_internally() { let unscrolled_first = tree.node_by_id(&"top.menu.settings.button.0".into()); assert!(unscrolled_first.is_some(), "top row visible before scroll"); snapshot.top_popover_scroll = max_scroll + 500.0; // clamps to max - let scrolled = build_top_view(&snapshot, w as f64, h as f64); + let scrolled = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let scrolled_panel = scrolled .node_by_id(&"top.menu.settings.panel".into()) .expect("panel"); @@ -1895,22 +2042,29 @@ fn tall_content_fills_a_tall_screen_without_scrolling() { // Without a known output height, the fixed fallback cap still scrolls. let (natural, capped) = - top_popover_scroll_bounds(&snapshot).expect("bounds while the settings popover is open"); + top_popover_scroll_bounds(&crate::ui_text::UiTextEngine::default(), &snapshot) + .expect("bounds while the settings popover is open"); assert_eq!(capped, super::menus::MENU_MAX_CONTENT_H); assert!(natural > capped, "content overflows the fallback cap"); // A tall screen: the popover grows to the full natural height, no scroll. snapshot.top_available_height = Some(natural + 600.0); let (natural2, viewport) = - top_popover_scroll_bounds(&snapshot).expect("bounds with a known tall output"); + top_popover_scroll_bounds(&crate::ui_text::UiTextEngine::default(), &snapshot) + .expect("bounds with a known tall output"); assert_eq!(natural2, natural, "content is unchanged"); assert!( (viewport - natural).abs() < 1e-9, "viewport should equal natural height on a tall screen: {viewport} vs {natural}" ); - let (w, h) = top_size(&snapshot); - let tree = build_top_view(&snapshot, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); assert!( tree.node_by_id(&"top.menu.settings.scrollbar".into()) .is_none(), @@ -1940,14 +2094,20 @@ fn tall_content_scrolls_on_a_short_screen() { snapshot.top_available_height = Some(360.0); let (natural, viewport) = - top_popover_scroll_bounds(&snapshot).expect("bounds with a known short output"); + top_popover_scroll_bounds(&crate::ui_text::UiTextEngine::default(), &snapshot) + .expect("bounds with a known short output"); assert!( viewport < natural, "content must still scroll when the screen is too short" ); - let (w, h) = top_size(&snapshot); - let tree = build_top_view(&snapshot, w as f64, h as f64); + let (w, h) = top_size(&crate::ui_text::UiTextEngine::default(), &snapshot); + let tree = build_top_view( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + w as f64, + h as f64, + ); let scrollbar = tree .node_by_id(&"top.menu.settings.scrollbar".into()) .expect("internal scrollbar on a short screen"); @@ -1978,7 +2138,10 @@ fn popover_height_budget_is_relative_to_the_toolbar_surface_origin() { Some(crate::ui::toolbar::ToolbarItemCustomizeGroup::TopControls); snapshot.top_available_height = Some(360.0); - assert_eq!(top_size(&snapshot).1, 360); + assert_eq!( + top_size(&crate::ui_text::UiTextEngine::default(), &snapshot).1, + 360 + ); } /// The wheel path scrolls the open popover against the same bounds the @@ -1987,7 +2150,7 @@ fn popover_height_budget_is_relative_to_the_toolbar_surface_origin() { fn top_popover_scroll_bounds_serve_the_wheel_path() { let mut snapshot = snapshot(); assert!( - top_popover_scroll_bounds(&snapshot).is_none(), + top_popover_scroll_bounds(&crate::ui_text::UiTextEngine::default(), &snapshot).is_none(), "no bounds while no menu popover is open" ); @@ -1997,7 +2160,8 @@ fn top_popover_scroll_bounds_serve_the_wheel_path() { Some(crate::ui::toolbar::ToolbarItemCustomizeGroup::TopControls); snapshot.top_available_height = Some(360.0); let (natural, viewport) = - top_popover_scroll_bounds(&snapshot).expect("bounds while the settings popover is open"); + top_popover_scroll_bounds(&crate::ui_text::UiTextEngine::default(), &snapshot) + .expect("bounds while the settings popover is open"); assert!( natural > viewport, "the Top-controls list overflows a short-screen viewport" @@ -2023,7 +2187,8 @@ fn top_popover_scroll_bounds_serve_the_wheel_path() { snapshot.customize_items_group = None; snapshot.session_popover_open = true; let (natural, viewport) = - top_popover_scroll_bounds(&snapshot).expect("bounds while the session popover is open"); + top_popover_scroll_bounds(&crate::ui_text::UiTextEngine::default(), &snapshot) + .expect("bounds while the session popover is open"); assert_eq!(natural, viewport, "short content never scrolls"); } @@ -2048,3 +2213,5 @@ fn hidden_items_produce_no_nodes() { assert!(tree.node_by_id(&"top.utility.undo".into()).is_none()); assert!(tree.node_by_id(&"top.tool.marker".into()).is_some()); } + +mod engine; diff --git a/src/backend/wayland/toolbar/view/top/tests/engine.rs b/src/backend/wayland/toolbar/view/top/tests/engine.rs new file mode 100644 index 000000000..2235cedda --- /dev/null +++ b/src/backend/wayland/toolbar/view/top/tests/engine.rs @@ -0,0 +1,105 @@ +use super::*; + +#[test] +fn retained_planner_preserves_wrapped_notices_surface_size_and_input_regions() { + let engine = UiTextEngine::default(); + let mut snapshot = snapshot(); + snapshot.settings_popover_open = true; + snapshot.top_available_height = Some(1800.0); + let mut notice_heights = Vec::new(); + for path in [ + "/tmp/ui.toml", + "/tmp/設定/Καλημέρα/long-directory-name/another-long-directory-name/runtime-ui.toml", + "/tmp/ui.toml", + ] { + snapshot.runtime_ui_persistence = Some(crate::ui::toolbar::RuntimeUiPersistenceSnapshot { + path: path.into(), + mode: crate::ui::toolbar::RuntimeUiPersistenceMode::Supported, + detail: None, + recovery_artifacts: Vec::new(), + }); + let size = top_size(&engine, &snapshot); + let tree = build_top_view(&engine, &snapshot, size.0 as f64, size.1 as f64); + let fresh = UiTextEngine::default(); + assert_eq!(size, top_size(&fresh, &snapshot)); + assert!( + tree.nodes() == build_top_view(&fresh, &snapshot, size.0 as f64, size.1 as f64).nodes(), + "retained planner nodes for {path}" + ); + let rects = top_input_rects(&engine, &snapshot, size.0 as f64, size.1 as f64).unwrap(); + assert_eq!( + Some(rects.clone()), + top_input_rects(&fresh, &snapshot, size.0 as f64, size.1 as f64) + ); + assert_eq!( + top_popover_scroll_bounds(&engine, &snapshot), + top_popover_scroll_bounds(&fresh, &snapshot) + ); + let text = format!("Runtime state: {path}"); + let notice = tree + .nodes() + .iter() + .find(|node| matches!(&node.kind, WidgetKind::Label(label) if label.text == text)) + .expect("runtime-path notice"); + notice_heights.push(notice.rect.3); + let (x, y, w, h) = notice.rect; + assert!( + rects.iter().any(|&(rx, ry, rw, rh)| x >= rx + && y >= ry + && x + w <= rx + rw + && y + h <= ry + rh), + "notice remains inside surface input panel" + ); + // Rebind the measured notice layout to scaled paint targets, then repeat + // pre-target sizing with the same owner. + for density in [1.0, 2.0, 1.0] { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 8, 8).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(density, density); + engine.layout( + &ctx, + crate::ui_text::UiTextStyle { + family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT, + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: super::super::menus::MENU_META_FONT, + }, + &text, + Some(super::super::menus::MENU_CONTENT_W), + ); + assert_eq!(top_size(&engine, &snapshot), size); + } + } + assert!( + notice_heights[1] > notice_heights[0], + "long Unicode path must wrap into more height" + ); + assert_eq!(notice_heights[0], notice_heights[2]); +} + +#[test] +fn retained_planner_matches_fresh_under_width_pressure_and_scroll_bounds() { + let engine = UiTextEngine::default(); + let mut snapshot = snapshot(); + snapshot.settings_popover_open = true; + snapshot.customize_items_open = true; + snapshot.customize_items_group = + Some(crate::ui::toolbar::ToolbarItemCustomizeGroup::TopControls); + snapshot.top_available_height = Some(360.0); + for width in [1200.0, 420.0, 1200.0] { + snapshot.top_viewport_max = Some(width); + let size = top_size(&engine, &snapshot); + let fresh = UiTextEngine::default(); + assert_eq!(size, top_size(&fresh, &snapshot)); + let (natural, viewport) = top_popover_scroll_bounds(&engine, &snapshot).unwrap(); + assert!(natural > viewport); + assert_eq!( + Some((natural, viewport)), + top_popover_scroll_bounds(&fresh, &snapshot) + ); + let actual = build_top_view(&engine, &snapshot, size.0 as f64, size.1 as f64); + let expected = build_top_view(&fresh, &snapshot, size.0 as f64, size.1 as f64); + assert!(actual.nodes() == expected.nodes(), "width pressure {width}"); + assert!(!actual.to_hit_regions().is_empty()); + } +} diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index 6b439a7c2..53a68db50 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -330,8 +330,8 @@ fn effective_scale(snapshot: &ToolbarSnapshot) -> f64 { } } -fn top_default_width(snapshot: &ToolbarSnapshot) -> i32 { - top_toolbar_size(snapshot).0.min(i32::MAX as u32) as i32 +fn top_default_width(engine: &crate::ui_text::UiTextEngine, snapshot: &ToolbarSnapshot) -> i32 { + top_toolbar_size(engine, snapshot).0.min(i32::MAX as u32) as i32 } fn ring_row_active(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> bool { @@ -371,6 +371,7 @@ fn set_island_widget_id(_widget: &impl IsA, _island: model::TopToo } pub(in crate::toolbar_gtk) struct TopBar { + ui_text: crate::ui_text::UiTextEngine, pub(in crate::toolbar_gtk) window: gtk4::Window, feedback: FeedbackSender, root: gtk4::Box, @@ -482,6 +483,7 @@ impl TopBar { window.add_controller(hover); Self { + ui_text: crate::ui_text::UiTextEngine::default(), window, feedback, root, @@ -583,7 +585,7 @@ impl TopBar { self.base_x.set(update.top_base_x); self.apply_offsets(update.top_offset, update.top_offset_seq); - let plan = plan_top_strip(snapshot); + let plan = plan_top_strip(&self.ui_text, snapshot); let key = StructureKey::of(snapshot, &plan); if self.structure.as_ref() != Some(&key) { self.rebuild(snapshot, &plan); diff --git a/src/toolbar_gtk/view/top_bar/strip.rs b/src/toolbar_gtk/view/top_bar/strip.rs index 80f98fb82..1e96c6790 100644 --- a/src/toolbar_gtk/view/top_bar/strip.rs +++ b/src/toolbar_gtk/view/top_bar/strip.rs @@ -193,7 +193,7 @@ impl TopBar { // narrower layout (notably `simple`) does not keep the regular strip's // empty trailing area. Height remains content-driven for GTK popovers. self.window - .set_default_size(top_default_width(snapshot), -1); + .set_default_size(top_default_width(&self.ui_text, snapshot), -1); let scale = effective_scale(snapshot); let use_icons = snapshot.use_icons || plan.compact; let gap = if plan.compact { COMPACT_GAP } else { GAP }; diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index a10086d36..07257d884 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -20,7 +20,7 @@ fn top_structure_rebuilds_when_current_shortcuts_change() { &state, ToolbarBindingHints::from_input_state(&state), ); - let initial_plan = plan_top_strip(&initial); + let initial_plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &initial); let initial_key = StructureKey::of(&initial, &initial_plan); state.set_action_bindings(HashMap::from([( @@ -31,7 +31,7 @@ fn top_structure_rebuilds_when_current_shortcuts_change() { &state, ToolbarBindingHints::from_input_state(&state), ); - let changed_plan = plan_top_strip(&changed); + let changed_plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &changed); let changed_key = StructureKey::of(&changed, &changed_plan); assert!(initial_key != changed_key); @@ -202,7 +202,10 @@ fn top_structure_ignores_popover_only_section_visibility_changes() { &state, ToolbarBindingHints::from_input_state(&state), ); - let base_key = StructureKey::of(&base, &plan_top_strip(&base)); + let base_key = StructureKey::of( + &base, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &base), + ); for flag in [ ToolbarSectionFlag::Actions, @@ -236,7 +239,10 @@ fn top_structure_ignores_popover_only_section_visibility_changes() { } changed.resolved_toolbar_items.hidden.insert(flag.item_id()); changed.resolved_toolbar_items.shown.remove(&flag.item_id()); - let changed_key = StructureKey::of(&changed, &plan_top_strip(&changed)); + let changed_key = StructureKey::of( + &changed, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &changed), + ); assert!( base_key == changed_key, "popover-only {flag:?} visibility must not rebuild the top bar" @@ -262,8 +268,13 @@ fn top_structure_still_tracks_top_item_visibility() { .remove(&ids::TOP_TOOL_PEN); assert!( - StructureKey::of(&base, &plan_top_strip(&base)) - != StructureKey::of(&changed, &plan_top_strip(&changed)), + StructureKey::of( + &base, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &base) + ) != StructureKey::of( + &changed, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &changed) + ), "top-item visibility must still rebuild the top bar" ); } @@ -362,10 +373,13 @@ fn simple_layout_requests_its_smaller_natural_width() { ToolbarBindingHints::from_input_state(&state), ); - let regular_width = top_default_width(®ular); - let simple_width = top_default_width(&simple); + let regular_width = top_default_width(&crate::ui_text::UiTextEngine::default(), ®ular); + let simple_width = top_default_width(&crate::ui_text::UiTextEngine::default(), &simple); assert!(simple_width < regular_width); - assert_eq!(simple_width, top_toolbar_size(&simple).0 as i32); + assert_eq!( + simple_width, + top_toolbar_size(&crate::ui_text::UiTextEngine::default(), &simple).0 as i32 + ); } #[test] @@ -377,14 +391,14 @@ fn degraded_layout_requests_the_selected_plan_width() { ); snapshot.top_viewport_max = Some(700.0); - let plan = plan_top_strip(&snapshot); + let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &snapshot); let degraded = plan.compact || plan.drop_presets || !plan.dropped_tools.is_empty() || !plan.dropped_utilities.is_empty() || plan.swatch_count < 8; assert!(degraded, "the 700px budget must degrade the plan: {plan:?}"); - assert!(top_default_width(&snapshot) <= 700); + assert!(top_default_width(&crate::ui_text::UiTextEngine::default(), &snapshot) <= 700); } /// Colors left the strip for the pill (M7-C1); the presets island is the new @@ -1244,9 +1258,13 @@ fn builtin_semantic_records( snapshot: &ToolbarSnapshot, expected: &[SemanticAdapterRecord], ) -> Vec { - let (width, height) = top_toolbar_size(snapshot); - let tree = - crate::backend::wayland::build_top_toolbar_view(snapshot, width as f64, height as f64); + let (width, height) = top_toolbar_size(&crate::ui_text::UiTextEngine::default(), snapshot); + let tree = crate::backend::wayland::build_top_toolbar_view( + &crate::ui_text::UiTextEngine::default(), + snapshot, + width as f64, + height as f64, + ); let mut records = Vec::new(); for node in tree.nodes() { let raw_id = node.id.as_str(); @@ -1318,7 +1336,7 @@ fn shared_spec_matches_builtin_order_and_full_semantics_without_starting_a_gui() ("shapes", shapes), ("highlighted", highlighted), ] { - let plan = plan_top_strip(&snapshot); + let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), &snapshot); let spec = super::strip::top_toolbar_spec(&snapshot, &plan); let expected = expected_semantic_records(&snapshot, &spec, &plan); for record in &expected { @@ -1428,11 +1446,15 @@ fn style_pill_spec_matches_builtin_tree_across_morph_states() { } fn assert_builtin_style_pill_scenario(name: &str, snapshot: &ToolbarSnapshot) { - let plan = plan_top_strip(snapshot); + let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), snapshot); let expected = expected_style_pill_nodes(snapshot, &plan); - let (width, height) = top_toolbar_size(snapshot); - let tree = - crate::backend::wayland::build_top_toolbar_view(snapshot, width as f64, height as f64); + let (width, height) = top_toolbar_size(&crate::ui_text::UiTextEngine::default(), snapshot); + let tree = crate::backend::wayland::build_top_toolbar_view( + &crate::ui_text::UiTextEngine::default(), + snapshot, + width as f64, + height as f64, + ); assert_eq!( tree.node_by_id(&"top.island.style".into()).is_some(), @@ -1775,8 +1797,14 @@ fn top_structure_rebuilds_when_the_style_pill_morphs() { let pen = style_pill_tool_snapshot(®ular, Tool::Pen); let eraser = style_pill_tool_snapshot(®ular, Tool::Eraser); - let pen_key = StructureKey::of(&pen, &plan_top_strip(&pen)); - let eraser_key = StructureKey::of(&eraser, &plan_top_strip(&eraser)); + let pen_key = StructureKey::of( + &pen, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &pen), + ); + let eraser_key = StructureKey::of( + &eraser, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &eraser), + ); assert!( pen_key != eraser_key, "a pill morph change must rebuild the GTK bar structure" @@ -1786,7 +1814,10 @@ fn top_structure_rebuilds_when_the_style_pill_morphs() { // run through updaters, not rebuilds. let mut thicker = pen.clone(); thicker.thickness += 3.0; - let thicker_key = StructureKey::of(&thicker, &plan_top_strip(&thicker)); + let thicker_key = StructureKey::of( + &thicker, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &thicker), + ); assert!(pen_key == thicker_key, "value churn must not rebuild"); } @@ -1918,7 +1949,7 @@ fn assert_gtk_widget_scenario( snapshot: &ToolbarSnapshot, widths: &mut std::collections::BTreeMap<&'static str, i32>, ) { - let plan = plan_top_strip(snapshot); + let plan = plan_top_strip(&crate::ui_text::UiTextEngine::default(), snapshot); let spec = super::strip::top_toolbar_spec(snapshot, &plan); let expected = expected_semantic_records(snapshot, &spec, &plan); let style_controls = style_pill_controls(snapshot, &plan); @@ -2292,7 +2323,10 @@ fn assert_highlight_ring_event( highlighted: &ToolbarSnapshot, rx: &std::sync::mpsc::Receiver, ) { - top.build_strip(highlighted, &plan_top_strip(highlighted)); + top.build_strip( + highlighted, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), highlighted), + ); let ring = collect_semantic_widgets(top.root.upcast_ref()) .into_iter() .find(|widget| widget.widget_name() == ids::TOP_UTILITY_HIGHLIGHT_RING.as_str()) @@ -2329,7 +2363,10 @@ fn assert_eraser_pill_interactions( rx: &std::sync::mpsc::Receiver, ) { let eraser = style_pill_tool_snapshot(regular, Tool::Eraser); - top.build_strip(&eraser, &plan_top_strip(&eraser)); + top.build_strip( + &eraser, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &eraser), + ); let segment_row = pill_widget(top, "top.style.eraser-mode"); let mut halves = Vec::new(); let mut child = segment_row.first_child(); @@ -2379,7 +2416,10 @@ fn assert_pen_pill_interactions( rx: &std::sync::mpsc::Receiver, ) { let pen = style_pill_tool_snapshot(regular, Tool::Pen); - top.build_strip(&pen, &plan_top_strip(&pen)); + top.build_strip( + &pen, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &pen), + ); pill_widget(top, "top.style.color-chip") .downcast::() .expect("chip button") @@ -2446,7 +2486,10 @@ fn assert_shape_pill_interaction( rx: &std::sync::mpsc::Receiver, ) { let shape = style_pill_tool_snapshot(regular, Tool::Rect); - top.build_strip(&shape, &plan_top_strip(&shape)); + top.build_strip( + &shape, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &shape), + ); pill_widget(top, "top.style.fill") .downcast::() .expect("fill check button") @@ -2520,7 +2563,10 @@ fn assert_menu_popover_contracts(regular: &ToolbarSnapshot) { let (tx, menu_rx) = std::sync::mpsc::channel(); let mut menu_top = TopBar::new_for_test(FeedbackSender::new(tx)); // Building the strip creates the two overflow-anchored native popovers. - menu_top.build_strip(&session_snapshot, &plan_top_strip(&session_snapshot)); + menu_top.build_strip( + &session_snapshot, + &plan_top_strip(&crate::ui_text::UiTextEngine::default(), &session_snapshot), + ); assert!(menu_top.session_popover.is_some(), "session popover exists"); assert!( menu_top.settings_popover.is_some(), From 4147be7ce7b30736f1366f44d4473f855f7f9b2c Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:40:26 +0200 Subject: [PATCH 24/42] refactor(toolbar): reuse text resources throughout widget painting --- src/backend/wayland/toolbar/render/paint.rs | 50 +++-- .../wayland/toolbar/render/top_strip/mod.rs | 9 +- .../wayland/toolbar/render/top_strip/tests.rs | 182 ++++++++++++++++++ .../wayland/toolbar/render/widgets/buttons.rs | 5 +- .../toolbar/render/widgets/checkbox.rs | 10 +- .../wayland/toolbar/render/widgets/labels.rs | 23 ++- .../wayland/toolbar/render/widgets/tooltip.rs | 6 +- 7 files changed, 255 insertions(+), 30 deletions(-) create mode 100644 src/backend/wayland/toolbar/render/top_strip/tests.rs diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs index 37635b404..69ac32e55 100644 --- a/src/backend/wayland/toolbar/render/paint.rs +++ b/src/backend/wayland/toolbar/render/paint.rs @@ -8,7 +8,7 @@ use crate::backend::wayland::toolbar::view::{ ButtonStyle, ShortcutBadgePlacement, WidgetKind, WidgetNode, WidgetTree, }; -use crate::ui_text::UiTextStyle; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::widgets::constants::{ COLOR_ACCENT, COLOR_BADGE_BACKGROUND, COLOR_BADGE_BORDER, COLOR_ICON_DEFAULT, COLOR_LABEL_HINT, @@ -36,9 +36,14 @@ const TEXT_BUTTON_LABEL_INSET: f64 = 6.0; /// Paint every node of `tree` in order. `hover` is in the same logical space /// as the tree's rects. -pub fn paint_tree(ctx: &cairo::Context, tree: &WidgetTree, hover: Option<(f64, f64)>) { +pub fn paint_tree( + engine: &UiTextEngine, + ctx: &cairo::Context, + tree: &WidgetTree, + hover: Option<(f64, f64)>, +) { for node in tree.nodes() { - paint_node(ctx, node, hover); + paint_node(engine, ctx, node, hover); } } @@ -116,7 +121,7 @@ fn paint_preset_color_swatch( let _ = ctx.stroke(); } -fn paint_shortcut_badge(ctx: &cairo::Context, node: &WidgetNode) { +fn paint_shortcut_badge(engine: &UiTextEngine, ctx: &cairo::Context, node: &WidgetNode) { let Some(badge) = &node.shortcut_badge else { return; }; @@ -150,6 +155,7 @@ fn paint_shortcut_badge(ctx: &cairo::Context, node: &WidgetNode) { ShortcutBadgePlacement::Below => (9.0, COLOR_LABEL_HINT), }; draw_label_center_color( + engine, ctx, label_style(font_size, true), badge_x, @@ -161,7 +167,12 @@ fn paint_shortcut_badge(ctx: &cairo::Context, node: &WidgetNode) { ); } -fn paint_node(ctx: &cairo::Context, node: &WidgetNode, hover: Option<(f64, f64)>) { +fn paint_node( + engine: &UiTextEngine, + ctx: &cairo::Context, + node: &WidgetNode, + hover: Option<(f64, f64)>, +) { let (x, y, w, h) = node.rect; let is_hover = hovered(node, hover) && node.interact.is_some(); match &node.kind { @@ -208,27 +219,39 @@ fn paint_node(ctx: &cairo::Context, node: &WidgetNode, hover: Option<(f64, f64)> paint_button_body(ctx, node.rect, *style, is_hover); let text_style = label_style(label.size, label.bold); let display = ellipsize_to_width( + engine, ctx, text_style, &label.text, (w - TEXT_BUTTON_LABEL_INSET * 2.0).max(0.0), ); if style.disabled { - draw_label_center_color(ctx, text_style, x, y, w, h, &display, COLOR_TEXT_DISABLED); + draw_label_center_color( + engine, + ctx, + text_style, + x, + y, + w, + h, + &display, + COLOR_TEXT_DISABLED, + ); } else { - draw_label_center(ctx, text_style, x, y, w, h, &display); + draw_label_center(engine, ctx, text_style, x, y, w, h, &display); } } WidgetKind::Label(label) => { let text_style = label_style(label.size, label.bold); if label.wrap { - draw_label_left_wrapped(ctx, text_style, x, y, w, h, &label.text); + draw_label_left_wrapped(engine, ctx, text_style, x, y, w, h, &label.text); } else { - draw_label_left(ctx, text_style, x, y, w, h, &label.text); + draw_label_left(engine, ctx, text_style, x, y, w, h, &label.text); } } WidgetKind::MiniCheckbox { checked, label } => { draw_mini_checkbox( + engine, ctx, x, y, @@ -242,6 +265,7 @@ fn paint_node(ctx: &cairo::Context, node: &WidgetNode, hover: Option<(f64, f64)> } WidgetKind::Checkbox { checked, label } => { draw_checkbox( + engine, ctx, x, y, @@ -267,6 +291,7 @@ fn paint_node(ctx: &cairo::Context, node: &WidgetNode, hover: Option<(f64, f64)> } }); draw_segmented_control( + engine, ctx, x, y, @@ -349,6 +374,7 @@ fn paint_node(ctx: &cairo::Context, node: &WidgetNode, hover: Option<(f64, f64)> // color, inviting a save. None => { draw_label_center_color( + engine, ctx, label_style(FONT_SIZE_LABEL, true), x, @@ -403,7 +429,7 @@ fn paint_node(ctx: &cairo::Context, node: &WidgetNode, hover: Option<(f64, f64)> let _ = ctx.fill(); } } - paint_shortcut_badge(ctx, node); + paint_shortcut_badge(engine, ctx, node); } #[cfg(test)] @@ -453,7 +479,7 @@ mod tests { selected: false, }, ); - paint_node(&ctx, &node, None); + paint_node(&UiTextEngine::default(), &ctx, &node, None); } let mut surface = surface; pixel_at(&mut surface, 4 + size as i32 / 2, 4 + size as i32 / 2) @@ -477,7 +503,7 @@ mod tests { style: ButtonStyle::plain(), }, ); - paint_node(&ctx, &node, None); + paint_node(&UiTextEngine::default(), &ctx, &node, None); } let mut surface = surface; diff --git a/src/backend/wayland/toolbar/render/top_strip/mod.rs b/src/backend/wayland/toolbar/render/top_strip/mod.rs index 5dcda36a9..5dda36b17 100644 --- a/src/backend/wayland/toolbar/render/top_strip/mod.rs +++ b/src/backend/wayland/toolbar/render/top_strip/mod.rs @@ -35,13 +35,16 @@ pub fn render_top_strip( let fade = snapshot.top_fade.clamp(0.0, 1.0); if fade < 1.0 { ctx.push_group(); - paint_tree(ctx, &tree, hover); + paint_tree(engine, ctx, &tree, hover); let _ = ctx.pop_group_to_source(); let _ = ctx.paint_with_alpha(fade); } else { - paint_tree(ctx, &tree, hover); + paint_tree(engine, ctx, &tree, hover); } hits.extend(tree.to_hit_regions()); - draw_tooltip_with_delay(ctx, hits, hover, width, height, false, hover_start); + draw_tooltip_with_delay(engine, ctx, hits, hover, width, height, false, hover_start); Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/src/backend/wayland/toolbar/render/top_strip/tests.rs b/src/backend/wayland/toolbar/render/top_strip/tests.rs new file mode 100644 index 000000000..88650c534 --- /dev/null +++ b/src/backend/wayland/toolbar/render/top_strip/tests.rs @@ -0,0 +1,182 @@ +use super::*; +use crate::backend::wayland::toolbar::view::WidgetTree; +use crate::backend::wayland::toolbar::view::node::{ + ButtonStyle, LabelSpec, ShortcutBadgePlacement, WidgetKind, WidgetNode, +}; +use crate::ui::toolbar::ToolbarBindingHints; + +fn pixels(density: i32, paint: impl FnOnce(&cairo::Context)) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 1400 * density, 800 * density).unwrap(); + surface.set_device_scale(density as f64, density as f64); + let ctx = cairo::Context::new(&surface).unwrap(); + paint(&ctx); + drop(ctx); + surface.data().unwrap().to_vec() +} + +#[test] +fn retained_widget_text_matches_fresh_targets() { + let engine = UiTextEngine::default(); + let mut tree = WidgetTree::new((400.0, 500.0)); + let label = || { + LabelSpec::new( + "Toolbar 測試 label with enough words to truncate", + 14.0, + true, + ) + }; + let kinds = [ + WidgetKind::TextButton { + label: label(), + style: ButtonStyle::plain(), + }, + WidgetKind::TextButton { + label: label(), + style: ButtonStyle::disabled(), + }, + WidgetKind::Label(label().wrapped()), + WidgetKind::Label(LabelSpec::new("Plain label", 13.0, false)), + WidgetKind::Checkbox { + checked: true, + label: label(), + }, + WidgetKind::MiniCheckbox { + checked: true, + label: LabelSpec::new("Mini 測試", 12.0, false), + }, + WidgetKind::SegmentedControl { + left: LabelSpec::new("Left", 12.0, false), + right: LabelSpec::new("Right", 12.0, false), + active_right: true, + }, + WidgetKind::PresetSlot { + glyph: None, + color: (1.0, 0.0, 0.0, 1.0), + label: "2".into(), + active: false, + }, + ]; + for (index, kind) in kinds.into_iter().enumerate() { + tree.push( + WidgetNode::decor( + format!("fixture.{index}"), + (20.0, 20.0 + index as f64 * 60.0, 160.0, 55.0), + kind, + ) + .with_shortcut_badge( + Some("K"), + if index % 2 == 0 { + ShortcutBadgePlacement::Corner + } else { + ShortcutBadgePlacement::Below + }, + ), + ); + } + for density in [1, 2, 1] { + let actual = pixels(density, |ctx| { + paint_tree(&engine, ctx, &tree, Some((80.0, 390.0))) + }); + let expected = pixels(density, |ctx| { + paint_tree(&UiTextEngine::default(), ctx, &tree, Some((80.0, 390.0))) + }); + assert!(actual == expected, "widget text at density {density}"); + assert!(actual.iter().any(|byte| *byte != 0)); + } +} + +#[test] +fn retained_top_strip_preserves_fade_hits_and_delayed_tooltips_across_targets() { + let engine = UiTextEngine::default(); + let mut input = crate::input::state::test_support::make_test_input_state(); + input.set_toolbar_use_icons(false); + let mut snapshot = + ToolbarSnapshot::from_input_with_bindings(&input, ToolbarBindingHints::default()); + snapshot.settings_popover_open = true; + for fade in [1.0, 0.45] { + snapshot.top_fade = fade; + let (width, height) = crate::backend::wayland::toolbar::top_size(&engine, &snapshot); + let tree = view::top::build_top_view(&engine, &snapshot, width as f64, height as f64); + let planned_hits = tree.to_hit_regions(); + let hovered = planned_hits + .iter() + .find(|hit| hit.tooltip.is_some()) + .unwrap(); + let hover = Some(( + hovered.rect.0 + hovered.rect.2 / 2.0, + hovered.rect.1 + hovered.rect.3 / 2.0, + )); + for density in [1, 2, 1] { + let mut hits = Vec::new(); + let start = Some( + Instant::now() - super::super::TOOLTIP_DELAY - std::time::Duration::from_secs(1), + ); + let actual = pixels(density, |ctx| { + render_top_strip( + &engine, + ctx, + width as f64, + height as f64, + &snapshot, + &mut hits, + hover, + start, + ) + .unwrap() + }); + let mut fresh_hits = Vec::new(); + let expected = pixels(density, |ctx| { + render_top_strip( + &UiTextEngine::default(), + ctx, + width as f64, + height as f64, + &snapshot, + &mut fresh_hits, + hover, + start, + ) + .unwrap() + }); + assert!(actual == expected, "strip density {density}, fade {fade}"); + assert_eq!(hits.len(), planned_hits.len()); + assert_eq!(hits.len(), fresh_hits.len()); + for ((actual, fresh), planned) in hits.iter().zip(&fresh_hits).zip(&planned_hits) { + assert_eq!(actual.rect, planned.rect); + assert_eq!(actual.rect, fresh.rect); + assert_eq!(actual.event, planned.event); + assert_eq!(actual.kind, planned.kind); + assert_eq!(actual.focus_id, planned.focus_id); + assert_eq!(actual.tooltip, planned.tooltip); + } + let tooltip = pixels(density, |ctx| { + draw_tooltip_with_delay( + &engine, + ctx, + &hits, + hover, + width as f64, + height as f64, + false, + start, + ) + }); + assert!(tooltip.iter().any(|byte| *byte != 0)); + // A future start deterministically exercises the not-yet-ready path. + let hidden = pixels(density, |ctx| { + draw_tooltip_with_delay( + &engine, + ctx, + &hits, + hover, + width as f64, + height as f64, + false, + Some(Instant::now() + std::time::Duration::from_secs(60)), + ) + }); + assert!(hidden.iter().all(|byte| *byte == 0)); + } + } +} diff --git a/src/backend/wayland/toolbar/render/widgets/buttons.rs b/src/backend/wayland/toolbar/render/widgets/buttons.rs index 0b95412b2..84a52ea01 100644 --- a/src/backend/wayland/toolbar/render/widgets/buttons.rs +++ b/src/backend/wayland/toolbar/render/widgets/buttons.rs @@ -10,7 +10,7 @@ use super::constants::{ }; use super::draw_round_rect; use crate::ui::theme::{DESTRUCTIVE_RGB, Rgba, rgba}; -use crate::ui_text::{UiTextStyle, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; use std::f64::consts::PI; /// Faint white glow behind a hovered flat button (quieter than @@ -265,6 +265,7 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_destructive_button( /// Returns nothing but renders the control with proper active/hover states. #[allow(clippy::too_many_arguments)] pub(in crate::backend::wayland::toolbar::render) fn draw_segmented_control( + engine: &UiTextEngine, ctx: &cairo::Context, x: f64, y: f64, @@ -344,7 +345,7 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_segmented_control( let label_x = if i == 0 { x } else { x + segment_w }; // Center the label in the segment - let layout = text_layout(ctx, label_style, label, None); + let layout = engine.layout(ctx, label_style, label, None); let ext = layout.ink_extents(); let tx = label_x + (segment_w - ext.width()) / 2.0 - ext.x_bearing(); let ty = y + (h - ext.height()) / 2.0 - ext.y_bearing(); diff --git a/src/backend/wayland/toolbar/render/widgets/checkbox.rs b/src/backend/wayland/toolbar/render/widgets/checkbox.rs index 50280b542..02c99cb9d 100644 --- a/src/backend/wayland/toolbar/render/widgets/checkbox.rs +++ b/src/backend/wayland/toolbar/render/widgets/checkbox.rs @@ -5,10 +5,11 @@ use super::constants::{ SPACING_SM, SPACING_XS, set_color, }; use super::{draw_label_left, draw_round_rect, ellipsize_to_width}; -use crate::ui_text::{UiTextStyle, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; #[allow(clippy::too_many_arguments)] pub(in crate::backend::wayland::toolbar::render) fn draw_checkbox( + engine: &UiTextEngine, ctx: &cairo::Context, x: f64, y: f64, @@ -47,12 +48,13 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_checkbox( let label_x = box_x + box_size + SPACING_LG; // Never let a long label escape the cell: truncate with an ellipsis. let label_w = (x + w - label_x - SPACING_LG).max(0.0); - let display = ellipsize_to_width(ctx, label_style, label, label_w); - draw_label_left(ctx, label_style, label_x, y, label_w, h, &display); + let display = ellipsize_to_width(engine, ctx, label_style, label, label_w); + draw_label_left(engine, ctx, label_style, label_x, y, label_w, h, &display); } #[allow(clippy::too_many_arguments)] pub(in crate::backend::wayland::toolbar::render) fn draw_mini_checkbox( + engine: &UiTextEngine, ctx: &cairo::Context, x: f64, y: f64, @@ -89,7 +91,7 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_mini_checkbox( let _ = ctx.stroke(); } - let layout = text_layout(ctx, label_style, label, None); + let layout = engine.layout(ctx, label_style, label, None); let ext = layout.ink_extents(); let label_x = x + box_size + SPACING_LG + (w - box_size - 12.0 - ext.width()) / 2.0; let label_y = y + (h + ext.height()) / 2.0; diff --git a/src/backend/wayland/toolbar/render/widgets/labels.rs b/src/backend/wayland/toolbar/render/widgets/labels.rs index 0e8c5ada7..dade103bf 100644 --- a/src/backend/wayland/toolbar/render/widgets/labels.rs +++ b/src/backend/wayland/toolbar/render/widgets/labels.rs @@ -1,7 +1,9 @@ use super::constants::{COLOR_TEXT_PRIMARY, set_color}; -use crate::ui_text::{UiTextStyle, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; +#[allow(clippy::too_many_arguments)] pub(in crate::backend::wayland::toolbar::render) fn draw_label_center( + engine: &UiTextEngine, ctx: &cairo::Context, style: UiTextStyle<'_>, x: f64, @@ -10,7 +12,7 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_label_center( h: f64, text: &str, ) { - let layout = text_layout(ctx, style, text, None); + let layout = engine.layout(ctx, style, text, None); let ext = layout.ink_extents(); let tx = x + (w - ext.width()) / 2.0 - ext.x_bearing(); let ty = y + (h - ext.height()) / 2.0 - ext.y_bearing(); @@ -20,6 +22,7 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_label_center( #[allow(clippy::too_many_arguments)] pub(in crate::backend::wayland::toolbar::render) fn draw_label_center_color( + engine: &UiTextEngine, ctx: &cairo::Context, style: UiTextStyle<'_>, x: f64, @@ -29,7 +32,7 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_label_center_color( text: &str, color: (f64, f64, f64, f64), ) { - let layout = text_layout(ctx, style, text, None); + let layout = engine.layout(ctx, style, text, None); let ext = layout.ink_extents(); let tx = x + (w - ext.width()) / 2.0 - ext.x_bearing(); let ty = y + (h - ext.height()) / 2.0 - ext.y_bearing(); @@ -39,6 +42,7 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_label_center_color( /// Truncate `text` with a trailing ellipsis so it fits `max_width`. pub(in crate::backend::wayland::toolbar::render) fn ellipsize_to_width( + engine: &UiTextEngine, ctx: &cairo::Context, style: UiTextStyle<'_>, text: &str, @@ -47,7 +51,7 @@ pub(in crate::backend::wayland::toolbar::render) fn ellipsize_to_width( if max_width <= 0.0 { return String::new(); } - if text_layout(ctx, style, text, None).ink_extents().width() <= max_width { + if engine.layout(ctx, style, text, None).ink_extents().width() <= max_width { return text.to_string(); } let mut chars: Vec = text.chars().collect(); @@ -55,7 +59,8 @@ pub(in crate::backend::wayland::toolbar::render) fn ellipsize_to_width( chars.pop(); let candidate: String = chars.iter().collect(); let candidate = format!("{candidate}..."); - if text_layout(ctx, style, &candidate, None) + if engine + .layout(ctx, style, &candidate, None) .ink_extents() .width() <= max_width @@ -66,7 +71,9 @@ pub(in crate::backend::wayland::toolbar::render) fn ellipsize_to_width( "...".to_string() } +#[allow(clippy::too_many_arguments)] pub(in crate::backend::wayland::toolbar::render) fn draw_label_left( + engine: &UiTextEngine, ctx: &cairo::Context, style: UiTextStyle<'_>, x: f64, @@ -75,14 +82,16 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_label_left( h: f64, text: &str, ) { - let layout = text_layout(ctx, style, text, None); + let layout = engine.layout(ctx, style, text, None); let ext = layout.ink_extents(); let ty = y + (h - ext.height()) / 2.0 - ext.y_bearing(); set_color(ctx, COLOR_TEXT_PRIMARY); layout.show_at_baseline(ctx, x, ty); } +#[allow(clippy::too_many_arguments)] pub(in crate::backend::wayland::toolbar::render) fn draw_label_left_wrapped( + engine: &UiTextEngine, ctx: &cairo::Context, style: UiTextStyle<'_>, x: f64, @@ -91,7 +100,7 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_label_left_wrapped( h: f64, text: &str, ) { - let layout = text_layout(ctx, style, text, Some(w)); + let layout = engine.layout(ctx, style, text, Some(w)); let ext = layout.ink_extents(); let ty = y + (h - ext.height()) / 2.0 - ext.y_bearing(); set_color(ctx, COLOR_TEXT_PRIMARY); diff --git a/src/backend/wayland/toolbar/render/widgets/tooltip.rs b/src/backend/wayland/toolbar/render/widgets/tooltip.rs index 7bdf4fdb1..17d5179da 100644 --- a/src/backend/wayland/toolbar/render/widgets/tooltip.rs +++ b/src/backend/wayland/toolbar/render/widgets/tooltip.rs @@ -8,9 +8,11 @@ use super::constants::{ use super::draw_round_rect; use crate::backend::wayland::toolbar::hit::HitRegion; use crate::backend::wayland::toolbar::render::TOOLTIP_DELAY; -use crate::ui_text::{UiTextStyle, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; +#[allow(clippy::too_many_arguments)] pub(in crate::backend::wayland::toolbar::render) fn draw_tooltip_with_delay( + engine: &UiTextEngine, ctx: &cairo::Context, hits: &[HitRegion], hover: Option<(f64, f64)>, @@ -41,7 +43,7 @@ pub(in crate::backend::wayland::toolbar::render) fn draw_tooltip_with_delay( let pad = SPACING_STD; let max_tooltip_w = (panel_width - SPACING_LG).max(40.0); let max_text_w = (max_tooltip_w - pad * 2.0).max(20.0); - let layout = text_layout(ctx, style, text, Some(max_text_w)); + let layout = engine.layout(ctx, style, text, Some(max_text_w)); let ink_extents = layout.ink_extents(); let text_w = ink_extents.width().max(1.0); let text_h = ink_extents.height().max(1.0); From d4c3ca63bf74c60e531131ae1377369cebedd323 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:40:34 +0200 Subject: [PATCH 25/42] refactor(text): share resources across region selection chrome --- src/backend/wayland/state/ocr.rs | 1 + src/backend/wayland/state/render/ui.rs | 1 + src/ui/primitives.rs | 41 ++------- src/ui/region_action_bar.rs | 112 +++++++++++++++++++++---- src/ui/region_capture_picker.rs | 89 ++++++++++++++++++-- 5 files changed, 188 insertions(+), 56 deletions(-) diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs index 065e1a109..1afe8fe5d 100644 --- a/src/backend/wayland/state/ocr.rs +++ b/src/backend/wayland/state/ocr.rs @@ -585,6 +585,7 @@ impl WaylandState { // dismisses on the first drag through the shared selector state. if self.config.capture.region.show_legend && !self.region_picker_legend_dismissed() { crate::ui::render_region_legend( + self.render.ui_text(), ctx, (screen_width, screen_height), crate::ui::OCR_LEGEND_TEXT, diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index c958ae798..f25826303 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -567,6 +567,7 @@ impl WaylandState { }; crate::ui::render_region_capture_picker( + self.render.ui_text(), ctx, width, height, diff --git a/src/ui/primitives.rs b/src/ui/primitives.rs index e0ad210d9..2d23d6611 100644 --- a/src/ui/primitives.rs +++ b/src/ui/primitives.rs @@ -1,20 +1,7 @@ use std::f64::consts::{FRAC_PI_2, PI}; use crate::ui::theme::{self, Rgba}; -use crate::ui_text::{UiTextEngine, UiTextStyle, with_legacy_engine}; - -pub(crate) fn text_extents_for( - ctx: &cairo::Context, - family: &str, - slant: cairo::FontSlant, - weight: cairo::FontWeight, - size: f64, - text: &str, -) -> cairo::TextExtents { - with_legacy_engine(|engine| { - text_extents_for_with_engine(engine, ctx, family, slant, weight, size, text) - }) -} +use crate::ui_text::{UiTextEngine, UiTextStyle}; pub(crate) fn text_extents_for_with_engine( engine: &UiTextEngine, @@ -254,17 +241,13 @@ pub(crate) fn draw_pill( } /// Keycap chip interior padding, as fractions of the label font size. -/// Shared by [`keycap_size`] and [`draw_keycap`] so pre-measured centering +/// Shared by [`keycap_size_with_engine`] and [`draw_keycap_with_engine`] so pre-measured centering /// can never drift from the drawn chip. const KEYCAP_PAD_X_FACTOR: f64 = 0.5; const KEYCAP_PAD_Y_FACTOR: f64 = 0.3; -/// Measured (width, height) the [`draw_keycap`] chip occupies for `label` at +/// Measured (width, height) the [`draw_keycap_with_engine`] chip occupies for `label` at /// `font_size`, for callers that need to center the chip before drawing it. -pub(crate) fn keycap_size(ctx: &cairo::Context, label: &str, font_size: f64) -> (f64, f64) { - with_legacy_engine(|engine| keycap_size_with_engine(engine, ctx, label, font_size)) -} - pub(crate) fn keycap_size_with_engine( engine: &UiTextEngine, ctx: &cairo::Context, @@ -292,20 +275,6 @@ pub(crate) fn keycap_size_with_engine( /// Draw a flat keycap chip (rounded rect + centered label) and return its /// (width, height). The single keycap language that replaces the per-surface /// badge renderings as surfaces migrate (M2+). -pub(crate) fn draw_keycap( - ctx: &cairo::Context, - x: f64, - y: f64, - label: &str, - font_size: f64, - fill: Rgba, - text_color: Rgba, -) -> (f64, f64) { - with_legacy_engine(|engine| { - draw_keycap_with_engine(engine, ctx, x, y, label, font_size, fill, text_color) - }) -} - #[allow(clippy::too_many_arguments)] pub(crate) fn draw_keycap_with_engine( engine: &UiTextEngine, @@ -358,7 +327,7 @@ pub(crate) fn keycap_text_style(font_size: f64) -> UiTextStyle<'static> { } } -/// [`keycap_size`] without a rendering context, for callers that lay out +/// [`keycap_size_with_engine`] without a rendering context, for callers that lay out /// before a frame buffer exists (damage geometry). Goes through the shared /// measurement cache, so it agrees with the drawn chip exactly. pub(crate) fn keycap_box_size( @@ -375,7 +344,7 @@ pub(crate) fn keycap_box_size( /// Draw a keycap chip into a caller-provided box, centering the label inside /// it. Rows of chips use this so a shared row height survives labels with -/// different ascenders and descenders; [`draw_keycap`] is the natural-size +/// different ascenders and descenders; [`draw_keycap_with_engine`] is the natural-size /// shorthand over the same chrome. #[allow(clippy::too_many_arguments)] pub(crate) fn draw_keycap_in_box( diff --git a/src/ui/region_action_bar.rs b/src/ui/region_action_bar.rs index 67225ff0a..6dc73147d 100644 --- a/src/ui/region_action_bar.rs +++ b/src/ui/region_action_bar.rs @@ -1,8 +1,8 @@ use crate::input::state::RegionSelection; use crate::ui::theme::{self, Rgba, overlay}; -use crate::ui_text::{UiTextStyle, text_layout}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; -use super::primitives::{draw_keycap, draw_rounded_rect, keycap_size}; +use super::primitives::{draw_keycap_with_engine, draw_rounded_rect, keycap_size_with_engine}; const SURFACE_MARGIN: f64 = 8.0; const SELECTION_GAP: f64 = 12.0; @@ -362,6 +362,7 @@ impl RegionActionBar { } pub(crate) fn render_region_action_bar( + engine: &UiTextEngine, ctx: &cairo::Context, bar: &RegionActionBar, visual: RegionActionBarVisual, @@ -371,6 +372,7 @@ pub(crate) fn render_region_action_bar( for &item in &bar.items { draw_action( + engine, ctx, item, visual.hovered == Some(item.action), @@ -386,6 +388,7 @@ pub(crate) fn render_region_action_bar( ); for &item in &bar.edit { draw_action( + engine, ctx, item, visual.hovered == Some(item.action), @@ -399,8 +402,14 @@ pub(crate) fn render_region_action_bar( bar.toggle.bounds.y, bar.toggle.bounds.width, ); - draw_toggle(ctx, bar.toggle, visual.hovered, visual.include_drawings); - draw_status(ctx, bar, visual.status); + draw_toggle( + engine, + ctx, + bar.toggle, + visual.hovered, + visual.include_drawings, + ); + draw_status(engine, ctx, bar, visual.status); let _ = ctx.restore(); } @@ -467,6 +476,7 @@ fn draw_row_divider(ctx: &cairo::Context, row: RegionActionRect, next_y: f64, wi } fn draw_action( + engine: &UiTextEngine, ctx: &cairo::Context, item: RegionActionItem, hovered: bool, @@ -515,12 +525,18 @@ fn draw_action( ); let _ = ctx.stroke(); - draw_action_content(ctx, item, primary, enabled); + draw_action_content(engine, ctx, item, primary, enabled); } /// Label over keycap, the pair centred as one block so every control's text /// sits on the same optical line regardless of ascenders or descenders. -fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bool, enabled: bool) { +fn draw_action_content( + engine: &UiTextEngine, + ctx: &cairo::Context, + item: RegionActionItem, + primary: bool, + enabled: bool, +) { let _ = ctx.save(); ctx.rectangle( item.bounds.x, @@ -532,13 +548,13 @@ fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bo let center_x = item.bounds.x + item.bounds.width / 2.0; let label = item.action.label(); - let layout = text_layout(ctx, label_style(), label, None); + let layout = engine.layout(ctx, label_style(), label, None); let label_extents = layout.ink_extents(); let shortcut = item.action.shortcut(); let (keycap_width, keycap_height) = if shortcut.is_empty() { (0.0, 0.0) } else { - keycap_size(ctx, shortcut, KEYCAP_FONT_SIZE) + keycap_size_with_engine(engine, ctx, shortcut, KEYCAP_FONT_SIZE) }; let stack_height = if shortcut.is_empty() { @@ -563,7 +579,8 @@ fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bo ); if !shortcut.is_empty() { - draw_keycap( + draw_keycap_with_engine( + engine, ctx, center_x - keycap_width / 2.0, stack_top + label_extents.height() + LABEL_KEYCAP_GAP, @@ -585,6 +602,7 @@ fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bo } fn draw_toggle( + engine: &UiTextEngine, ctx: &cairo::Context, item: RegionActionItem, hovered: Option, @@ -623,7 +641,7 @@ fn draw_toggle( draw_checkbox(ctx, box_x, box_y, box_size, checked); let label = item.action.label(); - let layout = text_layout(ctx, toggle_label_style(), label, None); + let layout = engine.layout(ctx, toggle_label_style(), label, None); let extents = layout.ink_extents(); theme::set_color( ctx, @@ -639,8 +657,10 @@ fn draw_toggle( item.bounds.y + (item.bounds.height - extents.height()) / 2.0 - extents.y_bearing(), ); - let (keycap_width, keycap_height) = keycap_size(ctx, item.action.shortcut(), KEYCAP_FONT_SIZE); - draw_keycap( + let (keycap_width, keycap_height) = + keycap_size_with_engine(engine, ctx, item.action.shortcut(), KEYCAP_FONT_SIZE); + draw_keycap_with_engine( + engine, ctx, item.bounds.x + item.bounds.width - 6.0 - keycap_width, item.bounds.y + (item.bounds.height - keycap_height) / 2.0, @@ -699,7 +719,12 @@ fn draw_checkbox(ctx: &cairo::Context, x: f64, y: f64, size: f64, checked: bool) let _ = ctx.stroke(); } -fn draw_status(ctx: &cairo::Context, bar: &RegionActionBar, status: Option) { +fn draw_status( + engine: &UiTextEngine, + ctx: &cairo::Context, + bar: &RegionActionBar, + status: Option, +) { let Some(status) = status else { return; }; @@ -713,7 +738,7 @@ fn draw_status(ctx: &cairo::Context, bar: &RegionActionBar, status: Option Date: Sat, 5 Sep 2026 09:53:15 +0200 Subject: [PATCH 26/42] refactor(input): retain text resources through keyboard and action routing --- .../runtime_ui_state/tests/layout_state.rs | 10 +- .../tests/visibility_recovery.rs | 10 +- src/backend/wayland/state/input_actions.rs | 24 +- .../wayland/state/toolbar/events/tests.rs | 18 +- src/input/state/actions/action_board_pages.rs | 56 +-- src/input/state/actions/action_colors.rs | 20 +- src/input/state/actions/action_core.rs | 12 +- src/input/state/actions/action_dispatch.rs | 14 +- src/input/state/actions/action_history.rs | 28 +- src/input/state/actions/action_presets.rs | 16 +- src/input/state/actions/action_selection.rs | 44 +- src/input/state/actions/action_tools.rs | 22 +- src/input/state/actions/action_ui.rs | 46 +- src/input/state/actions/key_press/mod.rs | 24 +- src/input/state/actions/key_press/panels.rs | 78 ++-- .../state/actions/key_press/text_input.rs | 24 +- src/input/state/core/base/state/modifiers.rs | 10 +- .../state/core/board/delete_restore/page.rs | 10 - src/input/state/core/board/pages.rs | 15 - .../state/core/board_picker/state/actions.rs | 75 +++- .../state/core/board_picker/state/edit.rs | 38 +- .../state/core/board_picker/state/nav.rs | 18 +- src/input/state/core/command_palette/input.rs | 29 +- src/input/state/core/command_palette/mod.rs | 407 ++++++++++++++++-- src/input/state/core/font_cycle.rs | 18 +- src/input/state/core/font_picker/input.rs | 9 +- src/input/state/core/font_picker/tests.rs | 60 ++- src/input/state/core/history.rs | 3 +- src/input/state/core/menus/commands.rs | 91 ++-- src/input/state/core/menus/focus.rs | 7 +- src/input/state/core/modal.rs | 3 +- src/input/state/core/properties/apply.rs | 20 - .../apply_selection/actions/color.rs | 9 +- src/input/state/core/properties/panel.rs | 6 +- .../core/properties/panel_layout/focus.rs | 3 +- src/input/state/core/radial_menu/state.rs | 58 ++- .../selection_actions/arrow_bend/tests.rs | 4 +- .../state/core/selection_actions/delete.rs | 4 - .../state/core/selection_actions/reorder.rs | 10 +- .../state/core/selection_actions/state.rs | 6 +- .../core/selection_actions/translation/mod.rs | 16 - .../core/tool_controls/precision_entry.rs | 52 ++- src/input/state/core/tool_controls/toolbar.rs | 66 ++- src/input/state/core/toolbar/apply/actions.rs | 59 ++- src/input/state/core/toolbar/apply/boards.rs | 30 +- src/input/state/core/toolbar/apply/layout.rs | 37 -- src/input/state/core/toolbar/apply/mod.rs | 147 +++++-- src/input/state/core/toolbar/apply/pages.rs | 22 +- src/input/state/core/toolbar/apply/tools.rs | 76 +--- src/input/state/core/utility/focus_mode.rs | 6 +- src/input/state/core/utility/light_mode.rs | 4 - .../state/core/utility/presenter_mode.rs | 6 +- src/input/state/interaction/actions.rs | 8 +- .../state/interaction/adapters/actions.rs | 23 +- .../state/interaction/adapters/keyboard.rs | 58 ++- src/input/state/interaction/keyboard.rs | 64 ++- src/input/state/interaction/mod.rs | 20 +- src/input/state/mouse/press/polygon.rs | 20 +- src/input/state/tests/board_picker.rs | 110 +++-- src/input/state/tests/drawing.rs | 3 +- src/input/state/tests/focus_mode.rs | 8 +- src/input/state/tests/input_hud.rs | 28 +- src/input/state/tests/menus/context_menu.rs | 22 +- src/input/state/tests/menus/history.rs | 11 +- src/input/state/tests/presenter_mode.rs | 78 +++- src/input/state/tests/pressure_modes.rs | 24 +- src/input/state/tests/properties_panel.rs | 84 ++-- src/input/state/tests/selection/deletion.rs | 3 +- .../tests/status_hud/engine_mutations.rs | 49 ++- src/input/state/tests/text_input/editing.rs | 6 +- src/input/state/tests/toolbar_display.rs | 50 ++- src/input/state/tests/transform.rs | 3 +- src/input/state/text_resources/tests.rs | 123 +++++- src/session/tests/history.rs | 3 +- 74 files changed, 1868 insertions(+), 810 deletions(-) diff --git a/src/backend/wayland/runtime_ui_state/tests/layout_state.rs b/src/backend/wayland/runtime_ui_state/tests/layout_state.rs index b36542650..c67e070bf 100644 --- a/src/backend/wayland/runtime_ui_state/tests/layout_state.rs +++ b/src/backend/wayland/runtime_ui_state/tests/layout_state.rs @@ -201,6 +201,12 @@ fn a_stored_display_mode_is_restored_at_startup_over_the_config_seed() { } #[test] fn a_display_mode_change_during_presenter_mode_stores_the_pre_presenter_value() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; use crate::config::{PresenterToolbarMode, TopDisplayMode}; let temp = crate::test_temp::tempdir().unwrap(); @@ -210,7 +216,7 @@ fn a_display_mode_change_during_presenter_mode_stores_the_pre_presenter_value() input.presenter_mode_config_mut_for_test().hide_toolbars = true; input.presenter_mode_config_mut_for_test().toolbar_mode = PresenterToolbarMode::Micro; input.test_set_toolbar_display_state(TopDisplayMode::Full, input.toolbar_top_minimized()); - input.toggle_presenter_mode(); + input.toggle_presenter_mode_with_resources(route_resources); assert_eq!(input.toolbar_top_display_mode(), TopDisplayMode::Micro); // The live strip is presenter's; the persisted value stays the saved @@ -236,7 +242,7 @@ fn a_display_mode_change_during_presenter_mode_stores_the_pre_presenter_value() // Exiting presenter mode restores the live value; a change after that // persists the user's own choice again. - input.toggle_presenter_mode(); + input.toggle_presenter_mode_with_resources(route_resources); assert!(!input.presenter_restore_pending()); assert!(matches!( commit_display_mode(&mut runtime, &mut input, TopDisplayMode::Micro), diff --git a/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs b/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs index 91420b8cc..cc391b8af 100644 --- a/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs +++ b/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs @@ -96,6 +96,12 @@ fn an_exit_during_retry_pending_recovery_still_lands_the_deferred_toggle() { /// screen agreeing with the rolled-back pins. #[test] fn a_deferred_hide_rollback_lands_in_the_presenter_restore_snapshot() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; use crate::domain::Action; let config = Config::default(); @@ -107,7 +113,7 @@ fn a_deferred_hide_rollback_lands_in_the_presenter_restore_snapshot() { input.take_pending_toolbar_persistence(); // the write whose rollback arrives below input.presenter_mode_config_mut_for_test().hide_toolbars = true; - input.toggle_presenter_mode(); + input.toggle_presenter_mode_with_resources(route_resources); assert!(input.presenter_mode_active()); apply_toolbar_runtime_rollback( @@ -123,7 +129,7 @@ fn a_deferred_hide_rollback_lands_in_the_presenter_restore_snapshot() { "the live presenter-hidden flags must not move under the owner" ); - input.toggle_presenter_mode(); + input.toggle_presenter_mode_with_resources(route_resources); assert!(!input.presenter_mode_active()); assert!( input.toolbar_visible() && input.toolbar_top_visible(), diff --git a/src/backend/wayland/state/input_actions.rs b/src/backend/wayland/state/input_actions.rs index a2d7bddc6..e50253b3c 100644 --- a/src/backend/wayland/state/input_actions.rs +++ b/src/backend/wayland/state/input_actions.rs @@ -3,7 +3,7 @@ use crate::{ config::Action, input::{ InputState, Key, - state::{InputEffect, InputEffectDrain}, + state::{InputEffect, InputEffectDrain, InputTextResources}, }, }; @@ -33,23 +33,35 @@ impl InputHudSnapshot { impl WaylandState { pub(in crate::backend::wayland) fn apply_input_key(&mut self, key: Key) { - self.apply_input_update(|input_state| input_state.on_key_press(key)); + self.apply_input_update(|input_state, resources| { + input_state.on_key_press_with_resources(resources, key) + }); } pub(in crate::backend::wayland) fn apply_input_key_repeat(&mut self, key: Key) { - self.apply_input_update(|input_state| input_state.on_key_repeat(key)); + self.apply_input_update(|input_state, resources| { + input_state.on_key_repeat_with_resources(resources, key) + }); } pub(in crate::backend::wayland) fn dispatch_input_action(&mut self, action: Action) { - self.apply_input_update(|input_state| input_state.handle_action(action)); + self.apply_input_update(|input_state, resources| { + input_state.handle_action_with_resources(resources, action) + }); } - fn apply_input_update(&mut self, update: impl FnOnce(&mut InputState)) { + fn apply_input_update(&mut self, update: impl FnOnce(&mut InputState, InputTextResources<'_>)) { #[cfg(feature = "tablet-input")] let prev_thickness = self.input_state.style.current_thickness; let hud_before = InputHudSnapshot::from_input_state(&self.input_state); - update(&mut self.input_state); + update( + &mut self.input_state, + InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + ); self.input_state.needs_redraw = true; self.sync_overlay_interactivity(); diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs index 63459b3f5..b4a7730ea 100644 --- a/src/backend/wayland/state/toolbar/events/tests.rs +++ b/src/backend/wayland/state/toolbar/events/tests.rs @@ -326,6 +326,12 @@ fn named_section_visibility_persists_under_its_own_target() { /// restores on exit -- not the mode's. #[test] fn presenter_mode_persists_the_users_values_not_its_own() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut input_state = make_test_input_state(); input_state .presenter_mode_config_mut_for_test() @@ -334,7 +340,7 @@ fn presenter_mode_persists_the_users_values_not_its_own() { .presenter_mode_config_mut_for_test() .hide_tool_preview = true; input_state.ui_visibility.show_tool_preview = true; - input_state.toggle_presenter_mode(); + input_state.toggle_presenter_mode_with_resources(route_resources); assert!(input_state.presenter_mode_active()); assert!(input_state.click_highlight_enabled()); @@ -349,7 +355,7 @@ fn presenter_mode_persists_the_users_values_not_its_own() { // Leaving presenter mode restores the user's values, and a later toggle // is the user's own again. - input_state.toggle_presenter_mode(); + input_state.toggle_presenter_mode_with_resources(route_resources); assert!(!input_state.presenter_mode_active()); assert!(input_state.toggle_click_highlight()); assert!(user_click_highlight_enabled(&input_state)); @@ -359,11 +365,17 @@ fn presenter_mode_persists_the_users_values_not_its_own() { /// persists its runtime value even while the mode holds the enabled flag. #[test] fn presenter_mode_still_follows_the_highlight_ring_preference() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut input_state = make_test_input_state(); input_state .presenter_mode_config_mut_for_test() .enable_click_highlight = true; - input_state.toggle_presenter_mode(); + input_state.toggle_presenter_mode_with_resources(route_resources); assert!(input_state.set_highlight_tool_ring_enabled(true)); assert!(input_state.highlight_tool_ring_enabled()); diff --git a/src/input/state/actions/action_board_pages.rs b/src/input/state/actions/action_board_pages.rs index 3f4280d73..d82c21a83 100644 --- a/src/input/state/actions/action_board_pages.rs +++ b/src/input/state/actions/action_board_pages.rs @@ -7,31 +7,35 @@ use log::info; use super::super::InputState; impl InputState { - pub(in crate::input::state) fn handle_board_pages_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_board_pages_action_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + action: Action, + ) -> bool { match action { Action::ToggleWhiteboard => { if self.boards.has_board(BOARD_ID_WHITEBOARD) { log::info!("Toggling whiteboard board"); - self.switch_board(BOARD_ID_WHITEBOARD); + self.switch_board_with_measurer(measurer, BOARD_ID_WHITEBOARD); } true } Action::ToggleBlackboard => { if self.boards.has_board(BOARD_ID_BLACKBOARD) { log::info!("Toggling blackboard board"); - self.switch_board(BOARD_ID_BLACKBOARD); + self.switch_board_with_measurer(measurer, BOARD_ID_BLACKBOARD); } true } Action::ReturnToTransparent => { if self.boards.has_board(BOARD_ID_TRANSPARENT) { log::info!("Returning to transparent board"); - self.switch_board(BOARD_ID_TRANSPARENT); + self.switch_board_with_measurer(measurer, BOARD_ID_TRANSPARENT); } true } Action::PagePrev => { - if self.page_prev() { + if self.page_prev_with_measurer(measurer) { info!("Switched to previous page"); } else { self.push_toast( @@ -43,7 +47,7 @@ impl InputState { true } Action::PageNext => { - if self.page_next() { + if self.page_next_with_measurer(measurer) { info!("Switched to next page"); } else { self.push_toast( @@ -55,72 +59,72 @@ impl InputState { true } Action::PageNew => { - self.page_new(); + self.page_new_with_measurer(measurer); info!("Created new page"); true } Action::PageDuplicate => { - self.page_duplicate(); + self.page_duplicate_with_measurer(measurer); info!("Duplicated page"); true } Action::PageDelete => { - let outcome = self.page_delete(); + let outcome = self.page_delete_with_measurer(measurer); if matches!(outcome, PageDeleteOutcome::Removed) { info!("Deleted page"); } true } Action::PageRestoreDeleted => { - self.restore_deleted_page(); + self.restore_deleted_page_with_measurer(measurer); true } Action::Board1 => { - self.switch_board_slot(0); + self.switch_board_slot_with_measurer(measurer, 0); true } Action::Board2 => { - self.switch_board_slot(1); + self.switch_board_slot_with_measurer(measurer, 1); true } Action::Board3 => { - self.switch_board_slot(2); + self.switch_board_slot_with_measurer(measurer, 2); true } Action::Board4 => { - self.switch_board_slot(3); + self.switch_board_slot_with_measurer(measurer, 3); true } Action::Board5 => { - self.switch_board_slot(4); + self.switch_board_slot_with_measurer(measurer, 4); true } Action::Board6 => { - self.switch_board_slot(5); + self.switch_board_slot_with_measurer(measurer, 5); true } Action::Board7 => { - self.switch_board_slot(6); + self.switch_board_slot_with_measurer(measurer, 6); true } Action::Board8 => { - self.switch_board_slot(7); + self.switch_board_slot_with_measurer(measurer, 7); true } Action::Board9 => { - self.switch_board_slot(8); + self.switch_board_slot_with_measurer(measurer, 8); true } Action::BoardNext => { - self.switch_board_next(); + self.switch_board_next_with_measurer(measurer); true } Action::BoardPrev => { - self.switch_board_prev(); + self.switch_board_prev_with_measurer(measurer); true } Action::BoardNew => { - if !self.create_board() { + if !self.create_board_with_measurer(measurer) { self.push_toast( ToastPriority::Info, "page.nav", @@ -130,11 +134,11 @@ impl InputState { true } Action::BoardDelete => { - self.delete_active_board(); + self.delete_active_board_with_measurer(measurer); true } Action::BoardPicker => { - self.toggle_board_picker(); + self.toggle_board_picker_with_measurer(measurer); true } Action::BoardRestoreDeleted => { @@ -142,11 +146,11 @@ impl InputState { true } Action::BoardDuplicate => { - self.duplicate_board(); + self.duplicate_board_with_measurer(measurer); true } Action::BoardSwitchRecent => { - self.switch_board_recent(); + self.switch_board_recent_with_measurer(measurer); true } _ => false, diff --git a/src/input/state/actions/action_colors.rs b/src/input/state/actions/action_colors.rs index 063ca4ad7..2148d8484 100644 --- a/src/input/state/actions/action_colors.rs +++ b/src/input/state/actions/action_colors.rs @@ -10,7 +10,11 @@ impl InputState { self.style.quick_colors = quick_colors; } - pub(in crate::input::state) fn handle_color_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_color_action_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + action: Action, + ) -> bool { if action == Action::PickScreenColor { self.request_eyedropper_toggle(); return true; @@ -18,18 +22,28 @@ impl InputState { let Some(color) = self.style.quick_colors.color_for_action(action) else { return false; }; - let _ = self.apply_color_from_ui(color); + let _ = self.apply_color_from_ui_with_measurer(measurer, color); true } pub(crate) fn apply_color_from_ui(&mut self, color: Color) -> bool { + crate::draw::with_legacy_measurer(|measurer| { + self.apply_color_from_ui_with_measurer(measurer, color) + }) + } + + pub(crate) fn apply_color_from_ui_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + color: Color, + ) -> bool { self.note_recent_color(color); // First-run teaching signal: any color application (quick-color key, // radial swatch, picker, eyedropper) counts as "changed a color". self.pending_onboarding_usage.used_color_change = true; let mut changed = self.set_color(color); if self.active_tool() == Tool::Select && !self.selected_shape_ids().is_empty() { - let selection_changed = self.apply_selection_color_value(color); + let selection_changed = self.apply_selection_color_value_with(measurer, color); changed = selection_changed || changed; } changed diff --git a/src/input/state/actions/action_core.rs b/src/input/state/actions/action_core.rs index 51fca56d7..728321f4e 100644 --- a/src/input/state/actions/action_core.rs +++ b/src/input/state/actions/action_core.rs @@ -4,10 +4,14 @@ use crate::input::state::{Toast, ToastPriority}; use log::info; impl InputState { - pub(in crate::input::state) fn handle_core_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_core_action_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + action: Action, + ) -> bool { match action { Action::Exit => { - if self.try_cancel_active_interaction() { + if self.try_cancel_active_interaction_with(measurer) { true } else { self.should_exit = true; @@ -26,7 +30,7 @@ impl InputState { (screen_height / 2) as i32, String::new(), ); - self.update_text_preview_dirty(); + self.update_text_preview_dirty_with(measurer); self.needs_redraw = true; } true @@ -42,7 +46,7 @@ impl InputState { (screen_height / 2) as i32, String::new(), ); - self.update_text_preview_dirty(); + self.update_text_preview_dirty_with(measurer); self.needs_redraw = true; } true diff --git a/src/input/state/actions/action_dispatch.rs b/src/input/state/actions/action_dispatch.rs index a576c2071..131c4d3d6 100644 --- a/src/input/state/actions/action_dispatch.rs +++ b/src/input/state/actions/action_dispatch.rs @@ -5,9 +5,19 @@ use super::super::{InputState, interaction}; impl InputState { /// Handle an action that a non-key caller has already resolved. /// - /// Bound keys enter [`interaction::route_action`] directly, so action-wide + /// Bound keys enter [`interaction::route_action_with_resources`] directly, so action-wide /// gesture preflights live at that shared boundary rather than here. pub(crate) fn handle_action(&mut self, action: Action) { - let _ = interaction::route_action(self, action); + crate::input::state::with_legacy_text_resources(|resources| { + self.handle_action_with_resources(resources, action) + }); + } + + pub(crate) fn handle_action_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + action: Action, + ) { + let _ = interaction::route_action_with_resources(self, resources, action); } } diff --git a/src/input/state/actions/action_history.rs b/src/input/state/actions/action_history.rs index f26283bed..a56299086 100644 --- a/src/input/state/actions/action_history.rs +++ b/src/input/state/actions/action_history.rs @@ -3,11 +3,15 @@ use crate::domain::Action; use super::super::InputState; impl InputState { - pub(in crate::input::state) fn handle_history_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_history_action_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + action: Action, + ) -> bool { match action { Action::Undo => { if let Some(action) = self.boards.active_frame_mut().undo_last() { - self.apply_action_side_effects(&action); + self.apply_action_side_effects_with(measurer, &action); self.pending_onboarding_usage.first_undo_done = true; } else { // Nothing to undo - show blocked feedback @@ -17,7 +21,7 @@ impl InputState { } Action::Redo => { if let Some(action) = self.boards.active_frame_mut().redo_last() { - self.apply_action_side_effects(&action); + self.apply_action_side_effects_with(measurer, &action); } else { // Nothing to redo - show blocked feedback self.trigger_blocked_feedback(); @@ -25,11 +29,11 @@ impl InputState { true } Action::UndoAll => { - self.undo_all_immediate(); + self.undo_all_immediate_with_measurer(measurer); true } Action::RedoAll => { - self.redo_all_immediate(); + self.redo_all_immediate_with_measurer(measurer); true } Action::UndoAllDelayed => { @@ -44,15 +48,21 @@ impl InputState { } } - pub(crate) fn undo_all_immediate(&mut self) { + pub(crate) fn undo_all_immediate_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) { while let Some(action) = self.boards.active_frame_mut().undo_last() { - self.apply_action_side_effects(&action); + self.apply_action_side_effects_with(measurer, &action); } } - pub(crate) fn redo_all_immediate(&mut self) { + pub(crate) fn redo_all_immediate_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) { while let Some(action) = self.boards.active_frame_mut().redo_last() { - self.apply_action_side_effects(&action); + self.apply_action_side_effects_with(measurer, &action); } } } diff --git a/src/input/state/actions/action_presets.rs b/src/input/state/actions/action_presets.rs index 9148c5891..80b4ccb43 100644 --- a/src/input/state/actions/action_presets.rs +++ b/src/input/state/actions/action_presets.rs @@ -3,22 +3,26 @@ use crate::domain::Action; use super::super::InputState; impl InputState { - pub(in crate::input::state) fn handle_preset_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_preset_action_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + action: Action, + ) -> bool { match action { Action::ApplyPreset1 => { - let _ = self.apply_preset(1); + let _ = self.apply_preset_with(measurer, 1); } Action::ApplyPreset2 => { - let _ = self.apply_preset(2); + let _ = self.apply_preset_with(measurer, 2); } Action::ApplyPreset3 => { - let _ = self.apply_preset(3); + let _ = self.apply_preset_with(measurer, 3); } Action::ApplyPreset4 => { - let _ = self.apply_preset(4); + let _ = self.apply_preset_with(measurer, 4); } Action::ApplyPreset5 => { - let _ = self.apply_preset(5); + let _ = self.apply_preset_with(measurer, 5); } Action::SavePreset1 => { let _ = self.save_preset(1); diff --git a/src/input/state/actions/action_selection.rs b/src/input/state/actions/action_selection.rs index 439babda3..065a6ffee 100644 --- a/src/input/state/actions/action_selection.rs +++ b/src/input/state/actions/action_selection.rs @@ -8,10 +8,14 @@ const KEYBOARD_NUDGE_SMALL: i32 = 8; const KEYBOARD_NUDGE_LARGE: i32 = 32; impl InputState { - pub(in crate::input::state) fn handle_selection_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_selection_action_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + action: Action, + ) -> bool { match action { Action::CopySelection | Action::SelectAll => { - self.handle_selection_content_action(action) + self.handle_selection_content_action_with_measurer(measurer, action) } Action::PasteSelection => { self.request_clipboard_paste(); @@ -19,19 +23,19 @@ impl InputState { true } Action::DuplicateSelection => { - if self.duplicate_selection() { + if self.duplicate_selection_with(measurer) { info!("Duplicated selection"); } true } Action::MoveSelectionToFront => { - if self.move_selection_to_front() { + if self.move_selection_to_front_with(measurer) { info!("Moved selection to front"); } true } Action::MoveSelectionToBack => { - if self.move_selection_to_back() { + if self.move_selection_to_back_with(measurer) { info!("Moved selection to back"); } true @@ -41,33 +45,35 @@ impl InputState { | Action::NudgeSelectionLeft | Action::NudgeSelectionRight | Action::NudgeSelectionUpLarge - | Action::NudgeSelectionDownLarge => self.handle_selection_nudge_action(action), + | Action::NudgeSelectionDownLarge => { + self.handle_selection_nudge_action_with_measurer(measurer, action) + } Action::MoveSelectionToStart => { - if self.move_selection_to_horizontal_edge(true) { + if self.move_selection_to_horizontal_edge_with(measurer, true) { info!("Moved selection to start"); } true } Action::MoveSelectionToEnd => { - if self.move_selection_to_horizontal_edge(false) { + if self.move_selection_to_horizontal_edge_with(measurer, false) { info!("Moved selection to end"); } true } Action::MoveSelectionToTop => { - if self.move_selection_to_vertical_edge(true) { + if self.move_selection_to_vertical_edge_with(measurer, true) { info!("Moved selection to top"); } true } Action::MoveSelectionToBottom => { - if self.move_selection_to_vertical_edge(false) { + if self.move_selection_to_vertical_edge_with(measurer, false) { info!("Moved selection to bottom"); } true } Action::DeleteSelection => { - if self.delete_selection() { + if self.delete_selection_with(measurer) { info!("Deleted selection"); } true @@ -76,7 +82,11 @@ impl InputState { } } - fn handle_selection_content_action(&mut self, action: Action) -> bool { + fn handle_selection_content_action_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + action: Action, + ) -> bool { match action { Action::CopySelection => { let copied = self.copy_selection(); @@ -97,7 +107,7 @@ impl InputState { } } Action::SelectAll => { - crate::draw::with_legacy_measurer(|measurer| self.select_all_shapes_with(measurer)); + self.select_all_shapes_with(measurer); } _ => unreachable!("selection content dispatcher called with {action:?}"), } @@ -128,7 +138,11 @@ impl InputState { } } - fn handle_selection_nudge_action(&mut self, action: Action) -> bool { + fn handle_selection_nudge_action_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + action: Action, + ) -> bool { let shifted_step = if self.modifiers.shift { KEYBOARD_NUDGE_LARGE } else { @@ -179,7 +193,7 @@ impl InputState { ), _ => unreachable!("selection nudge dispatcher called with {action:?}"), }; - if self.translate_selection_with_undo(dx, dy) { + if self.translate_selection_with_undo_with(measurer, dx, dy) { self.selection_interaction.note_axis(axis); info!("Moved selection {} by {} px", direction, step); } else if self.has_selection() { diff --git a/src/input/state/actions/action_tools.rs b/src/input/state/actions/action_tools.rs index 239f0000f..dbaa6a0a1 100644 --- a/src/input/state/actions/action_tools.rs +++ b/src/input/state/actions/action_tools.rs @@ -25,7 +25,11 @@ impl InputState { self.push_toast(ToastPriority::Info, "pen-smoothing", Toast::info(message)); } - pub(in crate::input::state) fn handle_tool_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_tool_action_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + action: Action, + ) -> bool { if let Some(tool) = Tool::from_select_action(action) { if tool == Tool::Highlight { // Picking the highlight tool switches the click highlight on @@ -33,22 +37,22 @@ impl InputState { // explicit toggle makes. let previous_enabled = self.click_highlight_enabled(); let previous_tool_ring = self.highlight_tool_ring_enabled(); - self.set_highlight_tool(true); + self.set_highlight_tool_with_measurer(measurer, true); self.queue_toolbar_persistence(PendingToolbarPersistence::ClickHighlight { previous_enabled, previous_tool_ring, }); } - self.set_tool_override(Some(tool)); + self.set_tool_override_with(measurer, Some(tool)); return true; } match action { Action::IncreaseThickness => { - self.nudge_thickness_for_active_tool(1.0); + self.nudge_thickness_for_active_tool_with(measurer, 1.0); } Action::DecreaseThickness => { - self.nudge_thickness_for_active_tool(-1.0); + self.nudge_thickness_for_active_tool_with(measurer, -1.0); } Action::IncreaseMarkerOpacity => { self.set_marker_opacity(self.style.marker_opacity + 0.05); @@ -57,7 +61,7 @@ impl InputState { self.set_marker_opacity(self.style.marker_opacity - 0.05); } Action::CycleFontFamily => { - self.cycle_font_family(); + self.cycle_font_family_with_measurer(measurer); } Action::OpenFontPicker => { self.open_font_picker(); @@ -70,7 +74,7 @@ impl InputState { } } Action::SelectSpotlightTool => { - self.set_tool_override(Some(Tool::Spotlight)); + self.set_tool_override_with(measurer, Some(Tool::Spotlight)); } Action::CycleBlurStyle => { if self.cycle_blur_style() { @@ -88,7 +92,7 @@ impl InputState { // key both restyles what is on screen and sets what the next // arrow will be, without a modifier to remember. if self.selection_contains_arrow() { - self.cycle_selected_arrow_style_from_action(); + self.cycle_selected_arrow_style_from_action_with(measurer); } else if self.cycle_arrow_style() { let label = self.style.arrow_style.label(); info!("Arrow style set to {label}"); @@ -124,7 +128,7 @@ impl InputState { Action::ToggleHighlightTool => { let previous_enabled = self.click_highlight_enabled(); let previous_tool_ring = self.highlight_tool_ring_enabled(); - let enabled = self.toggle_all_highlights(); + let enabled = self.toggle_all_highlights_with_measurer(measurer); self.queue_toolbar_persistence(PendingToolbarPersistence::ClickHighlight { previous_enabled, previous_tool_ring, diff --git a/src/input/state/actions/action_ui.rs b/src/input/state/actions/action_ui.rs index 7bb88d0ca..97838cf9f 100644 --- a/src/input/state/actions/action_ui.rs +++ b/src/input/state/actions/action_ui.rs @@ -9,7 +9,11 @@ use log::info; use super::super::{DrawingState, InputState, PendingBackendAction, PendingToolbarPersistence}; impl InputState { - pub(in crate::input::state) fn handle_ui_action(&mut self, action: Action) -> bool { + pub(in crate::input::state) fn handle_ui_action_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + action: Action, + ) -> bool { match action { Action::ToggleHelp => { self.toggle_help_overlay(); @@ -20,7 +24,7 @@ impl InputState { true } Action::ToggleFocusMode => { - self.handle_toggle_focus_mode(); + self.handle_toggle_focus_mode_with_resources(resources); true } Action::ToggleStatusBar => { @@ -44,15 +48,15 @@ impl InputState { true } Action::ToggleToolbar => { - self.handle_toggle_toolbar(); + self.handle_toggle_toolbar_with_engine(resources.ui_engine); true } Action::CycleToolbarDisplay => { - self.handle_cycle_toolbar_display(); + self.handle_cycle_toolbar_display_with_engine(resources.ui_engine); true } Action::TogglePresenterMode => { - let enabled = self.toggle_presenter_mode(); + let enabled = self.toggle_presenter_mode_with_resources(resources); info!( "Presenter mode {}", if enabled { "enabled" } else { "disabled" } @@ -60,7 +64,7 @@ impl InputState { true } Action::ToggleLightMode => { - let enabled = self.toggle_light_mode(); + let enabled = self.toggle_light_mode_with_resources(resources); info!( "Light mode {}", if enabled { "enabled" } else { "disabled" } @@ -68,7 +72,7 @@ impl InputState { true } Action::ToggleLightModeDrawing => { - let drawing = self.toggle_light_mode_drawing(); + let drawing = self.toggle_light_mode_drawing_with_resources(resources); info!( "Light mode drawing {}", if drawing { "enabled" } else { "disabled" } @@ -112,12 +116,12 @@ impl InputState { } Action::OpenContextMenu => { if !self.zoom_active() { - self.toggle_context_menu_via_keyboard(); + self.toggle_context_menu_via_keyboard_with(resources.measurer); } true } Action::ToggleSelectionProperties => { - self.handle_toggle_selection_properties(); + self.handle_toggle_selection_properties_with_measurer(resources.measurer); true } Action::OpenConfigurator => { @@ -163,7 +167,7 @@ impl InputState { true } Action::ReplayTour => { - self.start_tour_replay(); + self.start_tour_replay_with_resources(resources); true } Action::ToggleCommandPalette => { @@ -174,13 +178,16 @@ impl InputState { } } - fn handle_toggle_focus_mode(&mut self) { + fn handle_toggle_focus_mode_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) { // Presenter mode already owns chrome visibility and restores it on // exit; a second snapshot layer would fight it. if self.presenter_mode_active() { return; } - self.toggle_focus_mode(); + self.toggle_focus_mode_with_resources(resources); info!( "Focus mode {}", if self.focus_mode_active() { @@ -277,13 +284,13 @@ impl InputState { } } - fn handle_toggle_toolbar(&mut self) { + fn handle_toggle_toolbar_with_engine(&mut self, engine: &crate::ui_text::UiTextEngine) { if self.presenter_hides_toolbars() { return; } self.break_focus_mode(); let now_visible = !self.toolbar_visible(); - if !self.set_toolbar_visible(now_visible) { + if !self.set_toolbar_visible_with_engine(engine, now_visible) { return; } let previous_top_pinned = self.toolbar_top_pinned(); @@ -303,13 +310,13 @@ impl InputState { } } - fn handle_cycle_toolbar_display(&mut self) { + fn handle_cycle_toolbar_display_with_engine(&mut self, engine: &crate::ui_text::UiTextEngine) { if self.presenter_hides_toolbars() { return; } self.break_focus_mode(); let previous_mode = self.toolbar_top_display_mode(); - let mode = self.cycle_top_toolbar_display(); + let mode = self.cycle_top_toolbar_display_with_engine(engine); self.pending_onboarding_usage.used_toolbar_toggle = true; let toast = self.toolbar_display_toast(mode); self.push_toast(ToastPriority::Info, "ui", toast); @@ -347,13 +354,16 @@ impl InputState { } } - fn handle_toggle_selection_properties(&mut self) { + fn handle_toggle_selection_properties_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) { if !matches!(self.state, DrawingState::Idle) { return; } if self.properties_panel().is_some() { self.close_properties_panel(); - } else if self.show_properties_panel() { + } else if self.show_properties_panel_with(measurer) { self.close_context_menu(); } else { self.push_toast( diff --git a/src/input/state/actions/key_press/mod.rs b/src/input/state/actions/key_press/mod.rs index a55a43ef0..4bffb5bdf 100644 --- a/src/input/state/actions/key_press/mod.rs +++ b/src/input/state/actions/key_press/mod.rs @@ -34,10 +34,30 @@ impl InputState { /// - Help toggle (configurable) /// - Modifier key tracking pub fn on_key_press(&mut self, key: Key) { - let _ = interaction::route_key_press(self, key); + crate::input::state::with_legacy_text_resources(|resources| { + self.on_key_press_with_resources(resources, key) + }); + } + + pub(crate) fn on_key_press_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + key: Key, + ) { + let _ = interaction::route_key_press_with_resources(self, resources, key); } pub fn on_key_repeat(&mut self, key: Key) { - let _ = interaction::route_key_repeat(self, key); + crate::input::state::with_legacy_text_resources(|resources| { + self.on_key_repeat_with_resources(resources, key) + }); + } + + pub(crate) fn on_key_repeat_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + key: Key, + ) { + let _ = interaction::route_key_repeat_with_resources(self, resources, key); } } diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs index 28ae1e538..47e5a6dd5 100644 --- a/src/input/state/actions/key_press/panels.rs +++ b/src/input/state/actions/key_press/panels.rs @@ -64,7 +64,11 @@ impl InputState { } } - pub(in crate::input::state) fn handle_board_picker_key(&mut self, key: Key) -> bool { + pub(in crate::input::state) fn handle_board_picker_key_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + key: Key, + ) -> bool { if !self.is_board_picker_open() { return false; } @@ -76,7 +80,7 @@ impl InputState { true } Key::Return => { - self.board_picker_commit_page_edit(); + self.board_picker_commit_page_edit_with_measurer(measurer); true } Key::Backspace | Key::Delete => { @@ -96,7 +100,7 @@ impl InputState { } else if self.board_picker_edit_state().is_some() { match key { Key::F2 => { - self.board_picker_rename_selected(); + self.board_picker_rename_selected_with_measurer(measurer); true } Key::Escape => { @@ -122,17 +126,23 @@ impl InputState { _ => true, } } else if self.board_picker_focus() == BoardPickerFocus::PagePanel { - if let Some(consumed) = self.handle_board_picker_page_nav_key(key) { + if let Some(consumed) = + self.handle_board_picker_page_nav_key_with_measurer(measurer, key) + { consumed } else { - self.handle_board_picker_page_panel_key(key) + self.handle_board_picker_page_panel_key_with_measurer(measurer, key) } } else { - self.handle_board_picker_board_list_key(key) + self.handle_board_picker_board_list_key_with_measurer(measurer, key) } } - fn handle_board_picker_board_list_key(&mut self, key: Key) -> bool { + fn handle_board_picker_board_list_key_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + key: Key, + ) -> bool { if self.board_picker_is_quick() { match key { Key::Delete | Key::F2 => return true, @@ -189,12 +199,12 @@ impl InputState { } Key::Return | Key::Space => { if let Some(index) = self.board_picker_selected_index() { - self.board_picker_activate_row(index); + self.board_picker_activate_row_with_measurer(measurer, index); } true } Key::Delete => { - self.board_picker_delete_selected(); + self.board_picker_delete_selected_with_measurer(measurer); true } Key::Tab | Key::Right => { @@ -208,15 +218,15 @@ impl InputState { true } Key::Char('n') | Key::Char('N') if self.modifiers.ctrl => { - self.board_picker_create_new(); + self.board_picker_create_new_with_measurer(measurer); true } Key::Char('r') | Key::Char('R') if self.modifiers.ctrl => { - self.board_picker_rename_selected(); + self.board_picker_rename_selected_with_measurer(measurer); true } Key::Char('c') | Key::Char('C') if self.modifiers.ctrl => { - self.board_picker_edit_color_selected(); + self.board_picker_edit_color_selected_with_measurer(measurer); true } Key::Char('p') | Key::Char('P') if self.modifiers.ctrl => { @@ -230,14 +240,18 @@ impl InputState { true } Key::F2 => { - self.board_picker_rename_selected(); + self.board_picker_rename_selected_with_measurer(measurer); true } _ => true, } } - fn handle_board_picker_page_panel_key(&mut self, key: Key) -> bool { + fn handle_board_picker_page_panel_key_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + key: Key, + ) -> bool { let (page_count, current) = self.board_picker_page_panel_position(); match key { Key::Escape => { @@ -263,13 +277,13 @@ impl InputState { } Key::Return | Key::Space => { if page_count > 0 { - self.board_picker_activate_page(current); + self.board_picker_activate_page_with_measurer(measurer, current); } true } Key::Delete => { if page_count > 0 { - let outcome = self.board_picker_delete_page(current); + let outcome = self.board_picker_delete_page_with_measurer(measurer, current); if matches!(outcome, PageDeleteOutcome::Pending) { return true; } @@ -296,7 +310,7 @@ impl InputState { true } Key::Char('n') | Key::Char('N') if self.modifiers.ctrl => { - self.board_picker_add_page(); + self.board_picker_add_page_with_measurer(measurer); true } Key::Char('g') | Key::Char('G') if self.modifiers.ctrl => { @@ -390,7 +404,11 @@ impl InputState { } } - pub(in crate::input::state) fn handle_properties_panel_key(&mut self, key: Key) -> bool { + pub(in crate::input::state) fn handle_properties_panel_key_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + key: Key, + ) -> bool { let adjust_step = if self.modifiers.shift { PROPERTIES_PANEL_COARSE_STEP } else { @@ -405,16 +423,24 @@ impl InputState { Key::Down => self.focus_next_properties_entry(), Key::Home => self.focus_first_properties_entry(), Key::End => self.focus_last_properties_entry(), - Key::Return | Key::Space => self.activate_properties_panel_entry(), - Key::Left => self.adjust_properties_panel_entry(-adjust_step), - Key::Right => self.adjust_properties_panel_entry(adjust_step), - Key::Char('+') | Key::Char('=') => self.adjust_properties_panel_entry(adjust_step), - Key::Char('-') | Key::Char('_') => self.adjust_properties_panel_entry(-adjust_step), + Key::Return | Key::Space => self.activate_properties_panel_entry_with(measurer), + Key::Left => self.adjust_properties_panel_entry_with(measurer, -adjust_step), + Key::Right => self.adjust_properties_panel_entry_with(measurer, adjust_step), + Key::Char('+') | Key::Char('=') => { + self.adjust_properties_panel_entry_with(measurer, adjust_step) + } + Key::Char('-') | Key::Char('_') => { + self.adjust_properties_panel_entry_with(measurer, -adjust_step) + } _ => false, } } - pub(in crate::input::state) fn handle_context_menu_key(&mut self, key: Key) -> bool { + pub(in crate::input::state) fn handle_context_menu_key_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + key: Key, + ) -> bool { match key { Key::Escape => { self.close_context_menu(); @@ -424,7 +450,9 @@ impl InputState { Key::Down => self.focus_next_context_menu_entry(), Key::Home => self.focus_first_context_menu_entry(), Key::End => self.focus_last_context_menu_entry(), - Key::Return | Key::Space => self.activate_context_menu_selection(), + Key::Return | Key::Space => { + self.activate_context_menu_selection_with_resources(resources) + } _ => false, } } diff --git a/src/input/state/actions/key_press/text_input.rs b/src/input/state/actions/key_press/text_input.rs index c7fa5a533..a866bdf71 100644 --- a/src/input/state/actions/key_press/text_input.rs +++ b/src/input/state/actions/key_press/text_input.rs @@ -14,12 +14,16 @@ use super::bindings::{fallback_unshifted_label, key_to_action_label}; use super::caret_edit::{self, MAX_TEXT_LENGTH, TextNavigation}; impl InputState { - pub(in crate::input::state) fn handle_text_input_key(&mut self, key: Key) { + pub(in crate::input::state) fn handle_text_input_key_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + key: Key, + ) { // Editing and caret navigation own their keys in text mode, ahead of // the action layer — otherwise arrows/Delete/Home/End would be swallowed // as tool shortcuts. Escape, F-keys, plain Return, and non-editing // Ctrl/Alt shortcuts (undo, exit, …) are left to fall through below. - if self.handle_text_editing_key(key) { + if self.handle_text_editing_key_with(resources.measurer, key) { return; } @@ -53,14 +57,14 @@ impl InputState { if let Some(action) = self.find_action(&key_str) { // Actions work in text mode. // Exit action has special logic in handle_action. - self.handle_action(action); + self.handle_action_with_resources(resources, action); return; } if self.modifiers.shift && let Some(fallback) = fallback_unshifted_label(&key_str) && let Some(action) = self.find_action(fallback) { - self.handle_action(action); + self.handle_action_with_resources(resources, action); return; } } @@ -74,13 +78,13 @@ impl InputState { && !c.is_control() { let mut encoded = [0u8; 4]; - self.insert_text_at_caret(c.encode_utf8(&mut encoded)); + self.insert_text_at_caret_with(resources.measurer, c.encode_utf8(&mut encoded)); return; } // Handle Return key for finalizing text input (only plain Return, not Shift+Return) if matches!(key, Key::Return) && !self.modifiers.shift { - with_legacy_measurer(|measurer| self.finalize_text_input_with(measurer)); + self.finalize_text_input_with(resources.measurer); } } @@ -150,10 +154,6 @@ impl InputState { /// text editor owns. Returns whether the key was consumed (so the caller /// stops routing it). Non-editing keys (Escape, F-keys, plain Return, and /// Ctrl/Alt shortcuts like undo/exit) return `false` and fall through. - fn handle_text_editing_key(&mut self, key: Key) -> bool { - with_legacy_measurer(|measurer| self.handle_text_editing_key_with(measurer, key)) - } - fn handle_text_editing_key_with(&mut self, measurer: &TextMeasurer, key: Key) -> bool { let ctrl = self.modifiers.ctrl; let alt = self.modifiers.alt; @@ -260,10 +260,6 @@ impl InputState { /// Insert clipboard text at the caret, then coordinate redraw and protocol /// effects owned by the root state. - pub(crate) fn insert_text_at_caret(&mut self, text: &str) -> bool { - with_legacy_measurer(|measurer| self.insert_text_at_caret_with(measurer, text)) - } - pub(crate) fn insert_text_at_caret_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/base/state/modifiers.rs b/src/input/state/core/base/state/modifiers.rs index 99e6dc9f3..7180b1ead 100644 --- a/src/input/state/core/base/state/modifiers.rs +++ b/src/input/state/core/base/state/modifiers.rs @@ -57,9 +57,15 @@ mod tests { #[test] fn focus_loss_clears_modal_repeats_and_modifiers() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_test_input_state(); state.toggle_command_palette(); - assert!(state.handle_command_palette_key(Key::Down)); + assert!(state.handle_command_palette_key_with_resources(route_resources, Key::Down)); state.sync_modifiers(true, true, true, true); state.modifiers.tab = true; assert!( @@ -82,7 +88,7 @@ mod tests { assert!(!state.modifiers.tab); state.open_font_picker(); - assert!(state.handle_font_picker_key(Key::Down, None)); + assert!(state.handle_font_picker_key_with_measurer(&route_measurer, Key::Down, None)); assert!(state.font_picker_repeat_timeout(Instant::now()).is_some()); state.clear_focus_owned_key_state(); diff --git a/src/input/state/core/board/delete_restore/page.rs b/src/input/state/core/board/delete_restore/page.rs index 9dc02d9ad..dda3c024c 100644 --- a/src/input/state/core/board/delete_restore/page.rs +++ b/src/input/state/core/board/delete_restore/page.rs @@ -11,16 +11,6 @@ use crate::input::state::{Toast, ToastPriority}; use std::time::Instant; impl InputState { - pub(crate) fn delete_page_in_board( - &mut self, - board_index: usize, - page_index: usize, - ) -> CanvasPageDeleteOutcome { - with_legacy_measurer(|measurer| { - self.delete_page_in_board_with_measurer(measurer, board_index, page_index) - }) - } - pub(crate) fn delete_page_in_board_with_measurer( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/board/pages.rs b/src/input/state/core/board/pages.rs index e2a83939e..f1b879efa 100644 --- a/src/input/state/core/board/pages.rs +++ b/src/input/state/core/board/pages.rs @@ -158,10 +158,6 @@ impl InputState { true } - pub(crate) fn add_page_in_board(&mut self, board_index: usize) -> bool { - with_legacy_measurer(|measurer| self.add_page_in_board_with_measurer(measurer, board_index)) - } - pub(crate) fn add_page_in_board_with_measurer( &mut self, measurer: &TextMeasurer, @@ -243,17 +239,6 @@ impl InputState { true } - pub(crate) fn rename_page_in_board( - &mut self, - board_index: usize, - page_index: usize, - name: Option, - ) -> bool { - with_legacy_measurer(|measurer| { - self.rename_page_in_board_with_measurer(measurer, board_index, page_index, name) - }) - } - pub(crate) fn rename_page_in_board_with_measurer( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/board_picker/state/actions.rs b/src/input/state/core/board_picker/state/actions.rs index d64bf7e2a..bc002f1fe 100644 --- a/src/input/state/core/board_picker/state/actions.rs +++ b/src/input/state/core/board_picker/state/actions.rs @@ -18,18 +18,38 @@ impl InputState { } pub(crate) fn board_picker_activate_row(&mut self, index: usize) { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_activate_row_with_measurer(measurer, index) + }) + } + + pub(crate) fn board_picker_activate_row_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + index: usize, + ) { let board_count = self.boards.board_count(); if index < board_count { if let Some(board_index) = self.board_picker_board_index_for_row(index) { - self.switch_board_slot(board_index); + self.switch_board_slot_with_measurer(measurer, board_index); self.close_board_picker(); } } else { - self.board_picker_create_new(); + self.board_picker_create_new_with_measurer(measurer); } } pub(crate) fn board_picker_activate_page(&mut self, page_index: usize) { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_activate_page_with_measurer(measurer, page_index) + }) + } + + pub(crate) fn board_picker_activate_page_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + page_index: usize, + ) { let Some(board_index) = self.board_picker_page_panel_board_index() else { return; }; @@ -42,17 +62,26 @@ impl InputState { return; } if self.boards.active_index() != board_index { - self.switch_board_slot(board_index); + self.switch_board_slot_with_measurer(measurer, board_index); } - self.switch_to_page(page_index); + self.switch_to_page_with_measurer(measurer, page_index); self.close_board_picker(); } pub(crate) fn board_picker_add_page(&mut self) { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_add_page_with_measurer(measurer) + }) + } + + pub(crate) fn board_picker_add_page_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) { let Some(board_index) = self.board_picker_page_panel_board_index() else { return; }; - if self.add_page_in_board(board_index) + if self.add_page_in_board_with_measurer(measurer, board_index) && let Some(page_index) = self .boards .board_states() @@ -66,10 +95,20 @@ impl InputState { } pub(crate) fn board_picker_delete_page(&mut self, page_index: usize) -> PageDeleteOutcome { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_delete_page_with_measurer(measurer, page_index) + }) + } + + pub(crate) fn board_picker_delete_page_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + page_index: usize, + ) -> PageDeleteOutcome { let Some(board_index) = self.board_picker_page_panel_board_index() else { return PageDeleteOutcome::Pending; }; - let outcome = self.delete_page_in_board(board_index, page_index); + let outcome = self.delete_page_in_board_with_measurer(measurer, board_index, page_index); if !matches!(outcome, PageDeleteOutcome::Pending) { self.board_picker_reconcile_page_nav_after_page_change(); } @@ -94,10 +133,19 @@ impl InputState { } pub(crate) fn board_picker_create_new(&mut self) { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_create_new_with_measurer(measurer) + }) + } + + pub(crate) fn board_picker_create_new_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) { if self.board_picker_is_quick() { self.board_picker_promote_to_full(); } - if !self.create_board() { + if !self.create_board_with_measurer(measurer) { self.push_toast( ToastPriority::Info, "board_picker", @@ -115,6 +163,15 @@ impl InputState { } pub(crate) fn board_picker_delete_selected(&mut self) { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_delete_selected_with_measurer(measurer) + }) + } + + pub(crate) fn board_picker_delete_selected_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) { let Some(index) = self.board_picker_selected_index() else { return; }; @@ -125,9 +182,9 @@ impl InputState { return; }; if self.boards.active_index() != board_index { - self.switch_board_slot(board_index); + self.switch_board_slot_with_measurer(measurer, board_index); } - self.delete_active_board(); + self.delete_active_board_with_measurer(measurer); if let Some(row) = self.board_picker_row_for_board(self.boards.active_index()) { self.board_picker_set_selected(row); } diff --git a/src/input/state/core/board_picker/state/edit.rs b/src/input/state/core/board_picker/state/edit.rs index f2f1c8ff5..37fd66e61 100644 --- a/src/input/state/core/board_picker/state/edit.rs +++ b/src/input/state/core/board_picker/state/edit.rs @@ -69,12 +69,26 @@ impl InputState { } pub(crate) fn board_picker_commit_page_edit(&mut self) -> bool { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_commit_page_edit_with_measurer(measurer) + }) + } + + pub(crate) fn board_picker_commit_page_edit_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) -> bool { let Some(edit) = self.board_picker.page_edit.take() else { return false; }; let name = edit.buffer.trim().to_string(); let name = if name.is_empty() { None } else { Some(name) }; - let _ = self.rename_page_in_board(edit.board_index, edit.page_index, name); + let _ = self.rename_page_in_board_with_measurer( + measurer, + edit.board_index, + edit.page_index, + name, + ); self.board_picker_reconcile_page_nav_after_page_change(); self.needs_redraw = true; true @@ -254,6 +268,15 @@ impl InputState { } pub(crate) fn board_picker_rename_selected(&mut self) { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_rename_selected_with_measurer(measurer) + }) + } + + pub(crate) fn board_picker_rename_selected_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) { let Some(index) = self.board_picker_selected_index() else { return; }; @@ -261,7 +284,7 @@ impl InputState { self.board_picker_promote_to_full(); } if self.board_picker_is_new_row(index) { - self.board_picker_create_new(); + self.board_picker_create_new_with_measurer(measurer); return; } let Some(board_index) = self.board_picker_board_index_for_row(index) else { @@ -273,6 +296,15 @@ impl InputState { } pub(crate) fn board_picker_edit_color_selected(&mut self) { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_edit_color_selected_with_measurer(measurer) + }) + } + + pub(crate) fn board_picker_edit_color_selected_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) { let Some(index) = self.board_picker_selected_index() else { return; }; @@ -280,7 +312,7 @@ impl InputState { self.board_picker_promote_to_full(); } if self.board_picker_is_new_row(index) { - self.board_picker_create_new(); + self.board_picker_create_new_with_measurer(measurer); return; } let Some(board_index) = self.board_picker_board_index_for_row(index) else { diff --git a/src/input/state/core/board_picker/state/nav.rs b/src/input/state/core/board_picker/state/nav.rs index 220cf0018..3d6fd1284 100644 --- a/src/input/state/core/board_picker/state/nav.rs +++ b/src/input/state/core/board_picker/state/nav.rs @@ -114,11 +114,17 @@ impl InputState { changed } - pub(crate) fn handle_board_picker_page_nav_key(&mut self, key: Key) -> Option { + pub(crate) fn handle_board_picker_page_nav_key_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + key: Key, + ) -> Option { match self.board_picker_page_nav_mode() { BoardPickerPageNavMode::Normal => None, BoardPickerPageNavMode::Jump => Some(self.handle_board_picker_page_jump_key(key)), - BoardPickerPageNavMode::Search => Some(self.handle_board_picker_page_search_key(key)), + BoardPickerPageNavMode::Search => { + Some(self.handle_board_picker_page_search_key_with_measurer(measurer, key)) + } } } @@ -183,7 +189,11 @@ impl InputState { self.board_picker_set_page_focus_page_index(page_index); } - fn handle_board_picker_page_search_key(&mut self, key: Key) -> bool { + fn handle_board_picker_page_search_key_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + key: Key, + ) -> bool { match key { Key::Escape => { self.board_picker_clear_page_nav(); @@ -191,7 +201,7 @@ impl InputState { } Key::Return => { if let Some(page_index) = self.board_picker_page_search_active_match() { - self.board_picker_activate_page(page_index); + self.board_picker_activate_page_with_measurer(measurer, page_index); } true } diff --git a/src/input/state/core/command_palette/input.rs b/src/input/state/core/command_palette/input.rs index c56fb0582..316d9cfa9 100644 --- a/src/input/state/core/command_palette/input.rs +++ b/src/input/state/core/command_palette/input.rs @@ -139,7 +139,11 @@ impl InputState { /// Handle a key press while the command palette is open. /// Returns true if the key was handled. - pub(crate) fn handle_command_palette_key(&mut self, key: Key) -> bool { + pub(crate) fn handle_command_palette_key_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + key: Key, + ) -> bool { if !self.command_palette_is_engaged() { return false; } @@ -175,7 +179,7 @@ impl InputState { self.dirty_tracker.mark_full(); self.needs_redraw = true; self.record_command_palette_action(command.action); - self.handle_action(command.action); + self.handle_action_with_resources(resources, command.action); } true } @@ -480,6 +484,25 @@ impl InputState { y: i32, screen_width: u32, screen_height: u32, + ) -> bool { + crate::input::state::with_legacy_text_resources(|resources| { + self.handle_command_palette_click_with_resources( + resources, + x, + y, + screen_width, + screen_height, + ) + }) + } + + pub(crate) fn handle_command_palette_click_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + x: i32, + y: i32, + screen_width: u32, + screen_height: u32, ) -> bool { if !self.command_palette_is_engaged() { return false; @@ -558,7 +581,7 @@ impl InputState { Toast::info(command.label).duration_ms(self.command_palette_toast_duration_ms()), ); - self.handle_action(command.action); + self.handle_action_with_resources(resources, command.action); return true; } diff --git a/src/input/state/core/command_palette/mod.rs b/src/input/state/core/command_palette/mod.rs index b1cb0608b..06ebe5cc7 100644 --- a/src/input/state/core/command_palette/mod.rs +++ b/src/input/state/core/command_palette/mod.rs @@ -80,10 +80,24 @@ mod tests { /// owns the keymap; the modal closes itself in the same step. #[test] fn shortcut_capture_emits_a_replace_request() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); assert!(state.begin_keybinding_capture(Action::SelectPenTool)); - assert!(state.handle_command_palette_key(crate::input::Key::Ctrl)); - assert!(state.handle_command_palette_key(crate::input::Key::Char('p'))); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Ctrl + ) + ); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('p') + )); assert_eq!( state.take_pending_keybinding_edits(), @@ -105,15 +119,35 @@ mod tests { /// Recording them in a slot dropped the first with nothing said about it. #[test] fn two_chords_captured_before_a_drain_both_survive_in_order() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); assert!(state.begin_keybinding_capture(Action::SelectPenTool)); - assert!(state.handle_command_palette_key(crate::input::Key::Ctrl)); - assert!(state.handle_command_palette_key(crate::input::Key::Alt)); - assert!(state.handle_command_palette_key(crate::input::Key::Char('p'))); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Ctrl + ) + ); + assert!( + state + .handle_command_palette_key_with_resources(route_resources, crate::input::Key::Alt) + ); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('p') + )); assert!(state.begin_keybinding_capture(Action::SelectMarkerTool)); - assert!(state.handle_command_palette_key(crate::input::Key::Char('m'))); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('m') + )); assert_eq!( state.take_pending_keybinding_edits(), @@ -142,11 +176,22 @@ mod tests { /// Escape leaves the modal without queueing anything. #[test] fn shortcut_capture_is_cancelled_by_escape() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); assert!(state.begin_keybinding_capture(Action::SelectPenTool)); - assert!(state.handle_command_palette_key(crate::input::Key::Escape)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Escape + ) + ); assert_eq!(state.keybinding_capture_action(), None); assert!(state.take_pending_keybinding_edits().is_empty()); @@ -157,6 +202,12 @@ mod tests { /// the command. #[test] fn ctrl_e_starts_capture_for_the_selected_row() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "pen tool".to_string(); @@ -164,7 +215,10 @@ mod tests { assert_eq!(action, Action::SelectPenTool); state.modifiers.ctrl = true; - assert!(state.handle_command_palette_key(crate::input::Key::Char('e'))); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('e') + )); assert_eq!(state.keybinding_capture_action(), Some(action)); assert!(state.take_pending_keybinding_edits().is_empty()); @@ -175,14 +229,33 @@ mod tests { /// the modifier flags directly. #[test] fn shift_held_through_the_palette_turns_ctrl_e_into_the_configurator_route() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "pen tool".to_string(); - assert!(state.handle_command_palette_key(crate::input::Key::Ctrl)); - assert!(state.handle_command_palette_key(crate::input::Key::Shift)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Ctrl + ) + ); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Shift + ) + ); assert!(state.modifiers.shift, "the palette must track Shift itself"); - assert!(state.handle_command_palette_key(crate::input::Key::Char('E'))); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('E') + )); assert_eq!( state.keybinding_capture_action(), @@ -197,7 +270,10 @@ mod tests { assert!(!state.modifiers.shift); state.toggle_command_palette(); state.command_palette.query = "pen tool".to_string(); - assert!(state.handle_command_palette_key(crate::input::Key::Char('e'))); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('e') + )); assert_eq!( state.keybinding_capture_action(), Some(Action::SelectPenTool) @@ -210,22 +286,42 @@ mod tests { /// driven here as a real press instead of a flag. #[test] fn alt_held_through_the_palette_reaches_the_captured_chord() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "pen tool".to_string(); // Alt goes down while the list, not the modal, owns the keyboard. - assert!(state.handle_command_palette_key(crate::input::Key::Alt)); + assert!( + state + .handle_command_palette_key_with_resources(route_resources, crate::input::Key::Alt) + ); assert!(state.modifiers.alt, "the palette must track Alt itself"); - assert!(state.handle_command_palette_key(crate::input::Key::Ctrl)); - assert!(state.handle_command_palette_key(crate::input::Key::Char('e'))); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Ctrl + ) + ); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('e') + )); assert_eq!( state.keybinding_capture_action(), Some(Action::SelectPenTool) ); // Still held when the modal reads the chord. - assert!(state.handle_command_palette_key(crate::input::Key::Char('k'))); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('k') + )); assert_eq!( state.take_pending_keybinding_edits(), @@ -244,20 +340,42 @@ mod tests { #[test] fn super_held_through_the_palette_reaches_the_captured_chord() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "pen tool".to_string(); - assert!(state.handle_command_palette_key(crate::input::Key::Super)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Super + ) + ); assert!(state.modifiers.logo, "the palette must track Super itself"); - assert!(state.handle_command_palette_key(crate::input::Key::Ctrl)); - assert!(state.handle_command_palette_key(crate::input::Key::Char('e'))); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Ctrl + ) + ); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('e') + )); assert_eq!( state.keybinding_capture_action(), Some(Action::SelectPenTool) ); - assert!(state.handle_command_palette_key(crate::input::Key::Char('k'))); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('k') + )); assert_eq!( state.take_pending_keybinding_edits(), vec![crate::input::state::KeybindingEditRequest { @@ -274,13 +392,24 @@ mod tests { #[test] fn palette_shortcut_controls_request_delete_and_reset() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "pen tool".to_string(); let action = state.selected_command().expect("selected command").action; state.modifiers.ctrl = true; - assert!(state.handle_command_palette_key(crate::input::Key::Delete)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Delete + ) + ); assert_eq!( state.take_pending_keybinding_edits(), vec![crate::input::state::KeybindingEditRequest { @@ -289,7 +418,10 @@ mod tests { }] ); - assert!(state.handle_command_palette_key(crate::input::Key::Char('r'))); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('r') + )); assert_eq!( state.take_pending_keybinding_edits(), vec![crate::input::state::KeybindingEditRequest { @@ -387,6 +519,12 @@ mod tests { #[test] fn ctrl_shift_e_requests_the_rows_keybindings_section() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; use crate::configurator_destination::{ ConfiguratorDestination, ConfiguratorScreen, KeybindingsSection, }; @@ -398,7 +536,10 @@ mod tests { state.modifiers.ctrl = true; state.modifiers.shift = true; - assert!(state.handle_command_palette_key(crate::input::Key::Char('e'))); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('e') + )); assert_eq!( state.take_pending_backend_action(), Some(PendingBackendAction::HelperLaunch( @@ -609,13 +750,22 @@ mod tests { #[test] fn backspace_resets_selection_and_scroll_when_query_changes() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "zoom".to_string(); state.command_palette.selected = 4; state.command_palette.scroll = 3; - assert!(state.handle_command_palette_key(crate::input::Key::Backspace)); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Backspace + )); assert_eq!(state.command_palette.query, "zoo"); assert_eq!(state.command_palette.selected, 0); assert_eq!(state.command_palette.scroll, 0); @@ -623,14 +773,28 @@ mod tests { #[test] fn ctrl_backspace_deletes_previous_query_word_and_resets_position() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "export canvas clipboard ".to_string(); state.command_palette.selected = 4; state.command_palette.scroll = 3; - assert!(state.handle_command_palette_key(crate::input::Key::Ctrl)); - assert!(state.handle_command_palette_key(crate::input::Key::Backspace)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Ctrl + ) + ); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Backspace + )); assert_eq!(state.command_palette.query, "export canvas "); assert_eq!(state.command_palette.selected, 0); @@ -639,38 +803,80 @@ mod tests { #[test] fn ctrl_backspace_stops_at_shortcut_token_separator() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "ctrl+shift+f".to_string(); - assert!(state.handle_command_palette_key(crate::input::Key::Ctrl)); - assert!(state.handle_command_palette_key(crate::input::Key::Backspace)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Ctrl + ) + ); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Backspace + )); assert_eq!(state.command_palette.query, "ctrl+shift+"); } #[test] fn ctrl_backspace_stops_at_slash_token_separator() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "capture/file".to_string(); - assert!(state.handle_command_palette_key(crate::input::Key::Ctrl)); - assert!(state.handle_command_palette_key(crate::input::Key::Backspace)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Ctrl + ) + ); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Backspace + )); assert_eq!(state.command_palette.query, "capture/"); } #[test] fn ctrl_u_clears_query_and_resets_position() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "status bar".to_string(); state.command_palette.selected = 4; state.command_palette.scroll = 3; - assert!(state.handle_command_palette_key(crate::input::Key::Ctrl)); - assert!(state.handle_command_palette_key(crate::input::Key::Char('u'))); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Ctrl + ) + ); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('u') + )); assert!(state.command_palette.query.is_empty()); assert_eq!(state.command_palette.selected, 0); @@ -679,6 +885,12 @@ mod tests { #[test] fn down_key_keeps_selection_visible_while_scrolling_past_headers() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); // Enough commands that walking twice the window is always valid. @@ -687,7 +899,10 @@ mod tests { let mut scrolled = false; for step in 0..COMMAND_PALETTE_MAX_VISIBLE * 2 { - assert!(state.handle_command_palette_key(crate::input::Key::Down)); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Down + )); // Selection advances one command per press. assert_eq!(state.command_palette.selected, step + 1); // The selected command's display row stays inside the visible window @@ -708,12 +923,23 @@ mod tests { #[test] fn home_key_jumps_to_first_command() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.selected = 5; state.command_palette.scroll = 3; - assert!(state.handle_command_palette_key(crate::input::Key::Home)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Home + ) + ); assert_eq!(state.command_palette.selected, 0); assert_eq!(state.command_palette.scroll, 0); @@ -721,12 +947,21 @@ mod tests { #[test] fn end_key_jumps_to_last_command_and_scrolls_into_view() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); let filtered_len = state.filtered_commands().len(); assert!(filtered_len > COMMAND_PALETTE_MAX_VISIBLE); - assert!(state.handle_command_palette_key(crate::input::Key::End)); + assert!( + state + .handle_command_palette_key_with_resources(route_resources, crate::input::Key::End) + ); assert_eq!(state.command_palette.selected, filtered_len - 1); // Scroll is measured in display rows (headers included), so the bottom of @@ -744,10 +979,21 @@ mod tests { #[test] fn held_down_key_repeats_after_delay_until_release() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); - assert!(state.handle_command_palette_key(crate::input::Key::Down)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Down + ) + ); assert_eq!(state.command_palette.selected, 1); assert!( state @@ -808,16 +1054,33 @@ mod tests { #[test] fn escape_key_closes_command_palette() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); assert!(state.command_palette.open); - assert!(state.handle_command_palette_key(crate::input::Key::Escape)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Escape + ) + ); assert!(!state.command_palette.open); } #[test] fn return_key_executes_selected_command_and_records_it() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "status bar".to_string(); @@ -828,7 +1091,12 @@ mod tests { ); assert!(state.ui_visibility.show_status_bar); - assert!(state.handle_command_palette_key(crate::input::Key::Return)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Return + ) + ); assert!(!state.command_palette.open); assert!(!state.ui_visibility.show_status_bar); assert_eq!( @@ -839,6 +1107,12 @@ mod tests { #[test] fn return_key_sets_pending_canvas_export_backend_action() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "export canvas clipboard".to_string(); @@ -848,7 +1122,12 @@ mod tests { crate::config::keybindings::Action::ExportCanvasClipboard ); - assert!(state.handle_command_palette_key(crate::input::Key::Return)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Return + ) + ); assert_eq!( state.take_pending_backend_action(), @@ -860,6 +1139,12 @@ mod tests { #[test] fn return_key_sets_pending_board_pdf_export_backend_action() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "export pdf".to_string(); @@ -869,7 +1154,12 @@ mod tests { crate::config::keybindings::Action::ExportBoardPdfFile ); - assert!(state.handle_command_palette_key(crate::input::Key::Return)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Return + ) + ); assert_eq!( state.take_pending_backend_action(), @@ -881,6 +1171,12 @@ mod tests { #[test] fn return_key_sets_pending_all_boards_pdf_export_backend_action() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "all boards pdf".to_string(); @@ -890,7 +1186,12 @@ mod tests { crate::config::keybindings::Action::ExportAllBoardsPdfFile ); - assert!(state.handle_command_palette_key(crate::input::Key::Return)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Return + ) + ); assert_eq!( state.take_pending_backend_action(), @@ -902,6 +1203,12 @@ mod tests { #[test] fn return_key_sets_pending_clear_saved_tool_state_backend_action() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.query = "clear saved tool state".to_string(); @@ -911,7 +1218,12 @@ mod tests { crate::config::keybindings::Action::ClearSavedToolState ); - assert!(state.handle_command_palette_key(crate::input::Key::Return)); + assert!( + state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Return + ) + ); assert_eq!( state.take_pending_backend_action(), @@ -930,12 +1242,21 @@ mod tests { #[test] fn char_key_appends_query_and_resets_selection_and_scroll() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_state(); state.toggle_command_palette(); state.command_palette.selected = 3; state.command_palette.scroll = 2; - assert!(state.handle_command_palette_key(crate::input::Key::Char('z'))); + assert!(state.handle_command_palette_key_with_resources( + route_resources, + crate::input::Key::Char('z') + )); assert_eq!(state.command_palette.query, "z"); assert_eq!(state.command_palette.selected, 0); assert_eq!(state.command_palette.scroll, 0); diff --git a/src/input/state/core/font_cycle.rs b/src/input/state/core/font_cycle.rs index 61159a0e0..3d5222170 100644 --- a/src/input/state/core/font_cycle.rs +++ b/src/input/state/core/font_cycle.rs @@ -10,8 +10,8 @@ //! blur tools already use for their variants. use super::InputState; +use crate::draw::TextMeasurer; use crate::draw::{FontDescriptor, families_match}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; impl InputState { /// Install the configured list. Blank and repeated names are the config @@ -51,10 +51,6 @@ impl InputState { /// The toast names the family because a font change has no visible effect /// until something is typed, and because a family name is the only way to /// tell two similar faces apart at a glance. - pub(crate) fn cycle_font_family(&mut self) -> bool { - with_legacy_measurer(|measurer| self.cycle_font_family_with_measurer(measurer)) - } - pub(crate) fn cycle_font_family_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { if self.style.font_cycle.is_empty() { self.push_toast( @@ -173,19 +169,21 @@ mod tests { #[test] fn an_empty_list_turns_the_action_off_rather_than_panicking() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); state.set_font_cycle(Vec::new()); assert_eq!(state.next_font_family("Sans"), None); - assert!(!state.cycle_font_family()); + assert!(!state.cycle_font_family_with_measurer(&route_measurer)); } #[test] fn cycling_with_nothing_selected_sets_what_the_next_label_uses() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = state_with_cycle(); let before = state.style.font_descriptor.family.clone(); - assert!(state.cycle_font_family()); + assert!(state.cycle_font_family_with_measurer(&route_measurer)); assert_ne!(state.style.font_descriptor.family, before); assert!( @@ -198,6 +196,7 @@ mod tests { #[test] fn cycling_with_text_selected_restyles_that_text_and_leaves_the_tool_alone() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = state_with_cycle(); let tool_font = state.style.font_descriptor.family.clone(); let id = state.boards.active_frame_mut().add_shape(Shape::Text { @@ -216,7 +215,7 @@ mod tests { }); state.set_selection(vec![id]); - assert!(state.cycle_font_family()); + assert!(state.cycle_font_family_with_measurer(&route_measurer)); let frame = state.boards.active_frame(); let Some(Shape::Text { @@ -234,6 +233,7 @@ mod tests { #[test] fn a_selection_with_no_text_in_it_falls_through_to_the_tool_font() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = state_with_cycle(); let before = state.style.font_descriptor.family.clone(); let id = state.boards.active_frame_mut().add_shape(Shape::Rect { @@ -247,7 +247,7 @@ mod tests { }); state.set_selection(vec![id]); - assert!(state.cycle_font_family()); + assert!(state.cycle_font_family_with_measurer(&route_measurer)); assert_ne!(state.style.font_descriptor.family, before); } diff --git a/src/input/state/core/font_picker/input.rs b/src/input/state/core/font_picker/input.rs index fcd8ce5a7..c3e6b4670 100644 --- a/src/input/state/core/font_picker/input.rs +++ b/src/input/state/core/font_picker/input.rs @@ -25,7 +25,12 @@ impl InputState { /// Every printable character goes into the query, so a family name can be /// typed straight in without a mode change. That is also why the filter /// toggle is `Tab` rather than a letter. - pub(crate) fn handle_font_picker_key(&mut self, key: Key, text: Option<&str>) -> bool { + pub(crate) fn handle_font_picker_key_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + key: Key, + text: Option<&str>, + ) -> bool { if !self.font_picker.open { return false; } @@ -34,7 +39,7 @@ impl InputState { self.close_font_picker(); } Key::Return => { - self.commit_font_picker(); + self.commit_font_picker_with_measurer(measurer); } Key::Tab => { self.font_picker.filter = self.font_picker.filter.next(); diff --git a/src/input/state/core/font_picker/tests.rs b/src/input/state/core/font_picker/tests.rs index db37bf87a..0ca9f7705 100644 --- a/src/input/state/core/font_picker/tests.rs +++ b/src/input/state/core/font_picker/tests.rs @@ -124,6 +124,7 @@ fn catalog_worker_failure_is_visible_and_reopening_allows_a_retry() { #[test] fn opening_lists_every_installed_family_and_closing_forgets_the_query() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); open_ready_font_picker(&mut state); @@ -133,7 +134,7 @@ fn opening_lists_every_installed_family_and_closing_forgets_the_query() { system_font_families().len() ); - state.handle_font_picker_key(Key::Char('x'), Some("x")); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Char('x'), Some("x")); assert_eq!(state.font_picker_query(), "x"); state.close_font_picker(); @@ -162,28 +163,38 @@ fn the_picker_opens_on_the_font_already_in_use() { #[test] fn typing_narrows_the_list_and_backspace_widens_it_again() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); open_ready_font_picker(&mut state); let all = state.font_picker_families().len(); for ch in "zzzz".chars() { - state.handle_font_picker_key(Key::Char(ch), Some(&ch.to_string())); + state.handle_font_picker_key_with_measurer( + &route_measurer, + Key::Char(ch), + Some(&ch.to_string()), + ); } assert!(state.font_picker_families().len() < all); for _ in 0..4 { - state.handle_font_picker_key(Key::Backspace, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Backspace, None); } assert_eq!(state.font_picker_families().len(), all); } #[test] fn a_query_that_matches_nothing_leaves_an_empty_list_rather_than_the_whole_one() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); open_ready_font_picker(&mut state); for ch in "qqzzxxjj".chars() { - state.handle_font_picker_key(Key::Char(ch), Some(&ch.to_string())); + state.handle_font_picker_key_with_measurer( + &route_measurer, + Key::Char(ch), + Some(&ch.to_string()), + ); } assert!(state.font_picker_families().is_empty()); @@ -194,21 +205,22 @@ fn a_query_that_matches_nothing_leaves_an_empty_list_rather_than_the_whole_one() #[test] fn arrow_keys_clamp_at_both_ends_instead_of_wrapping() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); open_ready_font_picker(&mut state); let count = state.font_picker_families().len(); state.set_font_picker_selection(0); - state.handle_font_picker_key(Key::Up, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Up, None); assert_eq!( state.font_picker_selected(), 0, "wrapping to the bottom of a 269-item list is never what Up meant" ); - state.handle_font_picker_key(Key::End, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::End, None); assert_eq!(state.font_picker_selected(), count - 1); - state.handle_font_picker_key(Key::Down, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Down, None); assert_eq!(state.font_picker_selected(), count - 1); } @@ -300,15 +312,16 @@ fn a_short_output_scrolls_by_the_rows_it_actually_shows() { #[test] fn tab_switches_to_monospace_and_back() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); open_ready_font_picker(&mut state); let all = state.font_picker_families().len(); - state.handle_font_picker_key(Key::Tab, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Tab, None); assert_eq!(state.font_picker_filter(), FontPickerFilter::Monospace); assert!(state.font_picker_families().len() <= all); - state.handle_font_picker_key(Key::Tab, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Tab, None); assert_eq!(state.font_picker_filter(), FontPickerFilter::All); assert_eq!(state.font_picker_families().len(), all); } @@ -396,12 +409,13 @@ fn recents_keep_the_most_recent_first_without_repeats() { #[test] fn escape_closes_without_changing_anything() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); let before = state.style.font_descriptor.family.clone(); open_ready_font_picker(&mut state); state.set_font_picker_selection(3); - state.handle_font_picker_key(Key::Escape, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Escape, None); assert!(!state.is_font_picker_open()); assert_eq!(state.style.font_descriptor.family, before); @@ -410,19 +424,21 @@ fn escape_closes_without_changing_anything() { #[test] fn stray_keys_are_swallowed_rather_than_reaching_the_canvas_behind_the_modal() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); open_ready_font_picker(&mut state); - assert!(state.handle_font_picker_key(Key::Delete, None)); - assert!(state.handle_font_picker_key(Key::Ctrl, None)); + assert!(state.handle_font_picker_key_with_measurer(&route_measurer, Key::Delete, None)); + assert!(state.handle_font_picker_key_with_measurer(&route_measurer, Key::Ctrl, None)); assert!(state.is_font_picker_open()); } #[test] fn a_closed_picker_consumes_nothing() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); - assert!(!state.handle_font_picker_key(Key::Escape, None)); + assert!(!state.handle_font_picker_key_with_measurer(&route_measurer, Key::Escape, None)); assert!(!state.font_picker_hover(10.0, 10.0)); assert!(!state.font_picker_press(10.0, 10.0)); } @@ -502,6 +518,7 @@ fn scrolling_carries_the_highlight_only_when_it_would_be_left_behind() { #[test] fn a_held_arrow_repeats_after_a_delay_and_stops_on_release() { + let route_measurer = crate::draw::TextMeasurer::default(); use std::time::{Duration, Instant}; let mut state = open_picker(); @@ -510,7 +527,7 @@ fn a_held_arrow_repeats_after_a_delay_and_stops_on_release() { } let now = Instant::now(); - state.handle_font_picker_key(Key::Down, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Down, None); assert_eq!( state.font_picker_selected(), 1, @@ -536,6 +553,7 @@ fn a_held_arrow_repeats_after_a_delay_and_stops_on_release() { #[test] fn a_long_hold_repeats_faster_than_a_short_one() { + let route_measurer = crate::draw::TextMeasurer::default(); use std::time::{Duration, Instant}; // The list runs to hundreds of families. At the command palette's flat rate @@ -546,7 +564,7 @@ fn a_long_hold_repeats_faster_than_a_short_one() { return; } let start = Instant::now(); - state.handle_font_picker_key(Key::Down, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Down, None); let steps_in = |state: &mut InputState, from: Duration, window: Duration| { let deadline = start + from + window; @@ -580,12 +598,13 @@ fn a_long_hold_repeats_faster_than_a_short_one() { #[test] fn moving_the_highlight_repaints_the_panel_rather_than_the_screen() { + let route_measurer = crate::draw::TextMeasurer::default(); // A held arrow ticks up to fifty times a second. Marking the whole surface // each time is the entire canvas re-rendered per row. let mut state = open_picker(); let _ = state.take_dirty_regions(); - state.handle_font_picker_key(Key::Down, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Down, None); let regions = state.take_dirty_regions(); assert!(!regions.is_empty(), "the move has to repaint something"); @@ -599,6 +618,7 @@ fn moving_the_highlight_repaints_the_panel_rather_than_the_screen() { #[test] fn the_first_query_that_shrinks_the_list_repaints_the_panel_it_is_leaving() { + let route_measurer = crate::draw::TextMeasurer::default(); // Opening on the installed catalog draws a tall panel; the first query can // cut it to no rows. Partial repaints clip to their damage, so unless the // taller panel is damaged too its lower half stays on screen underneath. @@ -614,7 +634,7 @@ fn the_first_query_that_shrinks_the_list_repaints_the_panel_it_is_leaving() { // mutation. Any earlier mutation would record the tall panel as a side // effect and hide the defect this test protects. let query = impossible_family_query(); - state.handle_font_picker_key(Key::Char('x'), Some(&query)); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Char('x'), Some(&query)); assert!(state.font_picker_families().is_empty()); let short = state .font_picker_panel_bounds() @@ -635,6 +655,7 @@ fn the_first_query_that_shrinks_the_list_repaints_the_panel_it_is_leaving() { #[test] fn the_first_narrowing_query_after_resize_repaints_the_resized_panel() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); state.update_screen_dimensions(1920, 1080); open_ready_font_picker(&mut state); @@ -658,7 +679,7 @@ fn the_first_narrowing_query_after_resize_repaints_the_resized_panel() { let _ = state.take_dirty_regions(); let query = impossible_family_query(); - state.handle_font_picker_key(Key::Char('x'), Some(&query)); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Char('x'), Some(&query)); assert!(state.font_picker_families().is_empty()); let regions = state.take_dirty_regions(); @@ -670,12 +691,13 @@ fn the_first_narrowing_query_after_resize_repaints_the_resized_panel() { #[test] fn reopening_the_picker_does_not_leave_the_old_key_repeating() { + let route_measurer = crate::draw::TextMeasurer::default(); use std::time::{Duration, Instant}; let mut state = make_test_input_state(); state.update_screen_dimensions(1920, 1080); open_ready_font_picker(&mut state); - state.handle_font_picker_key(Key::Down, None); + state.handle_font_picker_key_with_measurer(&route_measurer, Key::Down, None); assert!(state.font_picker_repeat_timeout(Instant::now()).is_some()); open_ready_font_picker(&mut state); diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs index 8178786b0..73c95a1f0 100644 --- a/src/input/state/core/history.rs +++ b/src/input/state/core/history.rs @@ -144,10 +144,11 @@ mod tests { #[test] fn apply_action_side_effects_closes_properties_panel_after_modify() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_state(); let shape_id = state.boards.active_frame_mut().add_shape(rect(10, 20)); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); assert!(state.is_properties_panel_open()); let _ = state.take_dirty_regions(); diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index c4353bd7e..cd8e8cc75 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -80,9 +80,19 @@ impl InputState { } pub fn execute_menu_command(&mut self, command: MenuCommand) { + crate::input::state::with_legacy_text_resources(|resources| { + self.execute_menu_command_with_resources(resources, command) + }) + } + + pub(crate) fn execute_menu_command_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + command: MenuCommand, + ) { match command { MenuCommand::Copy => { - self.handle_action(Action::CopySelection); + self.handle_action_with_resources(resources, Action::CopySelection); self.close_context_menu(); } MenuCommand::Paste => { @@ -92,41 +102,39 @@ impl InputState { self.close_context_menu(); } MenuCommand::Delete => { - self.delete_selection(); + self.delete_selection_with(resources.measurer); self.close_context_menu(); } MenuCommand::Duplicate => { - self.duplicate_selection(); + self.duplicate_selection_with(resources.measurer); self.close_context_menu(); } MenuCommand::SelectHoveredShape => { - crate::draw::with_legacy_measurer(|measurer| { - self.select_hovered_context_menu_shape_with(measurer); - }); + self.select_hovered_context_menu_shape_with(resources.measurer); } MenuCommand::MoveToFront => { - self.move_selection_to_front(); + self.move_selection_to_front_with(resources.measurer); self.close_context_menu(); } MenuCommand::MoveToBack => { - self.move_selection_to_back(); + self.move_selection_to_back_with(resources.measurer); self.close_context_menu(); } MenuCommand::Lock => { - self.set_selection_locked(true); + self.set_selection_locked_with(resources.measurer, true); self.close_context_menu(); } MenuCommand::Unlock => { - self.set_selection_locked(false); + self.set_selection_locked_with(resources.measurer, false); self.close_context_menu(); } MenuCommand::Properties => { - if self.show_properties_panel() { + if self.show_properties_panel_with(resources.measurer) { self.close_context_menu(); } } MenuCommand::EditText => { - if self.edit_selected_text() { + if self.edit_selected_text_with(resources.measurer) { self.close_context_menu(); } } @@ -163,7 +171,7 @@ impl InputState { // Through the action, not the primitive: the action is what // queues the durable click-highlight change, and the other // chrome commands in this menu already route the same way. - self.handle_action(Action::ToggleHighlightTool); + self.handle_action_with_resources(resources, Action::ToggleHighlightTool); self.close_context_menu(); } MenuCommand::OpenPagesMenu => { @@ -198,23 +206,26 @@ impl InputState { self.needs_redraw = true; } MenuCommand::PagePrev => { - self.page_prev(); + self.page_prev_with_measurer(resources.measurer); self.close_context_menu(); } MenuCommand::PageNext => { - self.page_next(); + self.page_next_with_measurer(resources.measurer); self.close_context_menu(); } MenuCommand::PageNew => { - self.page_new(); + self.page_new_with_measurer(resources.measurer); self.close_context_menu(); } MenuCommand::PageDuplicate => { - self.page_duplicate(); + self.page_duplicate_with_measurer(resources.measurer); self.close_context_menu(); } MenuCommand::PageDelete => { - if matches!(self.page_delete(), crate::draw::PageDeleteOutcome::Cleared) { + if matches!( + self.page_delete_with_measurer(resources.measurer), + crate::draw::PageDeleteOutcome::Cleared + ) { self.push_toast( ToastPriority::Info, "ui", @@ -233,8 +244,11 @@ impl InputState { if let Some(target) = self.context_menu.page_target { let affects_panel = self.board_picker_page_context_change_affects_panel(target.board_index); - if self.duplicate_page_in_board(target.board_index, target.page_index) - && affects_panel + if self.duplicate_page_in_board_with_measurer( + resources.measurer, + target.board_index, + target.page_index, + ) && affects_panel { self.board_picker_reconcile_page_nav_after_page_change(); } @@ -245,7 +259,11 @@ impl InputState { if let Some(target) = self.context_menu.page_target { let affects_panel = self.board_picker_page_context_change_affects_panel(target.board_index); - let outcome = self.delete_page_in_board(target.board_index, target.page_index); + let outcome = self.delete_page_in_board_with_measurer( + resources.measurer, + target.board_index, + target.page_index, + ); if !matches!(outcome, crate::draw::PageDeleteOutcome::Pending) && affects_panel { self.board_picker_reconcile_page_nav_after_page_change(); @@ -265,7 +283,8 @@ impl InputState { .iter() .position(|board| board.spec.id == id) { - let moved = self.move_page_between_boards_with_activation( + let moved = self.move_page_between_boards_with_activation_with_measurer( + resources.measurer, source_board, page_index, target_index, @@ -280,52 +299,52 @@ impl InputState { self.close_context_menu(); } MenuCommand::SwitchToPage(index) => { - self.switch_to_page(index); + self.switch_to_page_with_measurer(resources.measurer, index); self.close_context_menu(); } MenuCommand::OpenBoardPicker => { self.close_context_menu(); - self.toggle_board_picker(); + self.toggle_board_picker_with_measurer(resources.measurer); } MenuCommand::BoardPrev => { - self.switch_board_prev(); + self.switch_board_prev_with_measurer(resources.measurer); self.close_context_menu(); } MenuCommand::BoardNext => { - self.switch_board_next(); + self.switch_board_next_with_measurer(resources.measurer); self.close_context_menu(); } MenuCommand::BoardNew => { - self.create_board(); + self.create_board_with_measurer(resources.measurer); self.close_context_menu(); } MenuCommand::BoardDuplicate => { - self.duplicate_board(); + self.duplicate_board_with_measurer(resources.measurer); self.close_context_menu(); } MenuCommand::BoardDelete => { - self.delete_active_board(); + self.delete_active_board_with_measurer(resources.measurer); self.close_context_menu(); } MenuCommand::SwitchToBoard { id } => { - self.switch_board(&id); + self.switch_board_with_measurer(resources.measurer, &id); self.close_context_menu(); } MenuCommand::SwitchToWhiteboard => { - self.switch_board(BOARD_ID_WHITEBOARD); + self.switch_board_with_measurer(resources.measurer, BOARD_ID_WHITEBOARD); self.close_context_menu(); } MenuCommand::SwitchToBlackboard => { - self.switch_board(BOARD_ID_BLACKBOARD); + self.switch_board_with_measurer(resources.measurer, BOARD_ID_BLACKBOARD); self.close_context_menu(); } MenuCommand::ReturnToTransparent => { - self.switch_board(BOARD_ID_TRANSPARENT); + self.switch_board_with_measurer(resources.measurer, BOARD_ID_TRANSPARENT); self.close_context_menu(); } MenuCommand::OpenRadialMenu => { self.close_context_menu(); - self.handle_action(Action::ToggleRadialMenu); + self.handle_action_with_resources(resources, Action::ToggleRadialMenu); } MenuCommand::ToggleHelp => { self.toggle_help_overlay(); @@ -333,11 +352,11 @@ impl InputState { } MenuCommand::ShowToolbar => { self.close_context_menu(); - self.handle_action(Action::ToggleToolbar); + self.handle_action_with_resources(resources, Action::ToggleToolbar); } MenuCommand::ShowStatusBar => { self.close_context_menu(); - self.handle_action(Action::ToggleStatusBar); + self.handle_action_with_resources(resources, Action::ToggleStatusBar); } MenuCommand::OpenCommandPalette => { self.close_context_menu(); diff --git a/src/input/state/core/menus/focus.rs b/src/input/state/core/menus/focus.rs index 30e631b49..026ea64d9 100644 --- a/src/input/state/core/menus/focus.rs +++ b/src/input/state/core/menus/focus.rs @@ -78,7 +78,10 @@ impl InputState { self.select_edge_context_menu_entry(false) } - pub(crate) fn activate_context_menu_selection(&mut self) -> bool { + pub(crate) fn activate_context_menu_selection_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) -> bool { if !self.is_context_menu_open() { return false; } @@ -95,7 +98,7 @@ impl InputState { return false; } if let Some(command) = entry.command.clone() { - self.execute_menu_command(command); + self.execute_menu_command_with_resources(resources, command); } else { self.close_context_menu(); } diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index dcde1e89f..7789bbe5b 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -315,6 +315,7 @@ mod wheel_tests { #[test] fn the_properties_panel_leaves_the_wheel_to_the_canvas() { + let route_measurer = crate::draw::TextMeasurer::default(); // It docks beside the canvas rather than over it, and the canvas stays // drawable underneath, so the wheel still means what it means elsewhere. let mut state = make_test_input_state(); @@ -331,7 +332,7 @@ mod wheel_tests { thick: 2.0, }); state.set_selection(vec![id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); assert!(state.is_properties_panel_open()); assert!(!state.modal_owns_wheel()); diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index bb4d32bc4..b11ecc24b 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -11,12 +11,6 @@ impl InputState { self.adjust_properties_panel_entry_with(measurer, 0) } - pub(crate) fn adjust_properties_panel_entry(&mut self, direction: i32) -> bool { - with_legacy_measurer(|measurer| { - self.adjust_properties_panel_entry_with(measurer, direction) - }) - } - pub(crate) fn adjust_properties_panel_entry_with( &mut self, measurer: &TextMeasurer, @@ -75,16 +69,6 @@ impl InputState { /// popup: adjusts the selection property of `kind` when the current /// selection exposes it and the entry is editable. Refreshes the /// popup if it happens to be open. - pub(crate) fn adjust_selection_property_kind( - &mut self, - kind: SelectionPropertyKind, - direction: i32, - ) -> bool { - with_legacy_measurer(|measurer| { - self.adjust_selection_property_kind_with(measurer, kind, direction) - }) - } - pub(crate) fn adjust_selection_property_kind_with( &mut self, measurer: &TextMeasurer, @@ -116,10 +100,6 @@ impl InputState { /// selected arrow is locked. Visible property controls stay disabled, while /// the action still reaches the shared apply reporter so it can explain why /// nothing changed. - pub(crate) fn cycle_selected_arrow_style_from_action(&mut self) -> bool { - with_legacy_measurer(|measurer| self.cycle_selected_arrow_style_from_action_with(measurer)) - } - pub(crate) fn cycle_selected_arrow_style_from_action_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/properties/apply_selection/actions/color.rs b/src/input/state/core/properties/apply_selection/actions/color.rs index cbc3f6744..a83248a77 100644 --- a/src/input/state/core/properties/apply_selection/actions/color.rs +++ b/src/input/state/core/properties/apply_selection/actions/color.rs @@ -6,12 +6,6 @@ use crate::input::state::core::properties::utils::{ }; impl InputState { - pub(crate) fn apply_selection_color_value(&mut self, target: Color) -> bool { - crate::draw::with_legacy_measurer(|measurer| { - self.apply_selection_color_value_with(measurer, target) - }) - } - pub(crate) fn apply_selection_color_value_with( &mut self, measurer: &TextMeasurer, @@ -157,6 +151,7 @@ mod tests { #[test] fn apply_selection_color_value_preserves_marker_alpha() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_state(); let marker_id = state .boards @@ -173,7 +168,7 @@ mod tests { }); state.set_selection(vec![marker_id]); - assert!(state.apply_selection_color_value(RED)); + assert!(state.apply_selection_color_value_with(&route_measurer, RED)); match &state .boards diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs index 8548a4af3..17f37fb6d 100644 --- a/src/input/state/core/properties/panel.rs +++ b/src/input/state/core/properties/panel.rs @@ -2,7 +2,7 @@ use super::super::base::InputState; use super::panel_layout::selection_panel_anchor; use super::types::{PropertiesPanelLayout, SelectionPropertyEntry, ShapePropertiesPanel}; use super::utils::format_timestamp; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::TextMeasurer; impl InputState { pub fn properties_panel(&self) -> Option<&ShapePropertiesPanel> { @@ -43,10 +43,6 @@ impl InputState { self.needs_redraw = true; } - pub(crate) fn show_properties_panel(&mut self) -> bool { - with_legacy_measurer(|measurer| self.show_properties_panel_with(measurer)) - } - pub(crate) fn show_properties_panel_with(&mut self, measurer: &TextMeasurer) -> bool { if self.selected_shape_ids().is_empty() { return false; diff --git a/src/input/state/core/properties/panel_layout/focus.rs b/src/input/state/core/properties/panel_layout/focus.rs index bdf4562ce..5ce1dc343 100644 --- a/src/input/state/core/properties/panel_layout/focus.rs +++ b/src/input/state/core/properties/panel_layout/focus.rs @@ -129,6 +129,7 @@ mod tests { } fn open_rect_panel(state: &mut InputState) { + let route_measurer = crate::draw::TextMeasurer::default(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, y: 20, @@ -139,7 +140,7 @@ mod tests { thick: state.style.current_thickness, }); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); } #[test] diff --git a/src/input/state/core/radial_menu/state.rs b/src/input/state/core/radial_menu/state.rs index 8af07a0b7..4b477be37 100644 --- a/src/input/state/core/radial_menu/state.rs +++ b/src/input/state/core/radial_menu/state.rs @@ -139,6 +139,15 @@ impl InputState { /// Select the currently hovered segment and close the menu. pub fn radial_menu_select_hovered(&mut self) { + crate::input::state::with_legacy_text_resources(|resources| { + self.radial_menu_select_hovered_with_resources(resources) + }) + } + + pub(crate) fn radial_menu_select_hovered_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) { let hover = match &self.radial_menu.state { RadialMenuState::Open { hover, .. } => *hover, _ => return, @@ -151,13 +160,13 @@ impl InputState { return; } Some(RadialSegmentId::Tool(idx)) => { - self.dispatch_tool_segment(idx); + self.dispatch_tool_segment_with_resources(resources, idx); } Some(RadialSegmentId::SubTool(parent, child)) => { - self.dispatch_sub_tool_segment(parent, child); + self.dispatch_sub_tool_segment_with_resources(resources, parent, child); } Some(RadialSegmentId::Color(idx)) => { - self.dispatch_color_segment(idx); + self.dispatch_color_segment_with_measurer(resources.measurer, idx); } Some(RadialSegmentId::SizeRing) => { // The gauge is a drag/scroll surface, never a commit target. @@ -206,6 +215,18 @@ impl InputState { button: MouseButton, x: f64, y: f64, + ) -> bool { + crate::input::state::with_legacy_text_resources(|resources| { + self.radial_menu_handle_release_with_resources(resources, button, x, y) + }) + } + + pub(crate) fn radial_menu_handle_release_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + button: MouseButton, + x: f64, + y: f64, ) -> bool { if !self.is_radial_menu_open() { return false; @@ -251,12 +272,12 @@ impl InputState { y, ) { Some(RadialSegmentId::SubTool(parent, child)) => { - self.dispatch_sub_tool_segment(parent, child); + self.dispatch_sub_tool_segment_with_resources(resources, parent, child); self.close_radial_menu(); return true; } Some(RadialSegmentId::Color(idx)) => { - self.dispatch_color_segment(idx); + self.dispatch_color_segment_with_measurer(resources.measurer, idx); self.close_radial_menu(); return true; } @@ -273,7 +294,7 @@ impl InputState { self.radial_menu_expand_sub_ring(idx); return true; } - self.dispatch_tool_segment(idx); + self.dispatch_tool_segment_with_resources(resources, idx); self.close_radial_menu(); true } @@ -372,23 +393,36 @@ impl InputState { // never behave differently from the same action fired by a keybinding // or the toolbar. - fn dispatch_tool_segment(&mut self, idx: u8) { + fn dispatch_tool_segment_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + idx: u8, + ) { if let Some(slice) = compass_slice(idx) && let RadialSliceKind::Action(action) = slice.kind { - self.handle_action(action); + self.handle_action_with_resources(resources, action); } } - fn dispatch_sub_tool_segment(&mut self, parent: u8, child: u8) { + fn dispatch_sub_tool_segment_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + parent: u8, + child: u8, + ) { if let Some(action) = sub_ring_children(parent).get(child as usize) { - self.handle_action(*action); + self.handle_action_with_resources(resources, *action); } } - fn dispatch_color_segment(&mut self, idx: u8) { + fn dispatch_color_segment_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + idx: u8, + ) { if let Some(swatch) = self.radial_ring_swatches().get(idx as usize) { - self.apply_color_from_ui(swatch.color); + self.apply_color_from_ui_with_measurer(measurer, swatch.color); } } diff --git a/src/input/state/core/selection_actions/arrow_bend/tests.rs b/src/input/state/core/selection_actions/arrow_bend/tests.rs index 8f994d2e7..2cff7a0f2 100644 --- a/src/input/state/core/selection_actions/arrow_bend/tests.rs +++ b/src/input/state/core/selection_actions/arrow_bend/tests.rs @@ -272,6 +272,7 @@ fn arrow_style(state: &crate::input::InputState, id: crate::draw::ShapeId) -> Ar #[test] fn any_selection_property_change_ends_a_live_bend_first() { + let route_measurer = crate::draw::TextMeasurer::default(); // The guard sits on `dispatch_selection_property`, not on the arrow-style // action, because the toolbar and the shape properties panel reach the same // mutators by other routes — and because the hazard is not style-specific. @@ -296,7 +297,8 @@ fn any_selection_property_change_ends_a_live_bend_first() { assert!(state.drag_arrow_bend_to(200, 20, false)); let bent = arrow_bend(&state, id); - state.adjust_selection_property_kind( + state.adjust_selection_property_kind_with( + &route_measurer, crate::input::state::core::properties::SelectionPropertyKind::Thickness, 1, ); diff --git a/src/input/state/core/selection_actions/delete.rs b/src/input/state/core/selection_actions/delete.rs index 7620744a8..e8d09da17 100644 --- a/src/input/state/core/selection_actions/delete.rs +++ b/src/input/state/core/selection_actions/delete.rs @@ -6,10 +6,6 @@ use std::borrow::Cow; use std::collections::HashSet; impl InputState { - pub(crate) fn delete_selection(&mut self) -> bool { - with_legacy_measurer(|measurer| self.delete_selection_with(measurer)) - } - pub(crate) fn delete_selection_with(&mut self, measurer: &TextMeasurer) -> bool { let id_set: HashSet = { let ids = self.selected_shape_ids(); diff --git a/src/input/state/core/selection_actions/reorder.rs b/src/input/state/core/selection_actions/reorder.rs index c219516e9..03cecdca5 100644 --- a/src/input/state/core/selection_actions/reorder.rs +++ b/src/input/state/core/selection_actions/reorder.rs @@ -1,20 +1,12 @@ use super::super::base::InputState; +use crate::draw::TextMeasurer; use crate::draw::frame::UndoAction; -use crate::draw::{TextMeasurer, with_legacy_measurer}; impl InputState { - pub(crate) fn move_selection_to_front(&mut self) -> bool { - with_legacy_measurer(|measurer| self.move_selection_to_front_with(measurer)) - } - pub(crate) fn move_selection_to_front_with(&mut self, measurer: &TextMeasurer) -> bool { self.reorder_selection(measurer, true) } - pub(crate) fn move_selection_to_back(&mut self) -> bool { - with_legacy_measurer(|measurer| self.move_selection_to_back_with(measurer)) - } - pub(crate) fn move_selection_to_back_with(&mut self, measurer: &TextMeasurer) -> bool { self.reorder_selection(measurer, false) } diff --git a/src/input/state/core/selection_actions/state.rs b/src/input/state/core/selection_actions/state.rs index a4042d004..a41559a6a 100644 --- a/src/input/state/core/selection_actions/state.rs +++ b/src/input/state/core/selection_actions/state.rs @@ -1,16 +1,12 @@ use super::super::base::InputState; use crate::draw::DirtyFullReason; +use crate::draw::TextMeasurer; use crate::draw::frame::{ShapeSnapshot, UndoAction}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::util::Rect; const SELECTION_DAMAGE_PADDING: i32 = 8; impl InputState { - pub(crate) fn set_selection_locked(&mut self, locked: bool) -> bool { - with_legacy_measurer(|measurer| self.set_selection_locked_with(measurer, locked)) - } - pub(crate) fn set_selection_locked_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/translation/mod.rs b/src/input/state/core/selection_actions/translation/mod.rs index f07f1bd31..7a3719915 100644 --- a/src/input/state/core/selection_actions/translation/mod.rs +++ b/src/input/state/core/selection_actions/translation/mod.rs @@ -89,10 +89,6 @@ impl InputState { moved_any } - pub(crate) fn translate_selection_with_undo(&mut self, dx: i32, dy: i32) -> bool { - with_legacy_measurer(|measurer| self.translate_selection_with_undo_with(measurer, dx, dy)) - } - pub(crate) fn translate_selection_with_undo_with( &mut self, measurer: &TextMeasurer, @@ -113,12 +109,6 @@ impl InputState { true } - pub(crate) fn move_selection_to_horizontal_edge(&mut self, to_start: bool) -> bool { - with_legacy_measurer(|measurer| { - self.move_selection_to_horizontal_edge_with(measurer, to_start) - }) - } - pub(crate) fn move_selection_to_horizontal_edge_with( &mut self, measurer: &TextMeasurer, @@ -144,12 +134,6 @@ impl InputState { self.translate_selection_with_undo_with(measurer, dx, 0) } - pub(crate) fn move_selection_to_vertical_edge(&mut self, to_start: bool) -> bool { - with_legacy_measurer(|measurer| { - self.move_selection_to_vertical_edge_with(measurer, to_start) - }) - } - pub(crate) fn move_selection_to_vertical_edge_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/tool_controls/precision_entry.rs b/src/input/state/core/tool_controls/precision_entry.rs index 4bf6ae31f..7334ed609 100644 --- a/src/input/state/core/tool_controls/precision_entry.rs +++ b/src/input/state/core/tool_controls/precision_entry.rs @@ -109,7 +109,11 @@ impl InputState { /// Keyboard handling while the popup is open (the same shape as /// `handle_color_picker_popup_key`): every key is consumed. - pub(in crate::input::state) fn handle_precision_entry_key(&mut self, key: Key) -> bool { + pub(in crate::input::state) fn handle_precision_entry_key_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + key: Key, + ) -> bool { if !self.is_precision_entry_open() { return false; } @@ -121,7 +125,7 @@ impl InputState { if let Some((target, value)) = self.take_precision_entry_commit() { let event = crate::ui::toolbar::ToolbarEvent::CommitPrecisionEntry { target, value }; - let _ = self.apply_toolbar_event(event); + let _ = self.apply_toolbar_event_with_resources(resources, event); } } Key::Backspace | Key::Delete => self.precision_entry_backspace(), @@ -140,6 +144,12 @@ mod tests { #[test] fn open_prefills_the_selected_current_value_and_typing_replaces_it() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_test_input_state(); state.style.current_thickness = 4.0; assert!(state.apply_toolbar_event(ToolbarEvent::OpenPrecisionEntry( @@ -152,29 +162,35 @@ mod tests { // First digit replaces the selection; further digits append; only // digits and one decimal point are accepted. - assert!(state.handle_precision_entry_key(Key::Char('1'))); - assert!(state.handle_precision_entry_key(Key::Char('2'))); - assert!(state.handle_precision_entry_key(Key::Char('.'))); - assert!(state.handle_precision_entry_key(Key::Char('.'))); - assert!(state.handle_precision_entry_key(Key::Char('x'))); - assert!(state.handle_precision_entry_key(Key::Char('5'))); + assert!(state.handle_precision_entry_key_with_resources(route_resources, Key::Char('1'))); + assert!(state.handle_precision_entry_key_with_resources(route_resources, Key::Char('2'))); + assert!(state.handle_precision_entry_key_with_resources(route_resources, Key::Char('.'))); + assert!(state.handle_precision_entry_key_with_resources(route_resources, Key::Char('.'))); + assert!(state.handle_precision_entry_key_with_resources(route_resources, Key::Char('x'))); + assert!(state.handle_precision_entry_key_with_resources(route_resources, Key::Char('5'))); let entry = state.precision_entry().expect("open entry"); assert_eq!(entry.buffer, "12.5"); assert!(!entry.selected); - assert!(state.handle_precision_entry_key(Key::Backspace)); + assert!(state.handle_precision_entry_key_with_resources(route_resources, Key::Backspace)); assert_eq!(state.precision_entry().expect("entry").buffer, "12."); } #[test] fn enter_commits_the_clamped_value_and_esc_cancels() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_test_input_state(); state.style.current_thickness = 4.0; state.open_precision_entry(PrecisionEntryTarget::Thickness); for ch in "999".chars() { - let _ = state.handle_precision_entry_key(Key::Char(ch)); + let _ = state.handle_precision_entry_key_with_resources(route_resources, Key::Char(ch)); } - assert!(state.handle_precision_entry_key(Key::Return)); + assert!(state.handle_precision_entry_key_with_resources(route_resources, Key::Return)); assert!(!state.is_precision_entry_open()); // Clamped to the shared thickness slider range. assert_eq!( @@ -185,17 +201,23 @@ mod tests { // Esc restores nothing and applies nothing. let before = state.style.current_thickness; state.open_precision_entry(PrecisionEntryTarget::Thickness); - let _ = state.handle_precision_entry_key(Key::Char('7')); - assert!(state.handle_precision_entry_key(Key::Escape)); + let _ = state.handle_precision_entry_key_with_resources(route_resources, Key::Char('7')); + assert!(state.handle_precision_entry_key_with_resources(route_resources, Key::Escape)); assert!(!state.is_precision_entry_open()); assert_eq!(state.style.current_thickness, before); // A closed popup consumes no keys. - assert!(!state.handle_precision_entry_key(Key::Char('1'))); + assert!(!state.handle_precision_entry_key_with_resources(route_resources, Key::Char('1'))); } #[test] fn font_size_target_commits_through_the_font_apply_arm() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = make_test_input_state(); state.open_precision_entry(PrecisionEntryTarget::FontSize); assert_eq!( @@ -216,7 +238,7 @@ mod tests { // An unparseable buffer commits nothing. state.open_precision_entry(PrecisionEntryTarget::FontSize); - let _ = state.handle_precision_entry_key(Key::Backspace); + let _ = state.handle_precision_entry_key_with_resources(route_resources, Key::Backspace); assert!(state.take_precision_entry_commit().is_none()); } diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs index df82f30a8..434d5f92e 100644 --- a/src/input/state/core/tool_controls/toolbar.rs +++ b/src/input/state/core/tool_controls/toolbar.rs @@ -426,23 +426,59 @@ impl InputState { /// Wrapper for undo that preserves existing action plumbing. pub fn toolbar_undo(&mut self) { - self.handle_action(Action::Undo); + crate::input::state::with_legacy_text_resources(|resources| { + self.toolbar_undo_with_resources(resources) + }) + } + + pub(crate) fn toolbar_undo_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) { + self.handle_action_with_resources(resources, Action::Undo); } /// Wrapper for redo that preserves existing action plumbing. pub fn toolbar_redo(&mut self) { - self.handle_action(Action::Redo); + crate::input::state::with_legacy_text_resources(|resources| { + self.toolbar_redo_with_resources(resources) + }) + } + + pub(crate) fn toolbar_redo_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) { + self.handle_action_with_resources(resources, Action::Redo); } /// Wrapper for clear that preserves existing action plumbing. pub fn toolbar_clear(&mut self) { - self.handle_action(Action::ClearCanvas); + crate::input::state::with_legacy_text_resources(|resources| { + self.toolbar_clear_with_resources(resources) + }) + } + + pub(crate) fn toolbar_clear_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) { + self.handle_action_with_resources(resources, Action::ClearCanvas); } /// Mouse-path clear: clears like `Action::ClearCanvas` and, when shapes /// were removed without a locked-shape warning, offers a short toast with /// an "Undo?" chip. The keyboard action and Shift+click stay instant. pub fn toolbar_clear_with_undo_toast(&mut self) { + crate::input::state::with_legacy_text_resources(|resources| { + self.toolbar_clear_with_undo_toast_with_resources(resources) + }) + } + + pub(crate) fn toolbar_clear_with_undo_toast_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) { let (has_locked, has_unlocked) = { let frame = self.boards.active_frame(); ( @@ -450,7 +486,7 @@ impl InputState { frame.shapes.iter().any(|shape| !shape.locked), ) }; - self.toolbar_clear(); + self.toolbar_clear_with_resources(resources); // The locked-shape paths already raise their own warning toasts in // `handle_action`; only the silent success path gets the undo offer. if has_unlocked && !has_locked { @@ -466,12 +502,30 @@ impl InputState { /// Wrapper for entering text mode. pub fn toolbar_enter_text_mode(&mut self) { - self.handle_action(Action::EnterTextMode); + crate::input::state::with_legacy_text_resources(|resources| { + self.toolbar_enter_text_mode_with_resources(resources) + }) + } + + pub(crate) fn toolbar_enter_text_mode_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) { + self.handle_action_with_resources(resources, Action::EnterTextMode); } /// Wrapper for entering sticky note mode. pub fn toolbar_enter_sticky_note_mode(&mut self) { - self.handle_action(Action::EnterStickyNoteMode); + crate::input::state::with_legacy_text_resources(|resources| { + self.toolbar_enter_sticky_note_mode_with_resources(resources) + }) + } + + pub(crate) fn toolbar_enter_sticky_note_mode_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) { + self.handle_action_with_resources(resources, Action::EnterStickyNoteMode); } } diff --git a/src/input/state/core/toolbar/apply/actions.rs b/src/input/state/core/toolbar/apply/actions.rs index 6a98bf973..c03c805f6 100644 --- a/src/input/state/core/toolbar/apply/actions.rs +++ b/src/input/state/core/toolbar/apply/actions.rs @@ -2,23 +2,35 @@ use crate::config::Action; use crate::input::{InputState, ZoomAction}; impl InputState { - pub(super) fn apply_toolbar_undo(&mut self) -> bool { - self.toolbar_undo(); + pub(super) fn apply_toolbar_undo_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) -> bool { + self.toolbar_undo_with_resources(resources); true } - pub(super) fn apply_toolbar_redo(&mut self) -> bool { - self.toolbar_redo(); + pub(super) fn apply_toolbar_redo_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) -> bool { + self.toolbar_redo_with_resources(resources); true } - pub(super) fn apply_toolbar_undo_all(&mut self) -> bool { - self.undo_all_immediate(); + pub(super) fn apply_toolbar_undo_all_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) -> bool { + self.undo_all_immediate_with_measurer(measurer); true } - pub(super) fn apply_toolbar_redo_all(&mut self) -> bool { - self.redo_all_immediate(); + pub(super) fn apply_toolbar_redo_all_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) -> bool { + self.redo_all_immediate_with_measurer(measurer); true } @@ -48,29 +60,42 @@ impl InputState { true } - pub(super) fn apply_toolbar_clear_canvas(&mut self, instant: bool) -> bool { + pub(super) fn apply_toolbar_clear_canvas_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + instant: bool, + ) -> bool { if instant { - self.toolbar_clear(); + self.toolbar_clear_with_resources(resources); } else { - self.toolbar_clear_with_undo_toast(); + self.toolbar_clear_with_undo_toast_with_resources(resources); } true } - pub(super) fn apply_toolbar_capture_screenshot(&mut self) -> bool { - self.handle_action(Action::CaptureSelection); + pub(super) fn apply_toolbar_capture_screenshot_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) -> bool { + self.handle_action_with_resources(resources, Action::CaptureSelection); self.close_top_toolbar_menus(); true } - pub(super) fn apply_toolbar_copy_text_from_screen(&mut self) -> bool { - self.handle_action(Action::CopyTextFromScreen); + pub(super) fn apply_toolbar_copy_text_from_screen_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) -> bool { + self.handle_action_with_resources(resources, Action::CopyTextFromScreen); self.close_top_toolbar_menus(); true } - pub(super) fn apply_toolbar_open_command_palette(&mut self) -> bool { - self.handle_action(Action::ToggleCommandPalette); + pub(super) fn apply_toolbar_open_command_palette_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) -> bool { + self.handle_action_with_resources(resources, Action::ToggleCommandPalette); self.close_top_toolbar_menus(); true } diff --git a/src/input/state/core/toolbar/apply/boards.rs b/src/input/state/core/toolbar/apply/boards.rs index 15ae32fde..401f26661 100644 --- a/src/input/state/core/toolbar/apply/boards.rs +++ b/src/input/state/core/toolbar/apply/boards.rs @@ -1,30 +1,18 @@ -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::TextMeasurer; use crate::input::InputState; use crate::input::state::{Toast, ToastPriority}; impl InputState { - pub(super) fn apply_toolbar_board_prev(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_board_prev_with(measurer)) - } - pub(super) fn apply_toolbar_board_prev_with(&mut self, measurer: &TextMeasurer) -> bool { self.switch_board_prev_with_measurer(measurer); true } - pub(super) fn apply_toolbar_board_next(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_board_next_with(measurer)) - } - pub(super) fn apply_toolbar_board_next_with(&mut self, measurer: &TextMeasurer) -> bool { self.switch_board_next_with_measurer(measurer); true } - pub(super) fn apply_toolbar_board_new(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_board_new_with(measurer)) - } - pub(super) fn apply_toolbar_board_new_with(&mut self, measurer: &TextMeasurer) -> bool { if self.create_board_with_measurer(measurer) { true @@ -38,19 +26,11 @@ impl InputState { } } - pub(super) fn apply_toolbar_board_delete(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_board_delete_with(measurer)) - } - pub(super) fn apply_toolbar_board_delete_with(&mut self, measurer: &TextMeasurer) -> bool { self.delete_active_board_with_measurer(measurer); true } - pub(super) fn apply_toolbar_toggle_board_picker(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_toggle_board_picker_with(measurer)) - } - pub(super) fn apply_toolbar_toggle_board_picker_with( &mut self, measurer: &TextMeasurer, @@ -59,19 +39,11 @@ impl InputState { true } - pub(super) fn apply_toolbar_board_duplicate(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_board_duplicate_with(measurer)) - } - pub(super) fn apply_toolbar_board_duplicate_with(&mut self, measurer: &TextMeasurer) -> bool { self.duplicate_board_with_measurer(measurer); true } - pub(super) fn apply_toolbar_board_rename(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_board_rename_with(measurer)) - } - pub(super) fn apply_toolbar_board_rename_with(&mut self, measurer: &TextMeasurer) -> bool { // Open board picker in rename mode for active board self.toggle_board_picker_quick_with(measurer); diff --git a/src/input/state/core/toolbar/apply/layout.rs b/src/input/state/core/toolbar/apply/layout.rs index 096fc4790..d888c340b 100644 --- a/src/input/state/core/toolbar/apply/layout.rs +++ b/src/input/state/core/toolbar/apply/layout.rs @@ -62,15 +62,6 @@ impl InputState { } /// Set the top strip's display form (micro chip click → `Full`). - pub(super) fn apply_toolbar_set_top_display_mode( - &mut self, - mode: crate::config::TopDisplayMode, - ) -> bool { - crate::ui_text::with_legacy_engine(|engine| { - self.apply_toolbar_set_top_display_mode_with_engine(engine, mode) - }) - } - pub(super) fn apply_toolbar_set_top_display_mode_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, @@ -214,12 +205,6 @@ impl InputState { } } - pub(super) fn apply_toolbar_toggle_status_bar(&mut self, show: bool) -> bool { - crate::ui_text::with_legacy_engine(|engine| { - self.apply_toolbar_toggle_status_bar_with_engine(engine, show) - }) - } - pub(super) fn apply_toolbar_toggle_status_bar_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, @@ -251,16 +236,6 @@ impl InputState { true } - pub(super) fn apply_toolbar_set_status_bar_item_visible( - &mut self, - item: crate::config::StatusBarItem, - visible: bool, - ) -> bool { - crate::ui_text::with_legacy_engine(|engine| { - self.apply_toolbar_set_status_bar_item_visible_with_engine(engine, item, visible) - }) - } - pub(super) fn apply_toolbar_set_status_bar_item_visible_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, @@ -270,12 +245,6 @@ impl InputState { self.set_status_bar_item_visible_with_engine(engine, item, visible) } - pub(super) fn apply_toolbar_toggle_status_board_badge(&mut self, show: bool) -> bool { - crate::ui_text::with_legacy_engine(|engine| { - self.apply_toolbar_toggle_status_board_badge_with_engine(engine, show) - }) - } - pub(super) fn apply_toolbar_toggle_status_board_badge_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, @@ -288,12 +257,6 @@ impl InputState { ) } - pub(super) fn apply_toolbar_toggle_status_page_badge(&mut self, show: bool) -> bool { - crate::ui_text::with_legacy_engine(|engine| { - self.apply_toolbar_toggle_status_page_badge_with_engine(engine, show) - }) - } - pub(super) fn apply_toolbar_toggle_status_page_badge_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 0f1b1faf0..9f61fd0dc 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -14,6 +14,16 @@ impl InputState { /// /// Returns true if the event resulted in a state change. pub fn apply_toolbar_event(&mut self, event: ToolbarEvent) -> bool { + crate::input::state::with_legacy_text_resources(|resources| { + self.apply_toolbar_event_with_resources(resources, event) + }) + } + + pub(crate) fn apply_toolbar_event_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + event: ToolbarEvent, + ) -> bool { // Resolve the keyboard-action equivalent before the event is consumed // so the shortcut coach can learn from toolbar use (the slow path the // palette also feeds). @@ -27,7 +37,7 @@ impl InputState { // can delete the arrow outright — after which the release finds no // shape and drops the bend without a trace. self.finish_active_arrow_bend(); - let changed = self.apply_toolbar_event_inner(event); + let changed = self.apply_toolbar_event_inner_with_resources(resources, event); self.note_toolbar_shortcut_slow_path(coach_action, changed); changed } @@ -53,13 +63,27 @@ impl InputState { } } - fn apply_toolbar_event_inner(&mut self, event: ToolbarEvent) -> bool { + fn apply_toolbar_event_inner_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + event: ToolbarEvent, + ) -> bool { match event { - ToolbarEvent::SelectTool(tool) => self.apply_toolbar_select_tool(tool), - ToolbarEvent::SetColor(color) => self.apply_toolbar_set_color(color), - ToolbarEvent::SetQuickColor { color, .. } => self.apply_toolbar_set_color(color), - ToolbarEvent::EditQuickColor { index } => self.apply_toolbar_edit_quick_color(index), - ToolbarEvent::SetThickness(value) => self.apply_toolbar_set_thickness(value), + ToolbarEvent::SelectTool(tool) => { + self.apply_toolbar_select_tool_with(resources.measurer, tool) + } + ToolbarEvent::SetColor(color) => { + self.apply_toolbar_set_color_with_measurer(resources.measurer, color) + } + ToolbarEvent::SetQuickColor { color, .. } => { + self.apply_toolbar_set_color_with_measurer(resources.measurer, color) + } + ToolbarEvent::EditQuickColor { index } => { + self.apply_toolbar_edit_quick_color_with(resources.measurer, index) + } + ToolbarEvent::SetThickness(value) => { + self.apply_toolbar_set_thickness_with(resources.measurer, value) + } ToolbarEvent::SetMarkerOpacity(value) => self.apply_toolbar_set_marker_opacity(value), ToolbarEvent::SetSpotlightMagnification(value) => { self.apply_toolbar_set_spotlight_magnification(value) @@ -68,7 +92,9 @@ impl InputState { ToolbarEvent::OpenFontPicker => self.apply_toolbar_open_font_picker(), ToolbarEvent::SetEraserMode(mode) => self.apply_toolbar_set_eraser_mode(mode), ToolbarEvent::SetFont(descriptor) => self.apply_toolbar_set_font(descriptor), - ToolbarEvent::SetFontBold(bold) => self.apply_toolbar_set_font_bold(bold), + ToolbarEvent::SetFontBold(bold) => { + self.apply_toolbar_set_font_bold_with(resources.measurer, bold) + } ToolbarEvent::SetFontSize(size) => self.apply_toolbar_set_font_size(size), ToolbarEvent::NudgeFontSize(delta) => { self.apply_toolbar_set_font_size(self.style.current_font_size + delta) @@ -96,37 +122,55 @@ impl InputState { ToolbarEvent::SetCustomRedoSteps(steps) => { self.apply_toolbar_set_custom_redo_steps(steps) } - ToolbarEvent::NudgeThickness(delta) => self.apply_toolbar_nudge_thickness(delta), + ToolbarEvent::NudgeThickness(delta) => { + self.apply_toolbar_nudge_thickness_with(resources.measurer, delta) + } ToolbarEvent::NudgeMarkerOpacity(delta) => { self.apply_toolbar_nudge_marker_opacity(delta) } - ToolbarEvent::Undo => self.apply_toolbar_undo(), - ToolbarEvent::Redo => self.apply_toolbar_redo(), - ToolbarEvent::UndoAll => self.apply_toolbar_undo_all(), - ToolbarEvent::RedoAll => self.apply_toolbar_redo_all(), + ToolbarEvent::Undo => self.apply_toolbar_undo_with_resources(resources), + ToolbarEvent::Redo => self.apply_toolbar_redo_with_resources(resources), + ToolbarEvent::UndoAll => self.apply_toolbar_undo_all_with_measurer(resources.measurer), + ToolbarEvent::RedoAll => self.apply_toolbar_redo_all_with_measurer(resources.measurer), ToolbarEvent::UndoAllDelayed => self.apply_toolbar_undo_all_delayed(), ToolbarEvent::RedoAllDelayed => self.apply_toolbar_redo_all_delayed(), ToolbarEvent::CustomUndo => self.apply_toolbar_custom_undo(), ToolbarEvent::CustomRedo => self.apply_toolbar_custom_redo(), - ToolbarEvent::ClearCanvas { instant } => self.apply_toolbar_clear_canvas(instant), - ToolbarEvent::CaptureScreenshot => self.apply_toolbar_capture_screenshot(), - ToolbarEvent::CopyTextFromScreen => self.apply_toolbar_copy_text_from_screen(), - ToolbarEvent::PagePrev => self.apply_toolbar_page_prev(), - ToolbarEvent::PageNext => self.apply_toolbar_page_next(), - ToolbarEvent::PageNew => self.apply_toolbar_page_new(), - ToolbarEvent::PageDuplicate => self.apply_toolbar_page_duplicate(), - ToolbarEvent::PageDelete => self.apply_toolbar_page_delete(), - ToolbarEvent::BoardPrev => self.apply_toolbar_board_prev(), - ToolbarEvent::BoardNext => self.apply_toolbar_board_next(), - ToolbarEvent::BoardNew => self.apply_toolbar_board_new(), - ToolbarEvent::BoardDelete => self.apply_toolbar_board_delete(), - ToolbarEvent::BoardDuplicate => self.apply_toolbar_board_duplicate(), - ToolbarEvent::BoardRename => self.apply_toolbar_board_rename(), - ToolbarEvent::ToggleBoardPicker => self.apply_toolbar_toggle_board_picker(), - ToolbarEvent::EnterTextMode => self.apply_toolbar_enter_text_mode(), - ToolbarEvent::EnterStickyNoteMode => self.apply_toolbar_enter_sticky_note_mode(), + ToolbarEvent::ClearCanvas { instant } => { + self.apply_toolbar_clear_canvas_with_resources(resources, instant) + } + ToolbarEvent::CaptureScreenshot => { + self.apply_toolbar_capture_screenshot_with_resources(resources) + } + ToolbarEvent::CopyTextFromScreen => { + self.apply_toolbar_copy_text_from_screen_with_resources(resources) + } + ToolbarEvent::PagePrev => self.apply_toolbar_page_prev_with(resources.measurer), + ToolbarEvent::PageNext => self.apply_toolbar_page_next_with(resources.measurer), + ToolbarEvent::PageNew => self.apply_toolbar_page_new_with(resources.measurer), + ToolbarEvent::PageDuplicate => { + self.apply_toolbar_page_duplicate_with(resources.measurer) + } + ToolbarEvent::PageDelete => self.apply_toolbar_page_delete_with(resources.measurer), + ToolbarEvent::BoardPrev => self.apply_toolbar_board_prev_with(resources.measurer), + ToolbarEvent::BoardNext => self.apply_toolbar_board_next_with(resources.measurer), + ToolbarEvent::BoardNew => self.apply_toolbar_board_new_with(resources.measurer), + ToolbarEvent::BoardDelete => self.apply_toolbar_board_delete_with(resources.measurer), + ToolbarEvent::BoardDuplicate => { + self.apply_toolbar_board_duplicate_with(resources.measurer) + } + ToolbarEvent::BoardRename => self.apply_toolbar_board_rename_with(resources.measurer), + ToolbarEvent::ToggleBoardPicker => { + self.apply_toolbar_toggle_board_picker_with(resources.measurer) + } + ToolbarEvent::EnterTextMode => { + self.apply_toolbar_enter_text_mode_with_resources(resources) + } + ToolbarEvent::EnterStickyNoteMode => { + self.apply_toolbar_enter_sticky_note_mode_with_resources(resources) + } ToolbarEvent::ToggleAllHighlight(enable) => { - self.apply_toolbar_toggle_all_highlight(enable) + self.apply_toolbar_toggle_all_highlight_with(resources.measurer, enable) } ToolbarEvent::ToggleHighlightToolRing(enable) => { self.apply_toolbar_toggle_highlight_tool_ring(enable) @@ -154,7 +198,9 @@ impl InputState { | ToolbarEvent::ConfirmPreserveInvalidRuntimeUiReset | ToolbarEvent::CancelPreserveInvalidRuntimeUiReset | ToolbarEvent::CancelRuntimeUiRecovery => false, - ToolbarEvent::OpenCommandPalette => self.apply_toolbar_open_command_palette(), + ToolbarEvent::OpenCommandPalette => { + self.apply_toolbar_open_command_palette_with_resources(resources) + } ToolbarEvent::ToggleTopOverflow(open) => self.apply_toolbar_toggle_top_overflow(open), ToolbarEvent::ToggleSessionPopover(open) => { self.apply_toolbar_toggle_session_popover(open) @@ -169,7 +215,9 @@ impl InputState { ToolbarEvent::SetTopMinimized(minimized) => { self.apply_toolbar_set_top_minimized(minimized) } - ToolbarEvent::SetTopDisplayMode(mode) => self.apply_toolbar_set_top_display_mode(mode), + ToolbarEvent::SetTopDisplayMode(mode) => { + self.apply_toolbar_set_top_display_mode_with_engine(resources.ui_engine, mode) + } ToolbarEvent::CloseTopToolbar => self.apply_toolbar_set_top_minimized(true), ToolbarEvent::PinTopToolbar(pin) => self.apply_toolbar_pin_top_toolbar(pin), ToolbarEvent::ToggleIconMode(use_icons) => { @@ -178,16 +226,20 @@ impl InputState { ToolbarEvent::ToggleMoreColors(show) => self.apply_toolbar_toggle_more_colors(show), ToolbarEvent::CopyHexColor => self.apply_toolbar_copy_hex_color(), ToolbarEvent::PasteHexColor => self.apply_toolbar_paste_hex_color(), - ToolbarEvent::EditHexColor => self.apply_toolbar_edit_hex_color(), - ToolbarEvent::OpenColorPickerPopup => self.apply_toolbar_open_color_picker_popup(), + ToolbarEvent::EditHexColor => { + self.apply_toolbar_edit_hex_color_with(resources.measurer) + } + ToolbarEvent::OpenColorPickerPopup => { + self.apply_toolbar_open_color_picker_popup_with(resources.measurer) + } ToolbarEvent::AdjustSelectionProperty { kind, direction } => { - self.adjust_selection_property_kind(kind, direction) + self.adjust_selection_property_kind_with(resources.measurer, kind, direction) } ToolbarEvent::OpenPrecisionEntry(target) => { self.apply_toolbar_open_precision_entry(target) } ToolbarEvent::CommitPrecisionEntry { target, value } => { - self.apply_toolbar_commit_precision_entry(target, value) + self.apply_toolbar_commit_precision_entry_with(resources.measurer, target, value) } ToolbarEvent::CancelPrecisionEntry => self.cancel_precision_entry(), ToolbarEvent::PickScreenColor => { @@ -214,18 +266,23 @@ impl InputState { ToolbarEvent::TogglePresetToasts(show) => self.apply_toolbar_toggle_preset_toasts(show), ToolbarEvent::ToggleIdleFade(enable) => self.apply_toolbar_toggle_idle_fade(enable), ToolbarEvent::ToggleToolPreview(show) => self.apply_toolbar_toggle_tool_preview(show), - ToolbarEvent::ToggleStatusBar(show) => self.apply_toolbar_toggle_status_bar(show), + ToolbarEvent::ToggleStatusBar(show) => { + self.apply_toolbar_toggle_status_bar_with_engine(resources.ui_engine, show) + } ToolbarEvent::SetStatusBarInteractive(interactive) => { self.apply_toolbar_set_status_bar_interactive(interactive) } - ToolbarEvent::SetStatusBarItemVisible(item, visible) => { - self.apply_toolbar_set_status_bar_item_visible(item, visible) - } + ToolbarEvent::SetStatusBarItemVisible(item, visible) => self + .apply_toolbar_set_status_bar_item_visible_with_engine( + resources.ui_engine, + item, + visible, + ), ToolbarEvent::ToggleStatusBoardBadge(show) => { - self.apply_toolbar_toggle_status_board_badge(show) + self.apply_toolbar_toggle_status_board_badge_with_engine(resources.ui_engine, show) } ToolbarEvent::ToggleStatusPageBadge(show) => { - self.apply_toolbar_toggle_status_page_badge(show) + self.apply_toolbar_toggle_status_page_badge_with_engine(resources.ui_engine, show) } ToolbarEvent::ToggleFloatingBadgeAlways(show) => { self.apply_toolbar_toggle_floating_badge_always(show) @@ -260,7 +317,9 @@ impl InputState { self.apply_toolbar_set_status_bar_contents_open(open) } ToolbarEvent::ToggleShapePicker(open) => self.apply_toolbar_toggle_shape_picker(open), - ToolbarEvent::ApplyPreset(slot) => self.apply_toolbar_apply_preset(slot), + ToolbarEvent::ApplyPreset(slot) => { + self.apply_toolbar_apply_preset_with(resources.measurer, slot) + } ToolbarEvent::SavePreset(slot) => self.apply_toolbar_save_preset(slot), ToolbarEvent::ClearPreset(slot) => self.apply_toolbar_clear_preset(slot), ToolbarEvent::OpenSession diff --git a/src/input/state/core/toolbar/apply/pages.rs b/src/input/state/core/toolbar/apply/pages.rs index 043feb10f..4b00dd8cf 100644 --- a/src/input/state/core/toolbar/apply/pages.rs +++ b/src/input/state/core/toolbar/apply/pages.rs @@ -1,13 +1,9 @@ use crate::draw::PageDeleteOutcome; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::TextMeasurer; use crate::input::InputState; use crate::input::state::{Toast, ToastPriority}; impl InputState { - pub(super) fn apply_toolbar_page_prev(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_page_prev_with(measurer)) - } - pub(super) fn apply_toolbar_page_prev_with(&mut self, measurer: &TextMeasurer) -> bool { if self.page_prev_with_measurer(measurer) { true @@ -21,10 +17,6 @@ impl InputState { } } - pub(super) fn apply_toolbar_page_next(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_page_next_with(measurer)) - } - pub(super) fn apply_toolbar_page_next_with(&mut self, measurer: &TextMeasurer) -> bool { if self.page_next_with_measurer(measurer) { true @@ -38,28 +30,16 @@ impl InputState { } } - pub(super) fn apply_toolbar_page_new(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_page_new_with(measurer)) - } - pub(super) fn apply_toolbar_page_new_with(&mut self, measurer: &TextMeasurer) -> bool { self.page_new_with_measurer(measurer); true } - pub(super) fn apply_toolbar_page_duplicate(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_page_duplicate_with(measurer)) - } - pub(super) fn apply_toolbar_page_duplicate_with(&mut self, measurer: &TextMeasurer) -> bool { self.page_duplicate_with_measurer(measurer); true } - pub(super) fn apply_toolbar_page_delete(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_page_delete_with(measurer)) - } - pub(super) fn apply_toolbar_page_delete_with(&mut self, measurer: &TextMeasurer) -> bool { if matches!( self.page_delete_with_measurer(measurer), diff --git a/src/input/state/core/toolbar/apply/tools.rs b/src/input/state/core/toolbar/apply/tools.rs index 4369623a9..44f401c46 100644 --- a/src/input/state/core/toolbar/apply/tools.rs +++ b/src/input/state/core/toolbar/apply/tools.rs @@ -1,5 +1,5 @@ +use crate::draw::TextMeasurer; use crate::draw::{Color, FontDescriptor}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::{DrawingState, EraserMode, InputState, Tool}; use crate::ui::toolbar::PrecisionEntryTarget; @@ -17,16 +17,6 @@ impl InputState { /// Commit a typed precise-entry value, clamped to the target's shared /// slider range, and close the popup if it is still open. - pub(super) fn apply_toolbar_commit_precision_entry( - &mut self, - target: PrecisionEntryTarget, - value: f64, - ) -> bool { - with_legacy_measurer(|measurer| { - self.apply_toolbar_commit_precision_entry_with(measurer, target, value) - }) - } - pub(super) fn apply_toolbar_commit_precision_entry_with( &mut self, measurer: &TextMeasurer, @@ -49,10 +39,6 @@ impl InputState { } } - pub(super) fn apply_toolbar_select_tool(&mut self, tool: Tool) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_select_tool_with(measurer, tool)) - } - pub(super) fn apply_toolbar_select_tool_with( &mut self, measurer: &TextMeasurer, @@ -78,12 +64,12 @@ impl InputState { changed } - pub(super) fn apply_toolbar_set_color(&mut self, color: Color) -> bool { - self.apply_color_from_ui(color) - } - - pub(super) fn apply_toolbar_set_thickness(&mut self, value: f64) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_set_thickness_with(measurer, value)) + pub(super) fn apply_toolbar_set_color_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + color: Color, + ) -> bool { + self.apply_color_from_ui_with_measurer(measurer, color) } pub(super) fn apply_toolbar_set_thickness_with( @@ -123,10 +109,6 @@ impl InputState { self.set_font_descriptor(descriptor) } - pub(super) fn apply_toolbar_set_font_bold(&mut self, bold: bool) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_set_font_bold_with(measurer, bold)) - } - pub(super) fn apply_toolbar_set_font_bold_with( &mut self, measurer: &TextMeasurer, @@ -170,10 +152,6 @@ impl InputState { self.reset_step_marker_counter() } - pub(super) fn apply_toolbar_nudge_thickness(&mut self, delta: f64) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_nudge_thickness_with(measurer, delta)) - } - pub(super) fn apply_toolbar_nudge_thickness_with( &mut self, measurer: &TextMeasurer, @@ -186,26 +164,26 @@ impl InputState { self.set_marker_opacity(self.style.marker_opacity + delta) } - pub(super) fn apply_toolbar_enter_text_mode(&mut self) -> bool { - let _ = self.set_tool_override(None); - self.toolbar_enter_text_mode(); + pub(super) fn apply_toolbar_enter_text_mode_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) -> bool { + let _ = self.set_tool_override_with(resources.measurer, None); + self.toolbar_enter_text_mode_with_resources(resources); self.close_top_toolbar_menus(); true } - pub(super) fn apply_toolbar_enter_sticky_note_mode(&mut self) -> bool { - let _ = self.set_tool_override(None); - self.toolbar_enter_sticky_note_mode(); + pub(super) fn apply_toolbar_enter_sticky_note_mode_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) -> bool { + let _ = self.set_tool_override_with(resources.measurer, None); + self.toolbar_enter_sticky_note_mode_with_resources(resources); self.close_top_toolbar_menus(); true } - pub(super) fn apply_toolbar_toggle_all_highlight(&mut self, enable: bool) -> bool { - with_legacy_measurer(|measurer| { - self.apply_toolbar_toggle_all_highlight_with(measurer, enable) - }) - } - pub(super) fn apply_toolbar_toggle_all_highlight_with( &mut self, measurer: &TextMeasurer, @@ -235,10 +213,6 @@ impl InputState { self.set_input_hud_enabled(enable) } - pub(super) fn apply_toolbar_apply_preset(&mut self, slot: usize) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_apply_preset_with(measurer, slot)) - } - pub(super) fn apply_toolbar_apply_preset_with( &mut self, measurer: &TextMeasurer, @@ -265,10 +239,6 @@ impl InputState { true } - pub(super) fn apply_toolbar_open_color_picker_popup(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_open_color_picker_popup_with(measurer)) - } - pub(super) fn apply_toolbar_open_color_picker_popup_with( &mut self, measurer: &TextMeasurer, @@ -280,10 +250,6 @@ impl InputState { /// Open the color picker popup bound to a quick-color slot, so accepting /// it recolors that swatch. An index past the palette is a stale snapshot /// (the palette shrank between render and click) and opens nothing. - pub(super) fn apply_toolbar_edit_quick_color(&mut self, index: usize) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_edit_quick_color_with(measurer, index)) - } - pub(super) fn apply_toolbar_edit_quick_color_with( &mut self, measurer: &TextMeasurer, @@ -294,10 +260,6 @@ impl InputState { /// Open the color picker popup ready for typing: the hex field is /// focused and its content selected, so the first keystroke replaces it. - pub(super) fn apply_toolbar_edit_hex_color(&mut self) -> bool { - with_legacy_measurer(|measurer| self.apply_toolbar_edit_hex_color_with(measurer)) - } - pub(super) fn apply_toolbar_edit_hex_color_with(&mut self, measurer: &TextMeasurer) -> bool { self.open_color_picker_popup_with_measurer(measurer); self.color_picker_popup_set_hex_editing(true); diff --git a/src/input/state/core/utility/focus_mode.rs b/src/input/state/core/utility/focus_mode.rs index 519d07c51..94e7b89be 100644 --- a/src/input/state/core/utility/focus_mode.rs +++ b/src/input/state/core/utility/focus_mode.rs @@ -15,7 +15,7 @@ use super::super::base::InputState; use super::super::modes::FocusModeRestore; use crate::domain::Action; -use crate::input::state::{InputTextResources, with_legacy_text_resources}; +use crate::input::state::InputTextResources; use crate::input::state::{Toast, ToastPriority}; const FOCUS_MODE_TOAST_KEY: &str = "focus.mode"; @@ -95,10 +95,6 @@ impl InputState { /// - chrome visible → snapshot and hide everything; /// - nothing visible and no snapshot → show everything (rescue arm, so /// the action always has a visible effect). - pub(crate) fn toggle_focus_mode(&mut self) { - with_legacy_text_resources(|resources| self.toggle_focus_mode_with_resources(resources)) - } - pub(crate) fn toggle_focus_mode_with_resources(&mut self, resources: InputTextResources<'_>) { if self.light_mode_active() { self.exit_light_mode_with(resources.measurer); diff --git a/src/input/state/core/utility/light_mode.rs b/src/input/state/core/utility/light_mode.rs index 41d618eb4..7a95b352f 100644 --- a/src/input/state/core/utility/light_mode.rs +++ b/src/input/state/core/utility/light_mode.rs @@ -54,10 +54,6 @@ impl InputState { .unwrap_or_else(|| self.active_tool()) } - pub(crate) fn toggle_light_mode(&mut self) -> bool { - with_legacy_text_resources(|resources| self.toggle_light_mode_with_resources(resources)) - } - pub(crate) fn toggle_light_mode_with_engine( &mut self, engine: &crate::ui_text::UiTextEngine, diff --git a/src/input/state/core/utility/presenter_mode.rs b/src/input/state/core/utility/presenter_mode.rs index 74fa00637..2b855655d 100644 --- a/src/input/state/core/utility/presenter_mode.rs +++ b/src/input/state/core/utility/presenter_mode.rs @@ -10,7 +10,7 @@ use super::super::base::InputState; use super::super::modes::PresenterRestore; use crate::domain::Action; -use crate::input::state::{InputTextResources, with_legacy_text_resources}; +use crate::input::state::InputTextResources; use crate::input::state::{Toast, ToastPriority}; use crate::input::tool::Tool; @@ -65,10 +65,6 @@ impl InputState { self.modes.override_presenter_for_test(active); } - pub(crate) fn toggle_presenter_mode(&mut self) -> bool { - with_legacy_text_resources(|resources| self.toggle_presenter_mode_with_resources(resources)) - } - pub(crate) fn toggle_presenter_mode_with_resources( &mut self, resources: InputTextResources<'_>, diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index 47c6201cd..4a68f65dc 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -182,7 +182,11 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { /// straight from `keyboard.rs` — so this is where an action-wide preflight /// belongs. Hanging one off `handle_action` alone leaves the ordinary key /// press, which is most of them, going around it. -pub(crate) fn route_action(state: &mut InputState, action: Action) -> RoutingOutcome { +pub(crate) fn route_action_with_resources( + state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, + action: Action, +) -> RoutingOutcome { // A wheel burst owns a snapshot from the frame where it started. Every // action route closes it before dispatch because page and board actions can // replace that frame, whose shape ids may alias the old one. Bound keys @@ -212,6 +216,6 @@ pub(crate) fn route_action(state: &mut InputState, action: Action) -> RoutingOut } let route = classify_action(action); - adapters::dispatch_action(state, action, route); + adapters::dispatch_action(state, resources, action, route); RoutingOutcome::DispatchedAction(route) } diff --git a/src/input/state/interaction/adapters/actions.rs b/src/input/state/interaction/adapters/actions.rs index f87c06ed1..d5ac4aee2 100644 --- a/src/input/state/interaction/adapters/actions.rs +++ b/src/input/state/interaction/adapters/actions.rs @@ -6,34 +6,39 @@ pub(crate) fn close_properties_panel_before_action(state: &mut InputState) { state.close_properties_panel(); } -pub(crate) fn dispatch_action(state: &mut InputState, action: Action, route: ActionRoute) { +pub(crate) fn dispatch_action( + state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, + action: Action, + route: ActionRoute, +) { match route { ActionRoute::Core => { - state.handle_core_action(action); + state.handle_core_action_with_measurer(resources.measurer, action); } ActionRoute::History => { - state.handle_history_action(action); + state.handle_history_action_with_measurer(resources.measurer, action); } ActionRoute::Selection => { - state.handle_selection_action(action); + state.handle_selection_action_with_measurer(resources.measurer, action); } ActionRoute::Tool => { - state.handle_tool_action(action); + state.handle_tool_action_with_measurer(resources.measurer, action); } ActionRoute::BoardPages => { - state.handle_board_pages_action(action); + state.handle_board_pages_action_with_measurer(resources.measurer, action); } ActionRoute::Ui => { - state.handle_ui_action(action); + state.handle_ui_action_with_resources(resources, action); } ActionRoute::Color => { - state.handle_color_action(action); + state.handle_color_action_with_measurer(resources.measurer, action); } ActionRoute::CaptureZoom => { state.handle_capture_zoom_action(action); } ActionRoute::Preset => { - state.handle_preset_action(action); + state.handle_preset_action_with_measurer(resources.measurer, action); } } } diff --git a/src/input/state/interaction/adapters/keyboard.rs b/src/input/state/interaction/adapters/keyboard.rs index 639c08ab8..8d98d5022 100644 --- a/src/input/state/interaction/adapters/keyboard.rs +++ b/src/input/state/interaction/adapters/keyboard.rs @@ -17,10 +17,12 @@ pub(crate) fn handle_tour_key(state: &mut InputState, key: Key) -> Option, key: Key, ) -> Option { - (state.command_palette_is_engaged() && state.handle_command_palette_key(key)) - .then_some(RoutingOutcome::Consumed(ConsumedBy::CommandPalette)) + (state.command_palette_is_engaged() + && state.handle_command_palette_key_with_resources(resources, key)) + .then_some(RoutingOutcome::Consumed(ConsumedBy::CommandPalette)) } pub(crate) fn handle_help_overlay_key(state: &mut InputState, key: Key) -> Option { @@ -60,10 +62,12 @@ pub(crate) fn handle_radial_menu_key(state: &mut InputState, key: Key) -> Option pub(crate) fn handle_precision_entry_key( state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, key: Key, ) -> Option { - (state.is_precision_entry_open() && state.handle_precision_entry_key(key)) - .then_some(RoutingOutcome::Consumed(ConsumedBy::PrecisionEntry)) + (state.is_precision_entry_open() + && state.handle_precision_entry_key_with_resources(resources, key)) + .then_some(RoutingOutcome::Consumed(ConsumedBy::PrecisionEntry)) } pub(crate) fn handle_color_picker_key(state: &mut InputState, key: Key) -> Option { @@ -75,20 +79,29 @@ pub(crate) fn handle_color_picker_key(state: &mut InputState, key: Key) -> Optio /// produced rather than only the key itself. pub(crate) fn handle_font_picker_key( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, key: Key, text: Option<&str>, ) -> Option { - (state.is_font_picker_open() && state.handle_font_picker_key(key, text)) + (state.is_font_picker_open() && state.handle_font_picker_key_with_measurer(measurer, key, text)) .then_some(RoutingOutcome::Consumed(ConsumedBy::FontPicker)) } -pub(crate) fn handle_context_menu_key(state: &mut InputState, key: Key) -> Option { - (state.is_context_menu_open() && state.handle_context_menu_key(key)) +pub(crate) fn handle_context_menu_key( + state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, + key: Key, +) -> Option { + (state.is_context_menu_open() && state.handle_context_menu_key_with_resources(resources, key)) .then_some(RoutingOutcome::Consumed(ConsumedBy::ContextMenu)) } -pub(crate) fn handle_board_picker_key(state: &mut InputState, key: Key) -> Option { - (state.is_board_picker_open() && state.handle_board_picker_key(key)) +pub(crate) fn handle_board_picker_key( + state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + key: Key, +) -> Option { + (state.is_board_picker_open() && state.handle_board_picker_key_with_measurer(measurer, key)) .then_some(RoutingOutcome::Consumed(ConsumedBy::BoardPicker)) } @@ -103,13 +116,14 @@ pub(crate) fn handle_global_modifier_key( pub(crate) fn handle_properties_panel_key( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, key: Key, ) -> Option { if !state.is_properties_panel_open() { return None; } - let _ = state.handle_properties_panel_key(key); + let _ = state.handle_properties_panel_key_with_measurer(measurer, key); Some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)) } @@ -146,13 +160,14 @@ pub(crate) fn handle_pending_delete_cancel_key( pub(crate) fn handle_idle_selection_cancel_key( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, key: Key, ) -> Option { if matches!(key, Key::Escape) && matches!(state.state, DrawingState::Idle) && state.has_selection() { - let bounds = state.selection_bounding_box(state.selected_shape_ids()); + let bounds = state.selection_bounding_box_with(measurer, state.selected_shape_ids()); state.clear_selection(); state.mark_selection_dirty_region(bounds); state.needs_redraw = true; @@ -162,9 +177,13 @@ pub(crate) fn handle_idle_selection_cancel_key( None } -pub(crate) fn handle_text_input_key(state: &mut InputState, key: Key) -> Option { +pub(crate) fn handle_text_input_key( + state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, + key: Key, +) -> Option { if matches!(&state.state, DrawingState::TextInput { .. }) { - state.handle_text_input_key(key); + state.handle_text_input_key_with_resources(resources, key); return Some(RoutingOutcome::Consumed(ConsumedBy::TextInput)); } @@ -173,6 +192,7 @@ pub(crate) fn handle_text_input_key(state: &mut InputState, key: Key) -> Option< pub(crate) fn handle_building_polygon_key( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, key: Key, ) -> Option { if !matches!(state.state, DrawingState::BuildingPolygon { .. }) { @@ -181,19 +201,19 @@ pub(crate) fn handle_building_polygon_key( match key { Key::Return => { - state.finish_building_polygon(); + state.finish_building_polygon_with_measurer(measurer); Some(RoutingOutcome::Finished( ActiveInteractionKind::BuildingPolygon, )) } Key::Escape => { - state.cancel_active_interaction(); + state.cancel_active_interaction_with(measurer); Some(RoutingOutcome::Canceled(CancelTarget::ActiveInteraction( ActiveInteractionKind::BuildingPolygon, ))) } Key::Backspace => { - state.pop_building_polygon_point(); + state.pop_building_polygon_point_with_measurer(measurer); Some(RoutingOutcome::Continued( ActiveInteractionKind::BuildingPolygon, )) @@ -204,13 +224,14 @@ pub(crate) fn handle_building_polygon_key( pub(crate) fn handle_drawing_escape_cancel_key( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, key: Key, ) -> Option { if matches!(key, Key::Escape) && let Some(ActiveInteractionKind::Drawing) = active_interaction_kind(state) && let Some(Action::Exit) = state.find_action("Escape") { - state.try_cancel_active_interaction(); + state.try_cancel_active_interaction_with(measurer); return Some(RoutingOutcome::Canceled(CancelTarget::ActiveInteraction( ActiveInteractionKind::Drawing, ))); @@ -242,13 +263,14 @@ pub(crate) fn action_for_key_binding( pub(crate) fn handle_return_edit_selected_text_key( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, key: Key, ) -> Option { if matches!(key, Key::Return) && !state.modifiers.has_shortcut_modifier() && matches!(state.state, DrawingState::Idle) { - if state.edit_selected_text() { + if state.edit_selected_text_with(measurer) { return Some(RoutingOutcome::Started(ActiveInteractionKind::TextInput)); } return Some(return_edit_miss_side_effect()); diff --git a/src/input/state/interaction/keyboard.rs b/src/input/state/interaction/keyboard.rs index 633076b96..e4584e5b5 100644 --- a/src/input/state/interaction/keyboard.rs +++ b/src/input/state/interaction/keyboard.rs @@ -1,4 +1,4 @@ -use super::actions::route_action; +use super::actions::route_action_with_resources; use super::adapters; use super::outcome::{ConsumedBy, NoRouteReason, RoutingOutcome}; use crate::input::events::Key; @@ -10,15 +10,28 @@ use std::time::Instant; use super::super::core::SequenceMatch; -pub(crate) fn route_key_press(state: &mut InputState, key: Key) -> RoutingOutcome { - route_key_event(state, key, false) +pub(crate) fn route_key_press_with_resources( + state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, + key: Key, +) -> RoutingOutcome { + route_key_event(state, resources, key, false) } -pub(crate) fn route_key_repeat(state: &mut InputState, key: Key) -> RoutingOutcome { - route_key_event(state, key, true) +pub(crate) fn route_key_repeat_with_resources( + state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, + key: Key, +) -> RoutingOutcome { + route_key_event(state, resources, key, true) } -fn route_key_event(state: &mut InputState, key: Key, is_repeat: bool) -> RoutingOutcome { +fn route_key_event( + state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, + key: Key, + is_repeat: bool, +) -> RoutingOutcome { if state.engaged_modal().is_some() || matches!(state.state, DrawingState::TextInput { .. }) || state.screen_modal_is_engaged() @@ -29,7 +42,7 @@ fn route_key_event(state: &mut InputState, key: Key, is_repeat: bool) -> Routing if let Some(outcome) = adapters::handle_tour_key(state, key) { return outcome; } - if let Some(outcome) = adapters::handle_command_palette_key(state, key) { + if let Some(outcome) = adapters::handle_command_palette_key(state, resources, key) { return outcome; } if let Some(outcome) = adapters::handle_help_overlay_key(state, key) { @@ -38,27 +51,30 @@ fn route_key_event(state: &mut InputState, key: Key, is_repeat: bool) -> Routing if let Some(outcome) = adapters::handle_radial_menu_key(state, key) { return outcome; } - if let Some(outcome) = adapters::handle_precision_entry_key(state, key) { + if let Some(outcome) = adapters::handle_precision_entry_key(state, resources, key) { return outcome; } if let Some(outcome) = adapters::handle_color_picker_key(state, key) { return outcome; } - if let Some(outcome) = - adapters::handle_font_picker_key(state, key, font_picker_text(key).as_deref()) - { + if let Some(outcome) = adapters::handle_font_picker_key( + state, + resources.measurer, + key, + font_picker_text(key).as_deref(), + ) { return outcome; } - if let Some(outcome) = adapters::handle_context_menu_key(state, key) { + if let Some(outcome) = adapters::handle_context_menu_key(state, resources, key) { return outcome; } - if let Some(outcome) = adapters::handle_board_picker_key(state, key) { + if let Some(outcome) = adapters::handle_board_picker_key(state, resources.measurer, key) { return outcome; } if let Some(outcome) = adapters::handle_global_modifier_key(state, key) { return outcome; } - if let Some(outcome) = adapters::handle_properties_panel_key(state, key) { + if let Some(outcome) = adapters::handle_properties_panel_key(state, resources.measurer, key) { return outcome; } if let Some(outcome) = adapters::handle_top_popover_dismiss_key(state, key) { @@ -67,21 +83,27 @@ fn route_key_event(state: &mut InputState, key: Key, is_repeat: bool) -> Routing if let Some(outcome) = adapters::handle_pending_delete_cancel_key(state, key) { return outcome; } - if let Some(outcome) = adapters::handle_idle_selection_cancel_key(state, key) { + if let Some(outcome) = + adapters::handle_idle_selection_cancel_key(state, resources.measurer, key) + { return outcome; } - if let Some(outcome) = adapters::handle_text_input_key(state, key) { + if let Some(outcome) = adapters::handle_text_input_key(state, resources, key) { return outcome; } - if let Some(outcome) = adapters::handle_building_polygon_key(state, key) { + if let Some(outcome) = adapters::handle_building_polygon_key(state, resources.measurer, key) { return outcome; } - if let Some(outcome) = adapters::handle_drawing_escape_cancel_key(state, key) { + if let Some(outcome) = + adapters::handle_drawing_escape_cancel_key(state, resources.measurer, key) + { return outcome; } match match_action_for_key_binding(state, key, is_repeat) { - Ok(SequenceMatch::Dispatched(action)) => return route_action(state, action), + Ok(SequenceMatch::Dispatched(action)) => { + return route_action_with_resources(state, resources, action); + } Ok(SequenceMatch::Pending) => { return RoutingOutcome::Consumed(ConsumedBy::SequencePrefix); } @@ -92,7 +114,9 @@ fn route_key_event(state: &mut InputState, key: Key, is_repeat: bool) -> Routing Err(reason) => return RoutingOutcome::NoRoute(reason), } - if let Some(outcome) = adapters::handle_return_edit_selected_text_key(state, key) { + if let Some(outcome) = + adapters::handle_return_edit_selected_text_key(state, resources.measurer, key) + { return outcome; } diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index 20ad224d8..89dff4833 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -6,12 +6,12 @@ mod keyboard; mod outcome; mod pointer; -pub(crate) use actions::route_action; +pub(crate) use actions::route_action_with_resources; pub(crate) use adapters::action_for_key_binding; pub(crate) use event::{ CanvasPoint, PointerMotion, PointerPoints, PointerPress, PointerRelease, ScreenPoint, }; -pub(crate) use keyboard::{route_key_press, route_key_repeat}; +pub(crate) use keyboard::{route_key_press_with_resources, route_key_repeat_with_resources}; pub(crate) use pointer::{route_pointer_motion, route_pointer_press, route_pointer_release}; #[cfg(test)] @@ -28,6 +28,19 @@ mod tests { use crate::input::state::{TopMenuState, test_support::make_test_input_state}; use crate::input::{BOARD_ID_BLACKBOARD, EraserMode, Key, MouseButton, Tool}; + fn route_key_press(state: &mut crate::input::state::InputState, key: Key) -> RoutingOutcome { + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + route_key_press_with_resources( + state, + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + key, + ) + } + fn points() -> PointerPoints { PointerPoints::new(ScreenPoint::new(10, 20), CanvasPoint::new(10, 20)) } @@ -84,10 +97,11 @@ mod tests { #[test] fn properties_panel_unhandled_key_is_consumed() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); let id = add_rect(&mut state); state.set_selection(vec![id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); assert_eq!( route_key_press(&mut state, Key::Char('x')), diff --git a/src/input/state/mouse/press/polygon.rs b/src/input/state/mouse/press/polygon.rs index b3df50cc2..f598ddf81 100644 --- a/src/input/state/mouse/press/polygon.rs +++ b/src/input/state/mouse/press/polygon.rs @@ -71,7 +71,10 @@ impl InputState { self.needs_redraw = true; } - pub(crate) fn pop_building_polygon_point(&mut self) { + pub(crate) fn pop_building_polygon_point_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) { let DrawingState::BuildingPolygon { points, .. } = &mut self.state else { return; }; @@ -83,12 +86,21 @@ impl InputState { } else { let (x, y) = self.canvas_pointer_position(); self.selection_interaction.clear_polygon_click(); - self.update_provisional_dirty(x, y); + self.update_provisional_dirty_with(measurer, x, y); } self.needs_redraw = true; } pub(crate) fn finish_building_polygon(&mut self) { + crate::draw::with_legacy_measurer(|measurer| { + self.finish_building_polygon_with_measurer(measurer) + }) + } + + pub(crate) fn finish_building_polygon_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) { let state = std::mem::replace(&mut self.state, DrawingState::Idle); let DrawingState::BuildingPolygon { points, @@ -116,7 +128,7 @@ impl InputState { color, thick, }; - let bounds = shape.bounding_box(); + let bounds = shape.bounding_box_with(measurer); let max_shapes = self.max_shapes_per_frame(); let addition = { let frame = self.boards.active_frame_mut(); @@ -135,7 +147,7 @@ impl InputState { }) }; if let Some((new_id, _snapshot)) = addition { - self.invalidate_hit_cache_for(new_id); + self.invalidate_hit_cache_for_with(measurer, new_id); self.dirty_tracker.mark_optional_rect(bounds); self.mark_session_dirty(); self.record_first_stroke_done_for_onboarding(); diff --git a/src/input/state/tests/board_picker.rs b/src/input/state/tests/board_picker.rs index 5594b2063..4f4a38bb2 100644 --- a/src/input/state/tests/board_picker.rs +++ b/src/input/state/tests/board_picker.rs @@ -540,6 +540,7 @@ fn board_picker_sticky_add_works_when_visible_grid_is_full() { #[test] fn board_picker_ctrl_n_adds_page_while_page_panel_focused() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -550,7 +551,7 @@ fn board_picker_ctrl_n_adds_page_while_page_panel_focused() { input.board_picker_set_focus(BoardPickerFocus::PagePanel); input.modifiers.ctrl = true; - assert!(input.handle_board_picker_key(Key::Char('n'))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('n'))); assert_eq!( input.boards.board_states()[board_index].pages.page_count(), @@ -678,6 +679,7 @@ fn board_picker_wheel_scroll_up_clamps_focus_to_last_visible_page() { #[test] fn board_picker_page_search_wheel_scroll_syncs_cursor_with_visible_focus() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -699,9 +701,9 @@ fn board_picker_page_search_wheel_scroll_syncs_cursor_with_visible_focus() { update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - input.handle_board_picker_key(Key::Char('/')); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); for ch in "match".chars() { - input.handle_board_picker_key(Key::Char(ch)); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } assert_eq!(input.board_picker_page_search_cursor(), Some(0)); assert_eq!(input.board_picker_page_search_active_match(), Some(0)); @@ -721,7 +723,7 @@ fn board_picker_page_search_wheel_scroll_syncs_cursor_with_visible_focus() { ); assert_page_visible(*input.board_picker_layout().expect("layout"), second_match); - assert!(input.handle_board_picker_key(Key::Return)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Return)); assert!(!input.is_board_picker_open()); assert_eq!( input.boards.board_states()[board_index] @@ -733,6 +735,7 @@ fn board_picker_page_search_wheel_scroll_syncs_cursor_with_visible_focus() { #[test] fn board_picker_page_search_wheel_scroll_without_visible_match_clears_cursor() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -744,9 +747,9 @@ fn board_picker_page_search_wheel_scroll_without_visible_match_clears_cursor() { update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - input.handle_board_picker_key(Key::Char('/')); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); for ch in "match".chars() { - input.handle_board_picker_key(Key::Char(ch)); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } assert_eq!(input.board_picker_page_search_cursor(), Some(0)); assert_eq!(input.board_picker_page_search_active_match(), Some(0)); @@ -758,10 +761,10 @@ fn board_picker_page_search_wheel_scroll_without_visible_match_clears_cursor() { assert_eq!(input.board_picker_page_search_cursor(), None); assert_eq!(input.board_picker_page_search_active_match(), None); assert_eq!(input.board_picker_page_focus_page_index(), None); - assert!(input.handle_board_picker_key(Key::Return)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Return)); assert!(input.is_board_picker_open()); - assert!(input.handle_board_picker_key(Key::F3)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::F3)); update_picker_layout(&mut input, 1280, 720); assert_eq!(input.board_picker_page_search_cursor(), Some(0)); assert_eq!(input.board_picker_page_search_active_match(), Some(0)); @@ -793,6 +796,7 @@ fn board_picker_column_change_keeps_focused_absolute_page_visible() { #[test] fn board_picker_page_jump_focuses_absolute_page_and_scrolls_visible() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -803,13 +807,13 @@ fn board_picker_page_jump_focuses_absolute_page_and_scrolls_visible() { input.board_picker_set_focus(BoardPickerFocus::PagePanel); input.modifiers.ctrl = true; - assert!(input.handle_board_picker_key(Key::Char('g'))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('g'))); input.modifiers.ctrl = false; for ch in "12".chars() { - assert!(input.handle_board_picker_key(Key::Char(ch))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch))); } assert_eq!(input.board_picker_page_jump_buffer(), Some("12")); - assert!(input.handle_board_picker_key(Key::Return)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Return)); update_picker_layout(&mut input, 1280, 720); assert_eq!( @@ -822,6 +826,7 @@ fn board_picker_page_jump_focuses_absolute_page_and_scrolls_visible() { #[test] fn board_picker_page_jump_edges_keep_picker_open() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -831,12 +836,12 @@ fn board_picker_page_jump_edges_keep_picker_open() { update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); input.modifiers.ctrl = true; - input.handle_board_picker_key(Key::Char('g')); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('g')); input.modifiers.ctrl = false; - assert!(input.handle_board_picker_key(Key::Char('x'))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('x'))); assert_eq!(input.board_picker_page_jump_buffer(), Some("")); - assert!(input.handle_board_picker_key(Key::Return)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Return)); assert!(input.is_board_picker_open()); assert_eq!( input.board_picker_page_nav_mode(), @@ -844,9 +849,9 @@ fn board_picker_page_jump_edges_keep_picker_open() { ); for ch in "99".chars() { - input.handle_board_picker_key(Key::Char(ch)); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } - assert!(input.handle_board_picker_key(Key::Return)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Return)); assert!(input.is_board_picker_open()); assert_eq!( input.board_picker_page_nav_mode(), @@ -857,7 +862,7 @@ fn board_picker_page_jump_edges_keep_picker_open() { Some("Page number out of range.") ); - assert!(input.handle_board_picker_key(Key::Escape)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Escape)); assert!(input.is_board_picker_open()); assert_eq!( input.board_picker_page_nav_mode(), @@ -867,12 +872,13 @@ fn board_picker_page_jump_edges_keep_picker_open() { #[test] fn board_picker_page_search_slash_starts_without_inserting_slash() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - assert!(input.handle_board_picker_key(Key::Char('/'))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/'))); assert_eq!( input.board_picker_page_nav_mode(), @@ -883,14 +889,15 @@ fn board_picker_page_search_slash_starts_without_inserting_slash() { #[test] fn board_picker_selecting_current_board_row_clears_page_nav_mode() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - input.handle_board_picker_key(Key::Char('/')); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); for ch in "target".chars() { - input.handle_board_picker_key(Key::Char(ch)); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } assert_eq!( input.board_picker_page_nav_mode(), @@ -910,6 +917,7 @@ fn board_picker_selecting_current_board_row_clears_page_nav_mode() { #[test] fn board_picker_page_search_finds_named_page_beyond_visible_window() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -921,9 +929,9 @@ fn board_picker_page_search_finds_named_page_beyond_visible_window() { update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - assert!(input.handle_board_picker_key(Key::Char('/'))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/'))); for ch in "cap".chars() { - assert!(input.handle_board_picker_key(Key::Char(ch))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch))); } update_picker_layout(&mut input, 1280, 720); @@ -934,6 +942,7 @@ fn board_picker_page_search_finds_named_page_beyond_visible_window() { #[test] fn board_picker_page_search_numeric_is_exact_page_number() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -945,9 +954,9 @@ fn board_picker_page_search_numeric_is_exact_page_number() { update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - assert!(input.handle_board_picker_key(Key::Char('/'))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/'))); for ch in "12".chars() { - assert!(input.handle_board_picker_key(Key::Char(ch))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch))); } assert_eq!(input.board_picker_page_search_active_match(), Some(11)); @@ -957,6 +966,7 @@ fn board_picker_page_search_numeric_is_exact_page_number() { #[test] fn board_picker_page_search_no_match_enter_is_noop() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -966,12 +976,12 @@ fn board_picker_page_search_no_match_enter_is_noop() { update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - assert!(input.handle_board_picker_key(Key::Char('/'))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/'))); for ch in "missing".chars() { - input.handle_board_picker_key(Key::Char(ch)); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } assert_eq!(input.board_picker_page_search_match_count(), 0); - assert!(input.handle_board_picker_key(Key::Return)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Return)); assert!(input.is_board_picker_open()); assert_eq!( @@ -980,7 +990,7 @@ fn board_picker_page_search_no_match_enter_is_noop() { .active_index(), 0 ); - assert!(input.handle_board_picker_key(Key::Escape)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Escape)); assert!(input.is_board_picker_open()); assert_eq!( input.board_picker_page_nav_mode(), @@ -990,6 +1000,7 @@ fn board_picker_page_search_no_match_enter_is_noop() { #[test] fn board_picker_page_search_f3_cycles_matches() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -1002,16 +1013,16 @@ fn board_picker_page_search_f3_cycles_matches() { update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - input.handle_board_picker_key(Key::Char('/')); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); for ch in "match".chars() { - input.handle_board_picker_key(Key::Char(ch)); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } assert_eq!(input.board_picker_page_search_active_match(), Some(2)); - assert!(input.handle_board_picker_key(Key::F3)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::F3)); assert_eq!(input.board_picker_page_search_active_match(), Some(11)); input.modifiers.shift = true; - assert!(input.handle_board_picker_key(Key::F3)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::F3)); input.modifiers.shift = false; assert_eq!(input.board_picker_page_search_active_match(), Some(2)); @@ -1019,6 +1030,7 @@ fn board_picker_page_search_f3_cycles_matches() { #[test] fn board_picker_page_search_enter_opens_absolute_page_beyond_nine() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -1030,12 +1042,12 @@ fn board_picker_page_search_enter_opens_absolute_page_beyond_nine() { update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - input.handle_board_picker_key(Key::Char('/')); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); for ch in "target".chars() { - input.handle_board_picker_key(Key::Char(ch)); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } assert_eq!(input.board_picker_page_search_active_match(), Some(11)); - assert!(input.handle_board_picker_key(Key::Return)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Return)); assert!(!input.is_board_picker_open()); assert_eq!( @@ -1048,6 +1060,7 @@ fn board_picker_page_search_enter_opens_absolute_page_beyond_nine() { #[test] fn board_picker_page_search_rename_updates_derived_match() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -1057,9 +1070,9 @@ fn board_picker_page_search_rename_updates_derived_match() { update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - input.handle_board_picker_key(Key::Char('/')); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); for ch in "target".chars() { - input.handle_board_picker_key(Key::Char(ch)); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } assert_eq!(input.board_picker_page_search_match_count(), 0); @@ -1076,6 +1089,7 @@ fn board_picker_page_search_rename_updates_derived_match() { #[test] fn board_picker_page_search_pending_delete_preserves_confirmed_delete_clamps() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker(); let board_index = input @@ -1089,11 +1103,11 @@ fn board_picker_page_search_pending_delete_preserves_confirmed_delete_clamps() { update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); - input.handle_board_picker_key(Key::Char('/')); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); for ch in "match".chars() { - input.handle_board_picker_key(Key::Char(ch)); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } - input.handle_board_picker_key(Key::F3); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::F3); assert_eq!(input.board_picker_page_search_cursor(), Some(1)); assert_eq!(input.board_picker_page_search_active_match(), Some(1)); @@ -1211,6 +1225,7 @@ fn board_picker_footer_text_prefers_active_search_query() { #[test] fn board_picker_footer_text_changes_for_quick_and_page_panel_modes() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); input.open_board_picker_quick(); assert_eq!( @@ -1237,15 +1252,15 @@ fn board_picker_footer_text_changes_for_quick_and_page_panel_modes() { ); input.modifiers.ctrl = true; - input.handle_board_picker_key(Key::Char('g')); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('g')); input.modifiers.ctrl = false; assert_eq!( input.board_picker_footer_text(), "Go to page: Enter: go Esc: cancel" ); - input.handle_board_picker_key(Key::Escape); - input.handle_board_picker_key(Key::Char('/')); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Escape); + input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); assert_eq!( input.board_picker_footer_text(), "Search pages: Enter: open F3: next Esc: clear" @@ -1300,6 +1315,7 @@ fn board_picker_rename_selected_promotes_quick_mode_to_full_editing() { #[test] fn board_picker_f2_starts_board_name_edit_not_color_edit() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); let blackboard_index = input .boards @@ -1313,7 +1329,7 @@ fn board_picker_f2_starts_board_name_edit_not_color_edit() { .board_picker_row_for_board(blackboard_index) .expect("blackboard row"); input.board_picker_set_selected(selected_row); - assert!(input.handle_board_picker_key(Key::F2)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::F2)); assert_eq!( input.board_picker_edit_state(), @@ -1346,6 +1362,7 @@ fn board_picker_f2_key_route_starts_board_name_edit_not_color_edit() { #[test] fn board_picker_f2_switches_color_edit_back_to_name_edit() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); let blackboard_index = input .boards @@ -1365,7 +1382,7 @@ fn board_picker_f2_switches_color_edit_back_to_name_edit() { Some((BoardPickerEditMode::Color, selected_row, "#111111")) ); - assert!(input.handle_board_picker_key(Key::F2)); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::F2)); assert_eq!( input.board_picker_edit_state(), @@ -1375,6 +1392,7 @@ fn board_picker_f2_switches_color_edit_back_to_name_edit() { #[test] fn board_picker_ctrl_c_starts_board_color_edit() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); let blackboard_index = input .boards @@ -1389,7 +1407,7 @@ fn board_picker_ctrl_c_starts_board_color_edit() { .expect("blackboard row"); input.board_picker_set_selected(selected_row); input.modifiers.ctrl = true; - assert!(input.handle_board_picker_key(Key::Char('c'))); + assert!(input.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('c'))); assert_eq!( input.board_picker_edit_state(), diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index 2dc1b9b96..c0bd761dc 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -162,6 +162,7 @@ fn freeform_polygon_double_click_finishes_without_duplicate_vertex() { #[test] fn freeform_polygon_backspace_does_not_prime_double_click_commit() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); assert!(state.set_tool_override(Some(Tool::FreeformPolygon))); @@ -169,7 +170,7 @@ fn freeform_polygon_backspace_does_not_prime_double_click_commit() { state.on_mouse_press(MouseButton::Left, 20, 0); state.on_mouse_press(MouseButton::Left, 20, 20); state.on_mouse_press(MouseButton::Left, 0, 20); - state.pop_building_polygon_point(); + state.pop_building_polygon_point_with_measurer(&route_measurer); state.on_mouse_press(MouseButton::Left, 0, 20); assert!(state.boards.active_frame().shapes.is_empty()); diff --git a/src/input/state/tests/focus_mode.rs b/src/input/state/tests/focus_mode.rs index 2f2a54706..97a068484 100644 --- a/src/input/state/tests/focus_mode.rs +++ b/src/input/state/tests/focus_mode.rs @@ -386,8 +386,14 @@ fn visibility_toggles_stay_process_only_across_focus_mode() { #[test] fn presenter_mode_gates_focus_mode() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(state.presenter_mode_active()); state.handle_action(Action::ToggleFocusMode); diff --git a/src/input/state/tests/input_hud.rs b/src/input/state/tests/input_hud.rs index 75891e0c0..e7375a10b 100644 --- a/src/input/state/tests/input_hud.rs +++ b/src/input/state/tests/input_hud.rs @@ -62,11 +62,17 @@ fn toggle_input_hud_defers_the_source_announcement_to_the_backend() { /// swallowed exactly like `ToggleClickHighlight` is. #[test] fn presenter_mode_forces_input_hud_and_gates_the_manual_toggle() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); state.presenter_mode_config_mut_for_test().enable_input_hud = true; assert!(!state.input_hud_enabled()); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(state.presenter_mode_active()); assert!(state.input_hud_enabled()); assert!( @@ -81,7 +87,7 @@ fn presenter_mode_forces_input_hud_and_gates_the_manual_toggle() { "presenter mode must swallow the manual toggle while it forces the HUD on" ); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(!state.presenter_mode_active()); assert!( !state.input_hud_enabled(), @@ -92,13 +98,19 @@ fn presenter_mode_forces_input_hud_and_gates_the_manual_toggle() { /// Presenter mode leaves an already-enabled HUD on after exit. #[test] fn presenter_mode_restores_a_manually_enabled_input_hud() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); state.presenter_mode_config_mut_for_test().enable_input_hud = true; state.handle_action(Action::ToggleInputHud); assert!(state.input_hud_enabled()); - state.toggle_presenter_mode(); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); + state.toggle_presenter_mode_with_resources(route_resources); assert!(state.input_hud_enabled()); } @@ -167,6 +179,12 @@ fn disabling_the_hud_drops_its_chips() { /// presenter gate applies to it too. #[test] fn toolbar_checkbox_toggles_the_input_hud() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); assert!(state.apply_toolbar_event(ToolbarEvent::ToggleInputHud(true))); assert!(state.input_hud_enabled()); @@ -174,7 +192,7 @@ fn toolbar_checkbox_toggles_the_input_hud() { assert!(!state.input_hud_enabled()); state.presenter_mode_config_mut_for_test().enable_input_hud = true; - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(state.input_hud_enabled()); assert!(!state.apply_toolbar_event(ToolbarEvent::ToggleInputHud(false))); assert!(state.input_hud_enabled()); diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index 781c8df78..b557e2b6d 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -603,6 +603,7 @@ fn page_duplicate_from_context_duplicates_target_page_and_closes_menu() { #[test] fn page_delete_from_context_reconciles_board_picker_page_search_cursor() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); @@ -615,11 +616,11 @@ fn page_delete_from_context_reconciles_board_picker_page_search_cursor() { state.open_board_picker(); state.board_picker_set_focus(BoardPickerFocus::PagePanel); - state.handle_board_picker_key(Key::Char('/')); + state.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); for ch in "match".chars() { - state.handle_board_picker_key(Key::Char(ch)); + state.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } - state.handle_board_picker_key(Key::F3); + state.handle_board_picker_key_with_measurer(&route_measurer, Key::F3); assert_eq!( state.board_picker_page_nav_mode(), BoardPickerPageNavMode::Search @@ -646,7 +647,7 @@ fn page_delete_from_context_reconciles_board_picker_page_search_cursor() { assert_eq!(state.board_picker_page_search_cursor(), Some(0)); assert_eq!(state.board_picker_page_search_active_match(), Some(0)); - assert!(state.handle_board_picker_key(Key::Return)); + assert!(state.handle_board_picker_key_with_measurer(&route_measurer, Key::Return)); assert!(!state.is_board_picker_open()); assert_eq!( state.boards.board_states()[blackboard].pages.active_index(), @@ -656,6 +657,7 @@ fn page_delete_from_context_reconciles_board_picker_page_search_cursor() { #[test] fn page_search_active_match_clamps_stale_cursor_after_external_page_delete() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); @@ -668,22 +670,22 @@ fn page_search_active_match_clamps_stale_cursor_after_external_page_delete() { state.open_board_picker(); state.board_picker_set_focus(BoardPickerFocus::PagePanel); - state.handle_board_picker_key(Key::Char('/')); + state.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); for ch in "match".chars() { - state.handle_board_picker_key(Key::Char(ch)); + state.handle_board_picker_key_with_measurer(&route_measurer, Key::Char(ch)); } - state.handle_board_picker_key(Key::F3); + state.handle_board_picker_key_with_measurer(&route_measurer, Key::F3); assert_eq!(state.board_picker_page_search_cursor(), Some(1)); assert_eq!(state.board_picker_page_search_active_match(), Some(1)); - state.delete_page_in_board(blackboard, 0); - state.delete_page_in_board(blackboard, 0); + state.delete_page_in_board_with_measurer(&route_measurer, blackboard, 0); + state.delete_page_in_board_with_measurer(&route_measurer, blackboard, 0); assert_eq!(state.board_picker_page_search_match_count(), 1); assert_eq!(state.board_picker_page_search_cursor(), Some(1)); assert_eq!(state.board_picker_page_search_active_match(), Some(0)); - assert!(state.handle_board_picker_key(Key::Return)); + assert!(state.handle_board_picker_key_with_measurer(&route_measurer, Key::Return)); assert!(!state.is_board_picker_open()); assert_eq!( state.boards.board_states()[blackboard].pages.active_index(), diff --git a/src/input/state/tests/menus/history.rs b/src/input/state/tests/menus/history.rs index f7773bb42..cb08f9110 100644 --- a/src/input/state/tests/menus/history.rs +++ b/src/input/state/tests/menus/history.rs @@ -26,6 +26,7 @@ fn push_rect_create(state: &mut InputState, x: i32) { #[test] fn undo_all_and_redo_all_process_entire_stack() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let frame = state.boards.active_frame_mut(); @@ -68,11 +69,11 @@ fn undo_all_and_redo_all_process_entire_stack() { assert_eq!(state.boards.active_frame().undo_stack_len(), 2); - state.undo_all_immediate(); + state.undo_all_immediate_with_measurer(&route_measurer); assert_eq!(state.boards.active_frame().shapes.len(), 0); assert_eq!(state.boards.active_frame().redo_stack_len(), 2); - state.redo_all_immediate(); + state.redo_all_immediate_with_measurer(&route_measurer); assert_eq!(state.boards.active_frame().shapes.len(), 2); assert_eq!(state.boards.active_frame().undo_stack_len(), 2); } @@ -107,6 +108,7 @@ fn undo_all_with_delay_respects_history() { #[test] fn redo_all_with_delay_replays_history() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let frame = state.boards.active_frame_mut(); @@ -127,7 +129,7 @@ fn redo_all_with_delay_replays_history() { state.history_limits.undo_stack_limit(), ); - state.undo_all_immediate(); + state.undo_all_immediate_with_measurer(&route_measurer); assert_eq!(state.boards.active_frame().redo_stack_len(), 1); state.start_redo_all_delayed(0); @@ -161,10 +163,11 @@ fn custom_undo_uses_step_budget_and_minimum_delay_between_steps() { #[test] fn custom_redo_respects_step_budget() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); push_rect_create(&mut state, 0); push_rect_create(&mut state, 20); - state.undo_all_immediate(); + state.undo_all_immediate_with_measurer(&route_measurer); assert_eq!(state.boards.active_frame().redo_stack_len(), 2); state.start_custom_redo(0, 1); diff --git a/src/input/state/tests/presenter_mode.rs b/src/input/state/tests/presenter_mode.rs index 004021263..4c98631cf 100644 --- a/src/input/state/tests/presenter_mode.rs +++ b/src/input/state/tests/presenter_mode.rs @@ -8,13 +8,19 @@ use crate::ui::toolbar::ToolbarEvent; #[test] fn presenter_mode_forces_click_highlight() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); state .presenter_mode_config_mut_for_test() .enable_click_highlight = true; assert!(!state.click_highlight_enabled()); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(state.presenter_mode_active()); assert!(state.click_highlight_enabled()); @@ -53,6 +59,12 @@ fn presenter_mode_exits_focus_mode_before_taking_chrome_ownership() { #[test] fn presenter_mode_blocks_preset_status_bar_toggle() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); state.presenter_mode_config_mut_for_test().hide_status_bar = true; @@ -77,7 +89,7 @@ fn presenter_mode_blocks_preset_status_bar_toggle() { }; state.preset_slots.presets_mut_for_test()[0] = Some(preset); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(!state.ui_visibility.show_status_bar); assert!(state.apply_preset(1)); @@ -86,10 +98,16 @@ fn presenter_mode_blocks_preset_status_bar_toggle() { #[test] fn presenter_mode_blocks_tool_preview_toggle() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); state.presenter_mode_config_mut_for_test().hide_tool_preview = true; - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(!state.ui_visibility.show_tool_preview); assert!(!state.apply_toolbar_event(ToolbarEvent::ToggleToolPreview(true))); @@ -98,11 +116,17 @@ fn presenter_mode_blocks_tool_preview_toggle() { #[test] fn presenter_mode_closes_help_overlay_and_switches_to_highlight_tool() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); state.help_overlay.visible = true; state.set_tool_override(Some(Tool::Pen)); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(state.presenter_mode_active()); assert!(!state.help_overlay.visible); @@ -111,6 +135,12 @@ fn presenter_mode_closes_help_overlay_and_switches_to_highlight_tool() { #[test] fn presenter_locked_mode_blocks_non_left_drag_bindings() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); let mut bindings = DragToolBindings::default(); bindings.right.drag = DragBinding::from_tool(Tool::Pen); @@ -118,7 +148,7 @@ fn presenter_locked_mode_blocks_non_left_drag_bindings() { state.presenter_mode_config_mut_for_test().tool_behavior = PresenterToolBehavior::ForceHighlightLocked; - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); state.on_mouse_press(MouseButton::Right, 0, 0); state.on_mouse_motion(10, 10); state.on_mouse_release(MouseButton::Right, 10, 10); @@ -129,17 +159,23 @@ fn presenter_locked_mode_blocks_non_left_drag_bindings() { #[test] fn presenter_mode_restores_status_bar_toolbars_and_tool_override_on_exit() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); state.ui_visibility.show_status_bar = true; state.test_set_toolbar_visibility_state(true, true, state.toolbar_top_pinned()); state.set_tool_override(Some(Tool::Arrow)); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(!state.ui_visibility.show_status_bar); assert!(!state.toolbar_visible()); assert_eq!(state.tool_override(), Some(Tool::Highlight)); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(!state.presenter_mode_active()); assert!(state.ui_visibility.show_status_bar); assert!(state.toolbar_visible()); @@ -149,6 +185,12 @@ fn presenter_mode_restores_status_bar_toolbars_and_tool_override_on_exit() { #[test] fn presenter_micro_mapping_shows_the_chip_and_restores_on_exit() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; use crate::config::{PresenterToolbarMode, TopDisplayMode}; let mut state = create_test_input_state(); @@ -157,7 +199,7 @@ fn presenter_micro_mapping_shows_the_chip_and_restores_on_exit() { state.test_set_toolbar_visibility_state(true, true, state.toolbar_top_pinned()); state.test_set_toolbar_display_state(state.toolbar_top_display_mode(), true); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(state.presenter_mode_active()); assert!( state.toolbar_top_visible(), @@ -168,7 +210,7 @@ fn presenter_micro_mapping_shows_the_chip_and_restores_on_exit() { !state.toolbar_top_minimized(), "the chip replaces the restore tab" ); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(!state.presenter_mode_active()); assert_eq!(state.toolbar_top_display_mode(), TopDisplayMode::Full); assert!( @@ -179,22 +221,34 @@ fn presenter_micro_mapping_shows_the_chip_and_restores_on_exit() { #[test] fn presenter_hidden_mapping_keeps_todays_behavior() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; use crate::config::{PresenterToolbarMode, TopDisplayMode}; let mut state = create_test_input_state(); state.presenter_mode_config_mut_for_test().hide_toolbars = true; state.presenter_mode_config_mut_for_test().toolbar_mode = PresenterToolbarMode::Hidden; - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); assert!(!state.toolbar_top_visible()); assert_eq!(state.toolbar_top_display_mode(), TopDisplayMode::Full); } #[test] fn presenter_mode_emits_entry_and_exit_toasts() { + let route_measurer = crate::draw::TextMeasurer::default(); + let route_ui_engine = crate::ui_text::UiTextEngine::default(); + let route_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &route_ui_engine, + }; let mut state = create_test_input_state(); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); let entry_toast = state.active_toast().expect("entry toast"); assert_eq!(entry_toast.message, "Presenter Mode active"); assert_eq!( @@ -205,7 +259,7 @@ fn presenter_mode_emits_entry_and_exit_toasts() { Some(crate::config::Action::TogglePresenterMode) ); - state.toggle_presenter_mode(); + state.toggle_presenter_mode_with_resources(route_resources); let exit_toast = state.active_toast().expect("exit toast"); assert_eq!(exit_toast.message, "Stopping Presenter Mode"); assert!(exit_toast.action.is_none()); diff --git a/src/input/state/tests/pressure_modes.rs b/src/input/state/tests/pressure_modes.rs index b89e161e6..9e01e5be2 100644 --- a/src/input/state/tests/pressure_modes.rs +++ b/src/input/state/tests/pressure_modes.rs @@ -32,7 +32,8 @@ fn add_text_shape(state: &mut InputState) -> ShapeId { } fn open_panel(state: &mut InputState) { - assert!(state.show_properties_panel()); + let route_measurer = crate::draw::TextMeasurer::default(); + assert!(state.show_properties_panel_with(&route_measurer)); } fn thickness_entry_index(state: &InputState) -> Option { @@ -119,6 +120,7 @@ fn pressure_entry_mode_pressure_only_requires_all_pressure() { #[test] fn pressure_entry_enabled_when_edit_mode_add_and_unlocked() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.style.pressure_thickness_entry_mode = PressureThicknessEntryMode::PressureOnly; state.style.pressure_thickness_edit_mode = PressureThicknessEditMode::Add; @@ -135,7 +137,7 @@ fn pressure_entry_enabled_when_edit_mode_add_and_unlocked() { assert!(!entry.disabled); state.set_properties_panel_focus(Some(index)); - assert!(state.adjust_properties_panel_entry(1)); + assert!(state.adjust_properties_panel_entry_with(&route_measurer, 1)); let updated = pressure_points(&state, id); assert!((updated[0] - 3.0).abs() < 0.01); @@ -144,6 +146,7 @@ fn pressure_entry_enabled_when_edit_mode_add_and_unlocked() { #[test] fn pressure_entry_add_mode_decrements_thickness() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.style.pressure_thickness_entry_mode = PressureThicknessEntryMode::PressureOnly; state.style.pressure_thickness_edit_mode = PressureThicknessEditMode::Add; @@ -160,7 +163,7 @@ fn pressure_entry_add_mode_decrements_thickness() { assert!(!entry.disabled); state.set_properties_panel_focus(Some(index)); - assert!(state.adjust_properties_panel_entry(-1)); + assert!(state.adjust_properties_panel_entry_with(&route_measurer, -1)); let updated = pressure_points(&state, id); assert!((updated[0] - 1.0).abs() < 0.01); @@ -169,6 +172,7 @@ fn pressure_entry_add_mode_decrements_thickness() { #[test] fn pressure_entry_disabled_when_edit_mode_disabled() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.style.pressure_thickness_entry_mode = PressureThicknessEntryMode::PressureOnly; state.style.pressure_thickness_edit_mode = PressureThicknessEditMode::Disabled; @@ -186,7 +190,7 @@ fn pressure_entry_disabled_when_edit_mode_disabled() { assert_eq!(entry.value, "Varies (pressure)"); state.set_properties_panel_focus(Some(index)); - assert!(!state.adjust_properties_panel_entry(1)); + assert!(!state.adjust_properties_panel_entry_with(&route_measurer, 1)); let updated = pressure_points(&state, id); assert!((updated[0] - 2.0).abs() < 0.01); @@ -254,6 +258,7 @@ fn pressure_entry_mode_any_pressure_mixed_lock_states_is_editable() { #[test] fn pressure_entry_mode_any_pressure_add_updates_pressure_only() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.style.pressure_thickness_entry_mode = PressureThicknessEntryMode::AnyPressure; state.style.pressure_thickness_edit_mode = PressureThicknessEditMode::Add; @@ -273,7 +278,7 @@ fn pressure_entry_mode_any_pressure_add_updates_pressure_only() { assert_eq!(entry.value, "Varies (pressure)"); state.set_properties_panel_focus(Some(index)); - assert!(state.adjust_properties_panel_entry(1)); + assert!(state.adjust_properties_panel_entry_with(&route_measurer, 1)); let updated = pressure_points(&state, pressure_id); assert!((updated[0] - 3.0).abs() < 0.01); @@ -283,6 +288,7 @@ fn pressure_entry_mode_any_pressure_add_updates_pressure_only() { #[test] fn pressure_entry_mode_any_pressure_scale_updates_pressure_only() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.style.pressure_thickness_entry_mode = PressureThicknessEntryMode::AnyPressure; state.style.pressure_thickness_edit_mode = PressureThicknessEditMode::Scale; @@ -302,7 +308,7 @@ fn pressure_entry_mode_any_pressure_scale_updates_pressure_only() { assert!(!entry.disabled); state.set_properties_panel_focus(Some(index)); - assert!(state.adjust_properties_panel_entry(1)); + assert!(state.adjust_properties_panel_entry_with(&route_measurer, 1)); let updated = pressure_points(&state, pressure_id); assert!((updated[0] - 2.2).abs() < 0.01); @@ -312,6 +318,7 @@ fn pressure_entry_mode_any_pressure_scale_updates_pressure_only() { #[test] fn pressure_entry_scale_mode_applies_step() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.style.pressure_thickness_entry_mode = PressureThicknessEntryMode::PressureOnly; state.style.pressure_thickness_edit_mode = PressureThicknessEditMode::Scale; @@ -329,7 +336,7 @@ fn pressure_entry_scale_mode_applies_step() { assert!(!entry.disabled); state.set_properties_panel_focus(Some(index)); - assert!(state.adjust_properties_panel_entry(1)); + assert!(state.adjust_properties_panel_entry_with(&route_measurer, 1)); let updated = pressure_points(&state, id); assert!((updated[0] - 2.2).abs() < 0.01); @@ -338,6 +345,7 @@ fn pressure_entry_scale_mode_applies_step() { #[test] fn pressure_entry_scale_mode_decrements_thickness() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.style.pressure_thickness_entry_mode = PressureThicknessEntryMode::PressureOnly; state.style.pressure_thickness_edit_mode = PressureThicknessEditMode::Scale; @@ -355,7 +363,7 @@ fn pressure_entry_scale_mode_decrements_thickness() { assert!(!entry.disabled); state.set_properties_panel_focus(Some(index)); - assert!(state.adjust_properties_panel_entry(-1)); + assert!(state.adjust_properties_panel_entry_with(&route_measurer, -1)); let updated = pressure_points(&state, id); assert!((updated[0] - 1.8).abs() < 0.01); diff --git a/src/input/state/tests/properties_panel.rs b/src/input/state/tests/properties_panel.rs index b5f07c1e1..02b8fa740 100644 --- a/src/input/state/tests/properties_panel.rs +++ b/src/input/state/tests/properties_panel.rs @@ -27,11 +27,12 @@ fn entry_index(state: &InputState, label: &str) -> usize { #[test] fn show_properties_panel_for_single_shape_reports_type_layer_and_lock_state() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let shape_id = add_rect(&mut state, 10, 20, 30, 40); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); let panel = state.properties_panel().expect("properties panel"); assert_eq!(panel.title, "Shape Properties"); @@ -50,6 +51,7 @@ fn show_properties_panel_for_single_shape_reports_type_layer_and_lock_state() { #[test] fn show_properties_panel_for_multi_selection_includes_locked_count_and_summary() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let first = add_rect(&mut state, 10, 10, 20, 20); let second = add_rect(&mut state, 50, 15, 10, 15); @@ -61,7 +63,7 @@ fn show_properties_panel_for_multi_selection_includes_locked_count_and_summary() state.boards.active_frame_mut().shapes[second_index].locked = true; state.set_selection(vec![first, second]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); let panel = state.properties_panel().expect("properties panel"); assert_eq!(panel.title, "Selection Properties"); @@ -147,6 +149,7 @@ fn style_pill_selection_docking_routes_through_the_properties_apply_machinery() #[test] fn spotlight_magnification_property_steps_the_selected_shape_and_is_undoable() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Spotlight { cx: 100, @@ -165,9 +168,11 @@ fn spotlight_magnification_property_steps_the_selected_shape_and_is_undoable() { assert_eq!(entry.label, "Magnification"); assert_eq!(entry.value, "1.5x"); - assert!( - state.adjust_selection_property_kind(SelectionPropertyKind::SpotlightMagnification, 1,) - ); + assert!(state.adjust_selection_property_kind_with( + &route_measurer, + SelectionPropertyKind::SpotlightMagnification, + 1, + )); let magnification = |state: &InputState| match &state .boards .active_frame() @@ -187,10 +192,11 @@ fn spotlight_magnification_property_steps_the_selected_shape_and_is_undoable() { #[test] fn close_properties_panel_clears_panel_and_requests_redraw() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let shape_id = add_rect(&mut state, 5, 5, 10, 10); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); state.needs_redraw = false; state.close_properties_panel(); @@ -202,6 +208,7 @@ fn close_properties_panel_clears_panel_and_requests_redraw() { #[test] fn show_properties_panel_anchors_to_screen_space_on_panned_boards() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.switch_board(BOARD_ID_WHITEBOARD); assert!(state.boards.active_frame_mut().set_view_offset(100, 50)); @@ -209,7 +216,7 @@ fn show_properties_panel_anchors_to_screen_space_on_panned_boards() { let shape_id = add_rect(&mut state, 140, 90, 20, 20); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); let panel = state.properties_panel().expect("properties panel"); assert_eq!(panel.anchor_rect, Rect::new(38, 38, 24, 24)); @@ -217,10 +224,11 @@ fn show_properties_panel_anchors_to_screen_space_on_panned_boards() { #[test] fn activate_fill_entry_toggles_rectangle_fill_and_refreshes_panel_value() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let shape_id = add_rect(&mut state, 5, 5, 20, 20); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); let fill_index = entry_index(&state, "Fill"); state.set_properties_panel_focus(Some(fill_index)); @@ -244,6 +252,7 @@ fn activate_fill_entry_toggles_rectangle_fill_and_refreshes_panel_value() { #[test] fn adjust_font_size_entry_increases_text_size_and_refreshes_panel_value() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Text { x: 10, @@ -256,11 +265,11 @@ fn adjust_font_size_entry_increases_text_size_and_refreshes_panel_value() { wrap_width: None, }); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); let font_index = entry_index(&state, "Font size"); state.set_properties_panel_focus(Some(font_index)); - assert!(state.adjust_properties_panel_entry(1)); + assert!(state.adjust_properties_panel_entry_with(&route_measurer, 1)); match &state .boards @@ -280,6 +289,7 @@ fn adjust_font_size_entry_increases_text_size_and_refreshes_panel_value() { #[test] fn activate_text_background_entry_on_mixed_selection_turns_all_backgrounds_on() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let first = state.boards.active_frame_mut().add_shape(Shape::Text { x: 10, @@ -302,7 +312,7 @@ fn activate_text_background_entry_on_mixed_selection_turns_all_backgrounds_on() wrap_width: None, }); state.set_selection(vec![first, second]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); let bg_index = entry_index(&state, "Text background"); state.set_properties_panel_focus(Some(bg_index)); @@ -330,6 +340,7 @@ fn activate_text_background_entry_on_mixed_selection_turns_all_backgrounds_on() #[test] fn adjust_arrow_length_entry_clamps_to_max_and_refreshes_panel_value() { + let route_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Arrow { x1: 0, @@ -346,12 +357,12 @@ fn adjust_arrow_length_entry_clamps_to_max_and_refreshes_panel_value() { label: None, }); state.set_selection(vec![shape_id]); - assert!(state.show_properties_panel()); + assert!(state.show_properties_panel_with(&route_measurer)); let length_index = entry_index(&state, "Arrow length"); state.set_properties_panel_focus(Some(length_index)); - assert!(state.adjust_properties_panel_entry(1)); - assert!(!state.adjust_properties_panel_entry(1)); + assert!(state.adjust_properties_panel_entry_with(&route_measurer, 1)); + assert!(!state.adjust_properties_panel_entry_with(&route_measurer, 1)); match &state .boards @@ -401,6 +412,7 @@ fn magnification_entry(state: &InputState) -> Option Date: Sat, 5 Sep 2026 09:53:22 +0200 Subject: [PATCH 27/42] refactor(export): retain text resources across PDF labels --- src/canvas_export/pdf.rs | 2 + src/canvas_export/pdf/tests.rs | 48 ++++++++++++++++++ src/canvas_export/pdf_labels.rs | 86 ++++++++++++++++++++++++++++++--- src/ui_text.rs | 17 ------- src/ui_text/tests.rs | 6 ++- 5 files changed, 132 insertions(+), 27 deletions(-) diff --git a/src/canvas_export/pdf.rs b/src/canvas_export/pdf.rs index 2c14df4d5..3042498c8 100644 --- a/src/canvas_export/pdf.rs +++ b/src/canvas_export/pdf.rs @@ -144,6 +144,7 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca .map_err(|err| CaptureError::ImageError(format!("Failed to create PDF context: {err}")))?; let mut caches = RenderCaches::default(); + let ui_text = crate::ui_text::UiTextEngine::default(); for page in &snapshot.pages { let layout = page.layout; validate_page_size(layout.page_width, layout.page_height)?; @@ -179,6 +180,7 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca )?; } render_pdf_label( + &ui_text, &ctx, &snapshot.labels, &page.metadata, diff --git a/src/canvas_export/pdf/tests.rs b/src/canvas_export/pdf/tests.rs index 3d9ee12da..610393ab2 100644 --- a/src/canvas_export/pdf/tests.rs +++ b/src/canvas_export/pdf/tests.rs @@ -236,3 +236,51 @@ fn pdf_page( }, } } + +#[test] +fn worker_exports_three_page_pdf_from_unicode_metadata() { + let source = CanvasExportRect::new(0.0, 0.0, 100.0, 100.0).unwrap(); + let pages = (0..3) + .map(|index| { + let mut page = pdf_page(300.0, 200.0, source, index, 3); + page.metadata.board_name = "Board 測試 العربية".into(); + page + }) + .collect(); + let snapshot = BoardPdfExportSnapshot { + pages, + labels: crate::config::PdfLabelConfig { + enabled: true, + content: crate::config::PdfLabelContentMode::BoardName, + ..Default::default() + }, + }; + // Only value snapshots cross the worker boundary; text resources are + // constructed by the export root on the worker thread. + let bytes = std::thread::spawn(move || render_board_pdf(&snapshot)) + .join() + .unwrap() + .unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + // Cairo can compress page dictionaries into PDF object streams. Ask the + // same PDF reader used by the existing page-layout test to inspect them. + let temp = crate::test_temp::tempdir().expect("tempdir"); + let path = temp.path().join("worker-pages.pdf"); + std::fs::write(&path, bytes).expect("write worker PDF"); + let output = match Command::new("pdfinfo").arg(&path).output() { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, + Err(error) => panic!("failed to run pdfinfo: {error}"), + }; + assert!( + output.status.success(), + "pdfinfo failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let info = String::from_utf8_lossy(&output.stdout); + let pages = info + .lines() + .find_map(|line| line.strip_prefix("Pages:")) + .expect("pdfinfo page count"); + assert_eq!(pages.trim(), "3", "worker PDF must contain all three pages"); +} diff --git a/src/canvas_export/pdf_labels.rs b/src/canvas_export/pdf_labels.rs index 50972fc1c..b93b56afa 100644 --- a/src/canvas_export/pdf_labels.rs +++ b/src/canvas_export/pdf_labels.rs @@ -6,10 +6,12 @@ use crate::config::{ }; use super::pdf::PdfPageMetadata; +use crate::ui_text::UiTextEngine; const ELLIPSIS: &str = "…"; pub(crate) fn render_pdf_label( + engine: &UiTextEngine, ctx: &cairo::Context, config: &PdfLabelConfig, metadata: &PdfPageMetadata, @@ -43,10 +45,10 @@ pub(crate) fn render_pdf_label( size: config.font_size, }; - let Some(text) = ellipsize_to_width(&style, &text, available_width) else { + let Some(text) = ellipsize_to_width(engine, &style, &text, available_width) else { return; }; - let layout = crate::ui_text::text_layout(ctx, style, &text, None); + let layout = engine.layout(ctx, style, &text, None); let extents = layout.ink_extents(); if extents.width() <= 0.0 || extents.height() <= 0.0 { return; @@ -187,12 +189,15 @@ fn label_value<'a>(name: &str, metadata: &'a PdfPageMetadata) -> Option<&'a str> /// fonts is fitted by what will actually be drawn rather than by the toy /// API's idea of its width. fn ellipsize_to_width( + engine: &UiTextEngine, style: &crate::ui_text::UiTextStyle<'_>, text: &str, max_width: f64, ) -> Option { let width_of = |candidate: &str| { - crate::ui_text::measure_text(*style, candidate, None).map(|extents| extents.width()) + engine + .measure(*style, candidate, None) + .map(|extents| extents.width()) }; if width_of(text)? <= max_width { @@ -290,9 +295,10 @@ mod tests { /// measured as nothing and painted as boxes in the exported PDF. #[test] fn non_latin_labels_measure_as_real_text() { + let engine = &UiTextEngine::default(); let style = label_style(); for text in ["ボード 1", "لوحة", "보드", "Доска"] { - let extents = crate::ui_text::measure_text(style, text, None).expect("shaped extents"); + let extents = engine.measure(style, text, None).expect("shaped extents"); assert!( extents.width() > 0.0, "{text:?} measured as nothing, so it would paint as nothing" @@ -304,23 +310,27 @@ mod tests { /// box is trimmed by what will actually be drawn. #[test] fn ellipsizing_uses_the_shaped_width() { + let engine = &UiTextEngine::default(); let style = label_style(); let long = "ボードボードボードボードボードボード"; - let full = crate::ui_text::measure_text(style, long, None) + let full = engine + .measure(style, long, None) .expect("shaped extents") .width(); - let fitted = ellipsize_to_width(&style, long, full / 2.0).expect("a shorter label fits"); + let fitted = + ellipsize_to_width(engine, &style, long, full / 2.0).expect("a shorter label fits"); assert!(fitted.ends_with(ELLIPSIS), "trimmed labels are marked"); assert!(fitted.chars().count() < long.chars().count()); - let fitted_width = crate::ui_text::measure_text(style, &fitted, None) + let fitted_width = engine + .measure(style, &fitted, None) .expect("shaped extents") .width(); assert!(fitted_width <= full / 2.0, "the result actually fits"); // A label that already fits is returned untouched. assert_eq!( - ellipsize_to_width(&style, long, full * 2.0).as_deref(), + ellipsize_to_width(engine, &style, long, full * 2.0).as_deref(), Some(long) ); } @@ -343,4 +353,64 @@ mod tests { Some((140.0, 70.0)) ); } + + #[test] + fn retained_labels_rebind_between_pdf_and_raster_without_changing_fit_or_pixels() { + let engine = UiTextEngine::default(); + let config = PdfLabelConfig { + enabled: true, + content: PdfLabelContentMode::BoardName, + position: PdfLabelPosition::BottomRight, + font_size: 12.0, + ..PdfLabelConfig::default() + }; + let mut metadata = metadata(); + metadata.board_name = "Board 測試 العربية long label repeated across exported pages".into(); + let style = crate::ui_text::UiTextStyle { + family: &config.font_family, + ..label_style() + }; + let available_width = 240.0 - config.margin * 2.0 - config.padding_x * 2.0; + let fitted = + ellipsize_to_width(&engine, &style, &metadata.board_name, available_width).unwrap(); + assert!(fitted.ends_with(ELLIPSIS)); + assert!(engine.measure(style, &fitted, None).unwrap().width() <= available_width); + for density in [1, 2, 1] { + // The same owner paints a vector target, then returns to raster and + // canonical measurement just as an export's repeated labels do. + let pdf = cairo::PdfSurface::for_stream(240.0, 100.0, Vec::::new()).unwrap(); + { + let ctx = cairo::Context::new(&pdf).unwrap(); + render_pdf_label(&engine, &ctx, &config, &metadata, 240.0, 100.0); + ctx.show_page().unwrap(); + } + let stream = pdf.finish_output_stream().unwrap(); + let bytes = stream.downcast::>().unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + assert_eq!( + ellipsize_to_width(&engine, &style, &metadata.board_name, available_width), + Some(fitted.clone()) + ); + let pixels = |engine: &UiTextEngine| { + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + 240 * density, + 100 * density, + ) + .unwrap(); + surface.set_device_scale(density as f64, density as f64); + { + let ctx = cairo::Context::new(&surface).unwrap(); + render_pdf_label(engine, &ctx, &config, &metadata, 240.0, 100.0); + } + surface.data().unwrap().to_vec() + }; + let actual = pixels(&engine); + assert!(actual.iter().any(|byte| *byte != 0)); + assert!( + actual == pixels(&UiTextEngine::default()), + "label density {density}" + ); + } + } } diff --git a/src/ui_text.rs b/src/ui_text.rs index a2c16f44b..57c44cfc7 100644 --- a/src/ui_text.rs +++ b/src/ui_text.rs @@ -162,14 +162,6 @@ pub(crate) fn with_legacy_engine(f: impl FnOnce(&UiTextEngine) -> T) -> T { LEGACY_UI_TEXT.with(f) } -pub(crate) fn measure_text( - style: UiTextStyle<'_>, - text: &str, - wrap_width: Option, -) -> Option { - with_legacy_engine(|engine| engine.measure(style, text, wrap_width)) -} - impl UiTextEngine { /// Measure before a target exists, using the same layout cache as painting. pub(crate) fn measure( @@ -205,15 +197,6 @@ fn weight_key(weight: cairo::FontWeight) -> u8 { } } -pub(crate) fn text_layout( - ctx: &cairo::Context, - style: UiTextStyle<'_>, - text: &str, - wrap_width: Option, -) -> UiTextLayout { - with_legacy_engine(|engine| engine.layout(ctx, style, text, wrap_width)) -} - impl UiTextEngine { pub(crate) fn layout( &self, diff --git a/src/ui_text/tests.rs b/src/ui_text/tests.rs index fa248c6e7..9d642a607 100644 --- a/src/ui_text/tests.rs +++ b/src/ui_text/tests.rs @@ -255,8 +255,10 @@ fn cache_keys_keep_font_categories_quantized_size_and_wrap_units() { fn temporary_legacy_bridge_retains_layouts_and_matches_an_explicit_owner() { let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); - let first = text_layout(&ctx, style(14.0), "legacy bridge", Some(50.0)); - let second = text_layout(&ctx, style(14.0), "legacy bridge", Some(50.0)); + let first = + with_legacy_engine(|engine| engine.layout(&ctx, style(14.0), "legacy bridge", Some(50.0))); + let second = + with_legacy_engine(|engine| engine.layout(&ctx, style(14.0), "legacy bridge", Some(50.0))); assert_eq!(first.layout, second.layout); let engine = UiTextEngine::default(); let explicit = engine.layout(&ctx, style(14.0), "legacy bridge", Some(50.0)); From 9fdb0796d5ad8135fe4cf5f119152c869104cee4 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:53:28 +0200 Subject: [PATCH 28/42] refactor(capture): separate region picker painting responsibilities --- src/ui/region_capture_picker.rs | 1449 +-------------------- src/ui/region_capture_picker/cut.rs | 81 ++ src/ui/region_capture_picker/layout.rs | 219 ++++ src/ui/region_capture_picker/legend.rs | 69 + src/ui/region_capture_picker/readout.rs | 77 ++ src/ui/region_capture_picker/selection.rs | 82 ++ src/ui/region_capture_picker/tests.rs | 835 ++++++++++++ src/ui/region_capture_picker/types.rs | 102 ++ 8 files changed, 1490 insertions(+), 1424 deletions(-) create mode 100644 src/ui/region_capture_picker/cut.rs create mode 100644 src/ui/region_capture_picker/layout.rs create mode 100644 src/ui/region_capture_picker/legend.rs create mode 100644 src/ui/region_capture_picker/readout.rs create mode 100644 src/ui/region_capture_picker/selection.rs create mode 100644 src/ui/region_capture_picker/tests.rs create mode 100644 src/ui/region_capture_picker/types.rs diff --git a/src/ui/region_capture_picker.rs b/src/ui/region_capture_picker.rs index 26ad3c84b..d477a8038 100644 --- a/src/ui/region_capture_picker.rs +++ b/src/ui/region_capture_picker.rs @@ -1,244 +1,32 @@ -use crate::capture::CutAxis; -use crate::input::SelectionHandle; -use crate::input::state::RegionSelection; -use crate::screen_pixels::PackedArgb32; -use crate::util::Rect; +//! Region selector painting and its numeric layout facade. + +mod cut; +mod layout; +mod legend; +mod readout; +mod selection; +mod types; + +pub(crate) use layout::{capture_size_text, measure_picker_damage}; +pub(crate) use legend::{OCR_LEGEND_TEXT, render_region_legend}; +pub(crate) use types::{ + RegionCaptureCutVisual, RegionCaptureLoupeVisual, RegionCapturePickerVisual, + RegionCaptureWindowVisual, RegionCutDragVisual, RegionCutPreviewVisual, +}; -use super::primitives::{draw_rounded_rect, text_extents_for_with_engine}; -use super::region_action_bar::{ - RegionAction, RegionActionAvailability, RegionActionBar, RegionActionBarVisual, - RegionActionRect, RegionCutStatus, render_region_action_bar, +use crate::ui::region_action_bar::{ + RegionActionBar, RegionActionBarVisual, render_region_action_bar, }; -use super::region_resize_handles::{RegionResizeHandles, render_region_resize_handles}; +use crate::ui::region_resize_handles::render_region_resize_handles; use crate::ui_text::UiTextEngine; +use cut::{draw_cut_drag, paint_cut_preview}; +use layout::normalized_rect; +use legend::picker_legend_text; +use readout::{READOUT_FONT_SIZE, draw_readout_panel}; +use selection::{draw_crosshair, draw_scrim, draw_selection_frame, draw_window_target_frames}; -const SCRIM: (f64, f64, f64, f64) = (0.02, 0.03, 0.05, 0.48); const PANEL_FILL: (f64, f64, f64, f64) = (12.0 / 255.0, 12.0 / 255.0, 15.0 / 255.0, 0.92); -const PANEL_BORDER: (f64, f64, f64, f64) = (1.0, 1.0, 1.0, 0.16); -const POINTER_GAP: f64 = 15.0; -/// Gap between the reviewed selection and its size badge. The badge is parked -/// on the selection during Review instead of trailing the pointer, so a -/// finished rectangle stops behaving like one that is still being dragged. -const SELECTION_BADGE_GAP: f64 = 6.0; -const PANEL_MARGIN: f64 = 6.0; -const PANEL_PADDING_X: f64 = 8.0; -const PANEL_HEIGHT: f64 = 22.0; const PANEL_RADIUS: f64 = 6.0; -const READOUT_FONT_SIZE: f64 = 12.0; -const LEGEND_FONT_SIZE: f64 = 12.0; -const AREA_LEGEND_TEXT: &str = "Drag to select Shift: square Ctrl+A: all Esc: cancel"; -const AREA_WITH_WINDOWS_LEGEND_TEXT: &str = - "Drag to select Shift: square Ctrl+A: all Space: window Esc: cancel"; -/// Recognition offers no square modifier, and `Ctrl+A` reads everything rather -/// than selecting everything, so it says what it does rather than borrowing -/// the capture picker's wording. -pub(crate) const OCR_LEGEND_TEXT: &str = "Drag to read text Ctrl+A: whole screen Esc: cancel"; -const WINDOW_LEGEND_TEXT: &str = - "Click: select Super+Arrows: choose Enter: select Space: area Esc: cancel"; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct RegionCaptureWindowVisual<'a> { - pub available: bool, - pub active: bool, - pub targets: &'a [RegionSelection], - /// The pointer-hovered or keyboard-focused window candidate. - pub highlighted_target: Option, -} - -impl RegionCaptureWindowVisual<'_> { - #[cfg(test)] - pub(crate) const fn disabled() -> Self { - Self { - available: false, - active: false, - targets: &[], - highlighted_target: None, - } - } - - fn highlighted_selection(self) -> Option { - self.active - .then(|| { - self.highlighted_target - .and_then(|index| self.targets.get(index).copied()) - }) - .flatten() - } -} - -fn picker_legend_text(window: RegionCaptureWindowVisual<'_>) -> &'static str { - if window.active { - WINDOW_LEGEND_TEXT - } else if window.available { - AREA_WITH_WINDOWS_LEGEND_TEXT - } else { - AREA_LEGEND_TEXT - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct RegionCapturePickerVisual<'a> { - pub selection: Option, - pub pointer: (f64, f64), - /// Authoritative pixel coordinates or size supplied by the picker owner. - pub measurement: Option<&'a str>, - pub show_scrim: bool, - pub show_legend: bool, - /// The selection is committed and awaiting a destination choice. Review - /// drops the targeting chrome: no crosshair, and the size badge anchors to - /// the rectangle rather than following the pointer. - pub review: bool, - /// Resize grips on the reviewed rectangle. Present only in Review, where - /// they replace the corner arms the targeting frame draws. - pub resize_handles: Option, - pub hovered_handle: Option, - pub loupe: Option, - pub action_bar: Option, - pub hovered_action: Option, - pub include_drawings: bool, - pub cut: RegionCaptureCutVisual<'a>, - pub window: RegionCaptureWindowVisual<'a>, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct RegionCutPreviewVisual<'a> { - pub pixels: &'a PackedArgb32, - pub display: RegionSelection, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct RegionCutDragVisual { - pub axis: CutAxis, - pub band: RegionSelection, -} - -#[derive(Debug, Clone, Copy, PartialEq, Default)] -pub(crate) struct RegionCaptureCutVisual<'a> { - pub preview: Option>, - pub drag: Option, - pub availability: RegionActionAvailability, - pub cut_armed: bool, - pub status: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct RegionCaptureLoupeVisual { - pub pointer: (f64, f64), - pub image_center: (f64, f64), -} - -impl RegionCaptureLoupeVisual { - pub(crate) fn when_enabled( - show_loupe: bool, - pointer: (f64, f64), - image_center: (f64, f64), - ) -> Option { - show_loupe.then_some(Self { - pointer, - image_center, - }) - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -struct PointerPanelLayout { - x: f64, - y: f64, - width: f64, - height: f64, -} - -pub(crate) fn capture_size_text(size: (u32, u32)) -> String { - format!("{} × {}", size.0, size.1) -} - -/// Conservative targeted damage for Measure Mode's chrome. The crosshair is -/// represented as two thin strips; the selection as four edge strips; and the -/// pointer readout by a bounded box covering every flip direction. This avoids -/// the capture picker's full-surface scrim damage without leaving trails. -pub(crate) fn measure_picker_damage( - selection: Option, - pointer: (f64, f64), - screen: (u32, u32), -) -> Vec { - let width = screen.0.min(i32::MAX as u32) as i32; - let height = screen.1.min(i32::MAX as u32) as i32; - if width <= 0 || height <= 0 { - return Vec::new(); - } - let x = pointer.0.round().clamp(0.0, f64::from(width - 1)) as i32; - let y = pointer.1.round().clamp(0.0, f64::from(height - 1)) as i32; - let mut damage = Vec::with_capacity(7); - push_clipped_damage(&mut damage, x - 2, 0, 5, height, width, height); - push_clipped_damage(&mut damage, 0, y - 2, width, 5, width, height); - // The monospace readout is short, but cover both horizontal and vertical - // flip choices so the damage remains correct without a Cairo text pass. - push_clipped_damage(&mut damage, x - 240, y - 48, 480, 96, width, height); - - if let Some(selection) = selection { - let min_x = selection.start.0.min(selection.end.0).floor() as i32; - let min_y = selection.start.1.min(selection.end.1).floor() as i32; - let max_x = selection.start.0.max(selection.end.0).ceil() as i32; - let max_y = selection.start.1.max(selection.end.1).ceil() as i32; - let rect_width = max_x.saturating_sub(min_x); - let rect_height = max_y.saturating_sub(min_y); - push_clipped_damage( - &mut damage, - min_x - 4, - min_y - 4, - rect_width + 8, - 8, - width, - height, - ); - push_clipped_damage( - &mut damage, - min_x - 4, - max_y - 4, - rect_width + 8, - 8, - width, - height, - ); - push_clipped_damage( - &mut damage, - min_x - 4, - min_y - 4, - 8, - rect_height + 8, - width, - height, - ); - push_clipped_damage( - &mut damage, - max_x - 4, - min_y - 4, - 8, - rect_height + 8, - width, - height, - ); - } - damage -} - -fn push_clipped_damage( - damage: &mut Vec, - x: i32, - y: i32, - width: i32, - height: i32, - screen_width: i32, - screen_height: i32, -) { - let min_x = x.clamp(0, screen_width); - let min_y = y.clamp(0, screen_height); - let max_x = x.saturating_add(width).clamp(0, screen_width); - let max_y = y.saturating_add(height).clamp(0, screen_height); - if let Some(rect) = Rect::from_min_max(min_x, min_y, max_x, max_y) { - damage.push(rect); - } -} pub(crate) fn render_region_capture_loupe( ctx: &cairo::Context, @@ -273,15 +61,7 @@ pub(crate) fn render_region_capture_picker( paint_cut_preview(ctx, preview); } if visual.show_scrim { - ctx.set_source_rgba(SCRIM.0, SCRIM.1, SCRIM.2, SCRIM.3); - ctx.rectangle(0.0, 0.0, width, height); - if let Some(selection) = effective_selection { - let (x, y, w, h) = normalized_rect(selection); - ctx.rectangle(x, y, w, h); - ctx.set_fill_rule(cairo::FillRule::EvenOdd); - } - let _ = ctx.fill(); - ctx.set_fill_rule(cairo::FillRule::Winding); + draw_scrim(ctx, width, height, effective_selection); } if visual.window.active { @@ -347,1184 +127,5 @@ pub(crate) fn render_region_capture_picker( let _ = ctx.restore(); } -fn paint_cut_preview(ctx: &cairo::Context, preview: RegionCutPreviewVisual<'_>) { - let pixels = preview.pixels; - let Ok(width) = i32::try_from(pixels.width()) else { - return; - }; - let Ok(height) = i32::try_from(pixels.height()) else { - return; - }; - if width <= 0 || height <= 0 { - return; - } - let (x, y, display_width, display_height) = normalized_rect(preview.display); - if display_width <= 0.0 || display_height <= 0.0 { - return; - } - // SAFETY: Cairo borrows `pixels.data` for this surface. The buffer is - // owned by the Review preview and stays alive until the surface is - // dropped at the end of this function. The API wants `*mut u8` even - // though this path only reads pixels; we never write through the - // pointer, and no other alias mutates the buffer while Cairo holds it. - let surface = unsafe { - cairo::ImageSurface::create_for_data_unsafe( - pixels.data().as_ptr() as *mut u8, - cairo::Format::ARgb32, - width, - height, - pixels.stride(), - ) - }; - let Ok(surface) = surface else { - return; - }; - let _ = ctx.save(); - ctx.rectangle(x, y, display_width, display_height); - ctx.clip(); - ctx.translate(x, y); - ctx.scale( - display_width / f64::from(pixels.width()), - display_height / f64::from(pixels.height()), - ); - // Place the surface in the translated/scaled user space, matching the - // frozen-backdrop path: the CTM maps one source pixel onto one displayed - // output pixel, and nearest-neighbor keeps cut seams crisp. - if ctx.set_source_surface(&surface, 0.0, 0.0).is_ok() { - ctx.source().set_filter(cairo::Filter::Nearest); - ctx.source().set_extend(cairo::Extend::None); - let _ = ctx.paint(); - } - let _ = ctx.restore(); -} - -fn draw_cut_drag(ctx: &cairo::Context, drag: RegionCutDragVisual) { - let (x, y, width, height) = normalized_rect(drag.band); - if width <= 0.0 || height <= 0.0 { - return; - } - ctx.set_source_rgba(0.05, 0.08, 0.14, 0.48); - ctx.rectangle(x, y, width, height); - let _ = ctx.fill(); - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.92); - ctx.set_line_width(1.0); - match drag.axis { - CutAxis::Columns => { - ctx.move_to(x + 0.5, y); - ctx.line_to(x + 0.5, y + height); - ctx.move_to(x + width - 0.5, y); - ctx.line_to(x + width - 0.5, y + height); - } - CutAxis::Rows => { - ctx.move_to(x, y + 0.5); - ctx.line_to(x + width, y + 0.5); - ctx.move_to(x, y + height - 0.5); - ctx.line_to(x + width, y + height - 0.5); - } - } - let _ = ctx.stroke(); -} - -fn normalized_rect(selection: RegionSelection) -> (f64, f64, f64, f64) { - let x = selection.start.0.min(selection.end.0); - let y = selection.start.1.min(selection.end.1); - ( - x, - y, - (selection.end.0 - selection.start.0).abs(), - (selection.end.1 - selection.start.1).abs(), - ) -} - -fn draw_selection_frame(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, corner_arms: bool) { - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.95); - ctx.set_line_width(1.0); - ctx.rectangle(x + 0.5, y + 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0)); - let _ = ctx.stroke(); - - if !corner_arms { - return; - } - let arm = (w.min(h) / 4.0).clamp(4.0, 20.0); - ctx.set_line_width(2.0); - ctx.set_line_cap(cairo::LineCap::Square); - for (corner_x, corner_y, dx, dy) in [ - (x, y, 1.0, 1.0), - (x + w, y, -1.0, 1.0), - (x, y + h, 1.0, -1.0), - (x + w, y + h, -1.0, -1.0), - ] { - ctx.move_to(corner_x + dx * arm, corner_y); - ctx.line_to(corner_x, corner_y); - ctx.line_to(corner_x, corner_y + dy * arm); - let _ = ctx.stroke(); - } -} - -fn draw_window_target_frames(ctx: &cairo::Context, window: RegionCaptureWindowVisual<'_>) { - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.34); - ctx.set_line_width(1.0); - for target in window.targets { - let (x, y, width, height) = normalized_rect(*target); - ctx.rectangle( - x + 0.5, - y + 0.5, - (width - 1.0).max(0.0), - (height - 1.0).max(0.0), - ); - let _ = ctx.stroke(); - } -} - -fn draw_crosshair(ctx: &cairo::Context, pointer: (f64, f64), screen: (f64, f64)) { - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.22); - ctx.set_line_width(1.0); - ctx.move_to(0.0, pointer.1 + 0.5); - ctx.line_to(screen.0, pointer.1 + 0.5); - ctx.move_to(pointer.0 + 0.5, 0.0); - ctx.line_to(pointer.0 + 0.5, screen.1); - let _ = ctx.stroke(); -} - -fn pointer_panel_layout( - pointer: (f64, f64), - text_width: f64, - screen: (u32, u32), -) -> PointerPanelLayout { - let screen_width = f64::from(screen.0); - let screen_height = f64::from(screen.1); - let width = - (text_width + PANEL_PADDING_X * 2.0).min((screen_width - PANEL_MARGIN * 2.0).max(0.0)); - let height = PANEL_HEIGHT.min((screen_height - PANEL_MARGIN * 2.0).max(0.0)); - let mut x = pointer.0 + POINTER_GAP; - let mut y = pointer.1 + POINTER_GAP; - if x + width + PANEL_MARGIN > screen_width { - x = pointer.0 - POINTER_GAP - width; - } - if y + height + PANEL_MARGIN > screen_height { - y = pointer.1 - POINTER_GAP - height; - } - PointerPanelLayout { - x: x.clamp( - PANEL_MARGIN, - (screen_width - width - PANEL_MARGIN).max(PANEL_MARGIN), - ), - y: y.clamp( - PANEL_MARGIN, - (screen_height - height - PANEL_MARGIN).max(PANEL_MARGIN), - ), - width, - height, - } -} - -/// Park the size badge on the selection's top-left corner. The action bar is -/// painted after the badge, and it flips above the selection when it does not -/// fit below, so each placement is checked against the bar and skipped rather -/// than drawn under it. Candidates run outside-above, inside-top-left, -/// inside-top-right; inside-top-left is the fallback when a clamped bar covers -/// all three. -fn selection_badge_layout( - rect: (f64, f64, f64, f64), - text_width: f64, - action_bar: Option, - screen: (u32, u32), -) -> PointerPanelLayout { - let screen_width = f64::from(screen.0); - let screen_height = f64::from(screen.1); - let width = - (text_width + PANEL_PADDING_X * 2.0).min((screen_width - PANEL_MARGIN * 2.0).max(0.0)); - let height = PANEL_HEIGHT.min((screen_height - PANEL_MARGIN * 2.0).max(0.0)); - let (rect_x, rect_y, rect_width, ..) = rect; - let clamp_x = |x: f64| { - x.clamp( - PANEL_MARGIN, - (screen_width - width - PANEL_MARGIN).max(PANEL_MARGIN), - ) - }; - let clamp_y = |y: f64| { - y.clamp( - PANEL_MARGIN, - (screen_height - height - PANEL_MARGIN).max(PANEL_MARGIN), - ) - }; - let left = clamp_x(rect_x); - let right = clamp_x(rect_x + rect_width - width); - let inside = clamp_y(rect_y + SELECTION_BADGE_GAP); - let above = rect_y - SELECTION_BADGE_GAP - height; - let fallback = (left, inside); - let (x, y) = [ - (above >= PANEL_MARGIN).then(|| (left, clamp_y(above))), - Some(fallback), - Some((right, inside)), - ] - .into_iter() - .flatten() - .find(|&(x, y)| !covered_by_action_bar(x, y, width, height, action_bar)) - .unwrap_or(fallback); - PointerPanelLayout { - x, - y, - width, - height, - } -} - -fn covered_by_action_bar( - x: f64, - y: f64, - width: f64, - height: f64, - action_bar: Option, -) -> bool { - action_bar.is_some_and(|bar| { - x < bar.x + bar.width && bar.x < x + width && y < bar.y + bar.height && bar.y < y + height - }) -} - -/// The measurement chip. `selection`, when present, anchors it to that -/// rectangle; otherwise it trails the pointer. -#[allow(clippy::too_many_arguments)] -fn draw_readout_panel( - engine: &UiTextEngine, - ctx: &cairo::Context, - text: &str, - font_size: f64, - pointer: (f64, f64), - selection: Option<(f64, f64, f64, f64)>, - action_bar: Option, - screen: (u32, u32), - weight: cairo::FontWeight, -) { - let extents = text_extents_for_with_engine( - engine, - ctx, - "monospace", - cairo::FontSlant::Normal, - weight, - font_size, - text, - ); - let layout = match selection { - Some(rect) => selection_badge_layout(rect, extents.width(), action_bar, screen), - None => pointer_panel_layout(pointer, extents.width(), screen), - }; - if layout.width <= 0.0 || layout.height <= 0.0 { - return; - } - ctx.set_source_rgba(PANEL_FILL.0, PANEL_FILL.1, PANEL_FILL.2, PANEL_FILL.3); - let radius = PANEL_RADIUS - .min(layout.width / 2.0) - .min(layout.height / 2.0); - draw_rounded_rect(ctx, layout.x, layout.y, layout.width, layout.height, radius); - let _ = ctx.fill(); - ctx.set_source_rgba( - PANEL_BORDER.0, - PANEL_BORDER.1, - PANEL_BORDER.2, - PANEL_BORDER.3, - ); - ctx.set_line_width(1.0); - draw_rounded_rect( - ctx, - layout.x + 0.5, - layout.y + 0.5, - layout.width - 1.0, - layout.height - 1.0, - (radius - 0.5).max(0.0), - ); - let _ = ctx.stroke(); - - ctx.set_source_rgb(1.0, 1.0, 1.0); - ctx.select_font_face( - "monospace", - cairo::FontSlant::Normal, - cairo::FontWeight::Bold, - ); - ctx.set_font_size(font_size); - let baseline = layout.y + (layout.height - extents.height()) / 2.0 - extents.y_bearing(); - let _ = ctx.save(); - ctx.rectangle(layout.x, layout.y, layout.width, layout.height); - ctx.clip(); - ctx.move_to(layout.x + PANEL_PADDING_X - extents.x_bearing(), baseline); - let _ = ctx.show_text(text); - let _ = ctx.restore(); -} - -/// The hint strip along the top of a region selector. Shared so every selector -/// teaches its keys the same way and in the same place. -pub(crate) fn render_region_legend( - engine: &UiTextEngine, - ctx: &cairo::Context, - screen: (u32, u32), - text: &str, -) { - let extents = text_extents_for_with_engine( - engine, - ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - LEGEND_FONT_SIZE, - text, - ); - let screen_width = f64::from(screen.0); - let screen_height = f64::from(screen.1); - let width = (extents.width() + 24.0).min((screen_width - 12.0).max(0.0)); - let height = 28.0_f64.min((screen_height - 12.0).max(0.0)); - if width <= 0.0 || height <= 0.0 { - return; - } - let x = ((screen_width - width) / 2.0).max(6.0); - let y = 12.0_f64.min((screen_height - height).max(0.0)); - let radius = PANEL_RADIUS.min(width / 2.0).min(height / 2.0); - ctx.set_source_rgba(PANEL_FILL.0, PANEL_FILL.1, PANEL_FILL.2, PANEL_FILL.3); - draw_rounded_rect(ctx, x, y, width, height, radius); - let _ = ctx.fill(); - ctx.set_source_rgba(1.0, 1.0, 1.0, 0.88); - ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); - ctx.set_font_size(LEGEND_FONT_SIZE); - let text_x = x + ((width - extents.width()) / 2.0).max(6.0) - extents.x_bearing(); - let baseline = y + (height - extents.height()) / 2.0 - extents.y_bearing(); - let _ = ctx.save(); - ctx.rectangle(x, y, width, height); - ctx.clip(); - ctx.move_to(text_x, baseline); - let _ = ctx.show_text(text); - let _ = ctx.restore(); -} - #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn size_readout_uses_export_pixel_units_and_multiplication_sign() { - assert_eq!(capture_size_text((0, 0)), "0 × 0"); - assert_eq!(capture_size_text((900, 620)), "900 × 620"); - } - - #[test] - fn legend_only_advertises_supported_window_controls() { - assert_eq!( - picker_legend_text(RegionCaptureWindowVisual { - available: false, - active: false, - targets: &[], - highlighted_target: None, - }), - "Drag to select Shift: square Ctrl+A: all Esc: cancel" - ); - assert_eq!( - picker_legend_text(RegionCaptureWindowVisual { - available: true, - active: false, - targets: &[], - highlighted_target: None, - }), - "Drag to select Shift: square Ctrl+A: all Space: window Esc: cancel" - ); - assert_eq!( - picker_legend_text(RegionCaptureWindowVisual { - available: true, - active: true, - targets: &[], - highlighted_target: None, - }), - "Click: select Super+Arrows: choose Enter: select Space: area Esc: cancel" - ); - } - - #[test] - fn every_selector_legend_names_the_keys_that_selector_actually_has() { - // Recognition has no square modifier, and its select-all reads rather - // than selects, so it must not borrow the capture wording. - assert!(OCR_LEGEND_TEXT.contains("Ctrl+A")); - assert!( - !OCR_LEGEND_TEXT.contains("Shift"), - "recognition offers no square modifier: {OCR_LEGEND_TEXT}" - ); - for legend in [ - AREA_LEGEND_TEXT, - AREA_WITH_WINDOWS_LEGEND_TEXT, - OCR_LEGEND_TEXT, - ] { - assert!(legend.contains("Esc"), "every selector says how to leave"); - } - } - - #[test] - fn the_shared_legend_paints_across_the_top_of_any_selector() { - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 400).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_legend(&UiTextEngine::default(), &ctx, (800, 400), OCR_LEGEND_TEXT); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; - - assert!(alpha(400, 24) > 0, "the strip sits along the top edge"); - assert_eq!(alpha(400, 300), 0, "and nowhere else"); - } - - #[test] - fn pointer_panel_prefers_below_right_then_flips_and_clamps() { - assert_eq!( - pointer_panel_layout((20.0, 30.0), 60.0, (400, 300)), - PointerPanelLayout { - x: 35.0, - y: 45.0, - width: 76.0, - height: 22.0, - } - ); - assert_eq!( - pointer_panel_layout((390.0, 290.0), 60.0, (400, 300)), - PointerPanelLayout { - x: 299.0, - y: 253.0, - width: 76.0, - height: 22.0, - } - ); - assert_eq!( - pointer_panel_layout((2.0, 2.0), 120.0, (80, 20)), - PointerPanelLayout { - x: 6.0, - y: 6.0, - width: 68.0, - height: 8.0, - } - ); - } - - #[test] - fn selected_area_is_cut_out_of_the_scrim() { - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_picker( - &UiTextEngine::default(), - &ctx, - 40, - 40, - &RegionCapturePickerVisual { - selection: Some(RegionSelection { - start: (10.0, 10.0), - end: (30.0, 30.0), - }), - pointer: (30.0, 30.0), - measurement: None, - show_scrim: true, - review: false, - resize_handles: None, - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: None, - hovered_action: None, - include_drawings: false, - cut: Default::default(), - window: RegionCaptureWindowVisual::disabled(), - }, - |_x, _y| None, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; - assert!(alpha(2, 2) > 0, "outside the selection must be scrimmed"); - assert_eq!(alpha(20, 20), 0, "the selected pixels must remain clear"); - } - - #[test] - fn highlighted_window_is_cut_out_in_window_mode() { - let targets = [ - RegionSelection { - start: (4.0, 4.0), - end: (14.0, 14.0), - }, - RegionSelection { - start: (20.0, 20.0), - end: (36.0, 36.0), - }, - ]; - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_picker( - &UiTextEngine::default(), - &ctx, - 40, - 40, - &RegionCapturePickerVisual { - selection: None, - pointer: (28.0, 28.0), - measurement: None, - show_scrim: true, - review: false, - resize_handles: None, - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: None, - hovered_action: None, - include_drawings: false, - cut: Default::default(), - window: RegionCaptureWindowVisual { - available: true, - active: true, - targets: &targets, - highlighted_target: Some(1), - }, - }, - |_x, _y| None, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; - assert!(alpha(1, 1) > 0, "outside windows remains scrimmed"); - assert!(alpha(9, 9) > 0, "an unhighlighted window remains scrimmed"); - assert_eq!(alpha(28, 28), 0, "highlighted window is the clear target"); - } - - #[test] - fn window_mode_omits_the_area_crosshair() { - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_picker( - &UiTextEngine::default(), - &ctx, - 40, - 40, - &RegionCapturePickerVisual { - selection: None, - pointer: (20.0, 20.0), - measurement: None, - show_scrim: false, - review: false, - resize_handles: None, - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: None, - hovered_action: None, - include_drawings: false, - cut: Default::default(), - window: RegionCaptureWindowVisual { - available: true, - active: true, - targets: &[], - highlighted_target: None, - }, - }, - |_x, _y| None, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - assert_eq!(data[20 * stride + 20 * 4 + 3], 0); - } - - #[test] - fn highlighted_window_outline_is_stronger_than_other_targets() { - let targets = [ - RegionSelection { - start: (4.0, 4.0), - end: (16.0, 16.0), - }, - RegionSelection { - start: (24.0, 4.0), - end: (36.0, 16.0), - }, - ]; - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 20).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_picker( - &UiTextEngine::default(), - &ctx, - 40, - 20, - &RegionCapturePickerVisual { - selection: None, - pointer: (20.0, 18.0), - measurement: None, - show_scrim: false, - review: false, - resize_handles: None, - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: None, - hovered_action: None, - include_drawings: false, - cut: Default::default(), - window: RegionCaptureWindowVisual { - available: true, - active: true, - targets: &targets, - highlighted_target: Some(1), - }, - }, - |_x, _y| None, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - let edge_alpha = |x: usize| { - (3..=6) - .flat_map(|y| ((x - 1)..=(x + 1)).map(move |sample_x| (sample_x, y))) - .map(|(sample_x, y)| u32::from(data[y * stride + sample_x * 4 + 3])) - .sum::() - }; - assert!( - edge_alpha(24) > edge_alpha(4), - "the highlighted candidate must be visually stronger" - ); - } - - #[test] - fn measure_visual_leaves_the_screen_unscrimmed() { - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_picker( - &UiTextEngine::default(), - &ctx, - 40, - 40, - &RegionCapturePickerVisual { - selection: None, - pointer: (20.0, 20.0), - measurement: Some("20, 20"), - show_scrim: false, - review: false, - resize_handles: None, - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: None, - hovered_action: None, - include_drawings: false, - cut: Default::default(), - window: RegionCaptureWindowVisual::disabled(), - }, - |_x, _y| None, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - assert_eq!(data[2 * stride + 2 * 4 + 3], 0, "no measure scrim"); - assert!(data[20 * stride + 20 * 4 + 3] > 0, "measure crosshair"); - } - - #[test] - fn measure_damage_uses_thin_chrome_regions_instead_of_the_full_surface() { - let damage = measure_picker_damage( - Some(RegionSelection { - start: (100.0, 80.0), - end: (700.0, 500.0), - }), - (400.0, 300.0), - (800, 600), - ); - - assert!(damage.len() >= 7); - assert!(damage.iter().all(|rect| { - rect.x >= 0 - && rect.y >= 0 - && rect.x + rect.width <= 800 - && rect.y + rect.height <= 600 - && (rect.width < 800 || rect.height < 600) - })); - assert!( - damage - .iter() - .any(|rect| rect.width == 800 && rect.height <= 5) - ); - assert!( - damage - .iter() - .any(|rect| rect.height == 600 && rect.width <= 5) - ); - } - - #[test] - fn crosshair_remains_visible_while_selecting() { - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_picker( - &UiTextEngine::default(), - &ctx, - 40, - 40, - &RegionCapturePickerVisual { - selection: Some(RegionSelection { - start: (10.0, 10.0), - end: (30.0, 30.0), - }), - pointer: (20.0, 20.0), - measurement: None, - show_scrim: true, - review: false, - resize_handles: None, - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: None, - hovered_action: None, - include_drawings: false, - cut: Default::default(), - window: RegionCaptureWindowVisual::disabled(), - }, - |_x, _y| None, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - assert!( - data[20 * stride + 20 * 4 + 3] > 0, - "crosshair must be painted inside the clear selection" - ); - } - - #[test] - fn review_visual_composes_the_action_bar_after_the_scrim() { - let selection = RegionSelection { - start: (100.0, 100.0), - end: (300.0, 200.0), - }; - let bar = crate::ui::RegionActionBar::place(selection, (800, 600)); - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_picker( - &UiTextEngine::default(), - &ctx, - 800, - 600, - &RegionCapturePickerVisual { - selection: Some(selection), - pointer: (200.0, 150.0), - measurement: Some("200 × 100"), - show_scrim: true, - review: true, - resize_handles: None, - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: Some(bar), - hovered_action: Some(crate::ui::RegionAction::Both), - include_drawings: true, - cut: Default::default(), - window: RegionCaptureWindowVisual::disabled(), - }, - |_x, _y| None, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - assert!(data[250 * stride + 40 * 4 + 3] > 0, "action bar surface"); - assert_eq!( - data[150 * stride + 200 * 4 + 3], - 0, - "review drops the targeting crosshair; the selection stays clear" - ); - assert!( - data[80 * stride + 110 * 4 + 3] > 0, - "the size badge parks above the selection's top-left corner" - ); - } - - #[test] - fn the_review_size_badge_drops_inside_a_selection_flush_with_the_top_edge() { - let above = selection_badge_layout((40.0, 120.0, 200.0, 100.0), 60.0, None, (400, 300)); - assert_eq!( - above, - PointerPanelLayout { - x: 40.0, - y: 92.0, - width: 76.0, - height: 22.0, - } - ); - - let flush = selection_badge_layout((40.0, 4.0, 200.0, 100.0), 60.0, None, (400, 300)); - assert_eq!( - flush, - PointerPanelLayout { - x: 40.0, - y: 10.0, - width: 76.0, - height: 22.0, - }, - "no room above: the badge drops just inside the rectangle" - ); - - let clamped = selection_badge_layout((380.0, 200.0, 20.0, 20.0), 60.0, None, (400, 300)); - assert_eq!(clamped.x, 318.0, "the badge stays on screen"); - } - - #[test] - fn a_bar_that_flipped_above_the_selection_pushes_the_badge_inside_it() { - // A selection low on the screen leaves no room for the bar below it, - // so the bar takes the space the badge would otherwise use. - let selection = RegionSelection { - start: (730.0, 560.0), - end: (790.0, 590.0), - }; - let bar = RegionActionBar::place(selection, (800, 600)); - let bounds = bar.bounds(); - assert!( - bounds.y + bounds.height < 560.0, - "precondition: the bar flipped above the selection" - ); - - let rect = normalized_rect(selection); - let badge = selection_badge_layout(rect, 60.0, Some(bounds), (800, 600)); - assert!( - !covered_by_action_bar(badge.x, badge.y, badge.width, badge.height, Some(bounds)), - "the badge must not be painted under the bar" - ); - assert!( - badge.y >= 560.0, - "it drops inside the rectangle instead of above it" - ); - - // Without the bar the same selection keeps the outside-above spot. - let unobstructed = selection_badge_layout(rect, 60.0, None, (800, 600)); - assert!(unobstructed.y < 560.0); - } - - /// The layout choice above only helps if the composed frame agrees: the - /// bar is painted after the badge, so a badge under it would simply - /// disappear. - #[test] - fn the_flipped_above_review_bar_never_paints_over_the_size_badge() { - let selection = RegionSelection { - start: (730.0, 560.0), - end: (790.0, 590.0), - }; - let bar = RegionActionBar::place(selection, (800, 600)); - let render = |measurement: Option<&str>| { - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_picker( - &UiTextEngine::default(), - &ctx, - 800, - 600, - &RegionCapturePickerVisual { - selection: Some(selection), - pointer: (760.0, 575.0), - measurement, - show_scrim: true, - review: true, - resize_handles: None, - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: Some(bar), - hovered_action: None, - include_drawings: false, - cut: Default::default(), - window: RegionCaptureWindowVisual::disabled(), - }, - |_x, _y| None, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap().to_vec(); - (data, stride) - }; - - let (with_badge, stride) = render(Some("60 × 30")); - let (without_badge, _) = render(None); - let bounds = bar.bounds(); - let mut visible_badge_pixels = 0usize; - for y in 0..600 { - for x in 0..800 { - let offset = y * stride + x * 4; - if with_badge[offset..offset + 4] == without_badge[offset..offset + 4] { - continue; - } - let inside_bar = (bounds.x..bounds.x + bounds.width).contains(&(x as f64)) - && (bounds.y..bounds.y + bounds.height).contains(&(y as f64)); - if !inside_bar { - visible_badge_pixels += 1; - } - } - } - // The badge box is 76 x 22; essentially all of it must survive. - assert!( - visible_badge_pixels > 1_000, - "only {visible_badge_pixels} badge pixels escaped the action bar" - ); - } - - #[test] - fn a_badge_blocked_on_the_left_slides_to_the_selections_right_edge() { - // A bar clamped over the top-left of a large selection: above and - // inside-left are both covered, inside-right is clear. - let bar = RegionActionRect::new(0.0, 0.0, 300.0, 90.0); - let badge = selection_badge_layout((20.0, 40.0, 600.0, 400.0), 60.0, Some(bar), (800, 600)); - assert!(!covered_by_action_bar( - badge.x, - badge.y, - badge.width, - badge.height, - Some(bar) - )); - assert_eq!(badge.x, 544.0, "right-aligned inside the selection"); - } - - #[test] - fn a_badge_with_no_clear_placement_falls_back_inside_the_selection() { - // A full-width bar over the whole rectangle leaves nothing clear; the - // badge still lands on the selection rather than somewhere arbitrary. - let bar = RegionActionRect::new(0.0, 0.0, 800.0, 600.0); - let rect = (20.0, 40.0, 600.0, 400.0); - assert_eq!( - selection_badge_layout(rect, 60.0, Some(bar), (800, 600)), - PointerPanelLayout { - x: 20.0, - y: 46.0, - width: 76.0, - height: 22.0, - }, - "inside the rectangle's top-left corner is the fallback" - ); - } - - #[test] - fn review_paints_grips_where_targeting_paints_corner_arms() { - let selection = RegionSelection { - start: (60.0, 60.0), - end: (240.0, 200.0), - }; - let render = |review: bool| { - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 300, 260).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_picker( - &UiTextEngine::default(), - &ctx, - 300, - 260, - &RegionCapturePickerVisual { - selection: Some(selection), - // Park the pointer in a corner: targeting still paints a - // crosshair, and its two lines must not cross the pixels - // this test samples. - pointer: (8.0, 252.0), - measurement: None, - // No scrim, so only chrome puts ink on the surface and - // the two frames can be compared pixel for pixel. - show_scrim: false, - review, - resize_handles: review - .then(|| crate::ui::RegionResizeHandles::place(selection)), - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: None, - hovered_action: None, - include_drawings: false, - cut: Default::default(), - window: RegionCaptureWindowVisual::disabled(), - }, - |_x, _y| None, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - (surface.data().unwrap().to_vec(), stride) - }; - - let (reviewing, stride) = render(true); - let (targeting, _) = render(false); - let alpha = |data: &[u8], x: usize, y: usize| data[y * stride + x * 4 + 3]; - - // An edge midpoint carries a grip only in Review, and the chip reaches - // above the frame line the two modes share. - assert!(alpha(&reviewing, 150, 57) > 0, "top edge grip"); - assert_eq!(alpha(&targeting, 150, 57), 0, "targeting has no edge grip"); - assert!(alpha(&reviewing, 60, 60) > 0, "top-left corner grip"); - - // The corner arms run inward along both edges from each corner. Review - // drops them so they cannot fight the corner grip for the same pixels; - // this row is past the grip but still inside the arm's reach. - assert!( - alpha(&targeting, 59, 75) > 0, - "targeting draws a corner arm below the top-left corner" - ); - assert_eq!( - alpha(&reviewing, 59, 75), - 0, - "review drops the arm; the grip covers the corner instead" - ); - } - - #[test] - fn capture_loupe_reuses_the_pixel_loupe_renderer_when_enabled() { - let visual = RegionCaptureLoupeVisual::when_enabled(true, (20.0, 30.0), (50.0, 50.0)) - .expect("enabled immutable option"); - assert!( - RegionCaptureLoupeVisual::when_enabled(false, (20.0, 30.0), (50.0, 50.0),).is_none() - ); - - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 200).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_loupe(&ctx, (200, 200), visual, |_x, _y| { - Some(crate::draw::Color::new(1.0, 0.0, 0.0, 1.0)) - }); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - assert!(data[98 * stride + 88 * 4 + 3] > 0, "loupe center pixel"); - assert_eq!(data[5 * stride + 5 * 4 + 3], 0, "outside untouched"); - } - - #[test] - fn accepted_cut_preview_paints_before_the_scrim_hole() { - let pixels = PackedArgb32::new( - 2, - 1, - 8, - [0x33, 0x22, 0x11, 0xFF, 0xCC, 0xBB, 0xAA, 0xFF].to_vec(), - ) - .unwrap(); - let created = unsafe { - cairo::ImageSurface::create_for_data_unsafe( - pixels.data().as_ptr() as *mut u8, - cairo::Format::ARgb32, - 2, - 1, - 8, - ) - }; - assert!( - created.is_ok(), - "preview pixels must be a valid Cairo source: {created:?}" - ); - drop(created); - // Large enough that the 4–20px corner arms cannot cover the samples, - // and far enough from the 1px frame. 2×1 source scales 16× onto this - // 32×16 display: (18, 18) is inside the first source pixel, (34, 18) - // inside the second. - let display = RegionSelection { - start: (10.0, 10.0), - end: (42.0, 26.0), - }; - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 60, 40).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_capture_picker( - &UiTextEngine::default(), - &ctx, - 60, - 40, - &RegionCapturePickerVisual { - selection: Some(display), - pointer: (4.0, 4.0), - measurement: None, - show_scrim: true, - review: true, - resize_handles: None, - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: None, - hovered_action: None, - include_drawings: false, - cut: RegionCaptureCutVisual { - preview: Some(RegionCutPreviewVisual { - pixels: &pixels, - display, - }), - ..Default::default() - }, - window: RegionCaptureWindowVisual::disabled(), - }, - |_x, _y| None, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; - assert!(alpha(18, 18) > 0, "preview occupies the displayed output"); - assert!(alpha(2, 2) > 0, "vacated source is dimmed by the scrim"); - assert_eq!( - &data[18 * stride + 18 * 4..18 * stride + 18 * 4 + 4], - &[0x33, 0x22, 0x11, 0xFF], - "first source pixel fills the left half of the displayed output" - ); - assert_eq!( - &data[18 * stride + 34 * 4..18 * stride + 34 * 4 + 4], - &[0xCC, 0xBB, 0xAA, 0xFF], - "second source pixel fills the right half of the displayed output" - ); - } - #[test] - fn retained_text_owner_matches_fresh_picker_pixels_across_modes_and_density() { - let engine = UiTextEngine::default(); - let selection = RegionSelection { - start: (90.0, 70.0), - end: (480.0, 220.0), - }; - for density in [1, 2, 1] { - for (review, show_scrim) in [(false, true), (true, true), (false, false)] { - let paint = |engine: &UiTextEngine| { - let mut surface = cairo::ImageSurface::create( - cairo::Format::ARgb32, - 640 * density, - 480 * density, - ) - .unwrap(); - { - let ctx = cairo::Context::new(&surface).unwrap(); - ctx.scale(f64::from(density), f64::from(density)); - render_region_capture_picker( - engine, - &ctx, - 640, - 480, - &RegionCapturePickerVisual { - selection: Some(selection), - pointer: (480.0, 220.0), - measurement: Some("390 × 150"), - show_scrim, - review, - resize_handles: None, - hovered_handle: None, - show_legend: false, - loupe: None, - action_bar: review - .then(|| RegionActionBar::place(selection, (640, 480))), - hovered_action: None, - include_drawings: true, - cut: Default::default(), - window: RegionCaptureWindowVisual::disabled(), - }, - |_, _| None, - ); - render_region_legend(engine, &ctx, (640, 480), OCR_LEGEND_TEXT); - } - surface.data().unwrap().to_vec() - }; - let actual = paint(&engine); - assert!(actual.iter().any(|&byte| byte != 0)); - assert!( - actual == paint(&UiTextEngine::default()), - "retained picker pixels differ" - ); - } - } - } -} +mod tests; diff --git a/src/ui/region_capture_picker/cut.rs b/src/ui/region_capture_picker/cut.rs new file mode 100644 index 000000000..5b5b9e55b --- /dev/null +++ b/src/ui/region_capture_picker/cut.rs @@ -0,0 +1,81 @@ +use super::layout::normalized_rect; +use super::types::{RegionCutDragVisual, RegionCutPreviewVisual}; +use crate::capture::CutAxis; + +pub(super) fn paint_cut_preview(ctx: &cairo::Context, preview: RegionCutPreviewVisual<'_>) { + let pixels = preview.pixels; + let Ok(width) = i32::try_from(pixels.width()) else { + return; + }; + let Ok(height) = i32::try_from(pixels.height()) else { + return; + }; + if width <= 0 || height <= 0 { + return; + } + let (x, y, display_width, display_height) = normalized_rect(preview.display); + if display_width <= 0.0 || display_height <= 0.0 { + return; + } + // SAFETY: Cairo borrows `pixels.data` for this surface. The buffer is + // owned by the Review preview and stays alive until the surface is + // dropped at the end of this function. The API wants `*mut u8` even + // though this path only reads pixels; we never write through the + // pointer, and no other alias mutates the buffer while Cairo holds it. + let surface = unsafe { + cairo::ImageSurface::create_for_data_unsafe( + pixels.data().as_ptr() as *mut u8, + cairo::Format::ARgb32, + width, + height, + pixels.stride(), + ) + }; + let Ok(surface) = surface else { + return; + }; + let _ = ctx.save(); + ctx.rectangle(x, y, display_width, display_height); + ctx.clip(); + ctx.translate(x, y); + ctx.scale( + display_width / f64::from(pixels.width()), + display_height / f64::from(pixels.height()), + ); + // Place the surface in the translated/scaled user space, matching the + // frozen-backdrop path: the CTM maps one source pixel onto one displayed + // output pixel, and nearest-neighbor keeps cut seams crisp. + if ctx.set_source_surface(&surface, 0.0, 0.0).is_ok() { + ctx.source().set_filter(cairo::Filter::Nearest); + ctx.source().set_extend(cairo::Extend::None); + let _ = ctx.paint(); + } + let _ = ctx.restore(); +} + +pub(super) fn draw_cut_drag(ctx: &cairo::Context, drag: RegionCutDragVisual) { + let (x, y, width, height) = normalized_rect(drag.band); + if width <= 0.0 || height <= 0.0 { + return; + } + ctx.set_source_rgba(0.05, 0.08, 0.14, 0.48); + ctx.rectangle(x, y, width, height); + let _ = ctx.fill(); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.92); + ctx.set_line_width(1.0); + match drag.axis { + CutAxis::Columns => { + ctx.move_to(x + 0.5, y); + ctx.line_to(x + 0.5, y + height); + ctx.move_to(x + width - 0.5, y); + ctx.line_to(x + width - 0.5, y + height); + } + CutAxis::Rows => { + ctx.move_to(x, y + 0.5); + ctx.line_to(x + width, y + 0.5); + ctx.move_to(x, y + height - 0.5); + ctx.line_to(x + width, y + height - 0.5); + } + } + let _ = ctx.stroke(); +} diff --git a/src/ui/region_capture_picker/layout.rs b/src/ui/region_capture_picker/layout.rs new file mode 100644 index 000000000..0ffaefbf3 --- /dev/null +++ b/src/ui/region_capture_picker/layout.rs @@ -0,0 +1,219 @@ +use crate::input::state::RegionSelection; +use crate::ui::region_action_bar::RegionActionRect; +use crate::util::Rect; + +const POINTER_GAP: f64 = 15.0; +/// Gap between the reviewed selection and its size badge. The badge is parked +/// on the selection during Review instead of trailing the pointer, so a +/// finished rectangle stops behaving like one that is still being dragged. +const SELECTION_BADGE_GAP: f64 = 6.0; +const PANEL_MARGIN: f64 = 6.0; +pub(super) const PANEL_PADDING_X: f64 = 8.0; +const PANEL_HEIGHT: f64 = 22.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct PointerPanelLayout { + pub(super) x: f64, + pub(super) y: f64, + pub(super) width: f64, + pub(super) height: f64, +} + +pub(crate) fn capture_size_text(size: (u32, u32)) -> String { + format!("{} × {}", size.0, size.1) +} + +/// Conservative targeted damage for Measure Mode's chrome. The crosshair is +/// represented as two thin strips; the selection as four edge strips; and the +/// pointer readout by a bounded box covering every flip direction. This avoids +/// the capture picker's full-surface scrim damage without leaving trails. +pub(crate) fn measure_picker_damage( + selection: Option, + pointer: (f64, f64), + screen: (u32, u32), +) -> Vec { + let width = screen.0.min(i32::MAX as u32) as i32; + let height = screen.1.min(i32::MAX as u32) as i32; + if width <= 0 || height <= 0 { + return Vec::new(); + } + let x = pointer.0.round().clamp(0.0, f64::from(width - 1)) as i32; + let y = pointer.1.round().clamp(0.0, f64::from(height - 1)) as i32; + let mut damage = Vec::with_capacity(7); + push_clipped_damage(&mut damage, x - 2, 0, 5, height, width, height); + push_clipped_damage(&mut damage, 0, y - 2, width, 5, width, height); + // The monospace readout is short, but cover both horizontal and vertical + // flip choices so the damage remains correct without a Cairo text pass. + push_clipped_damage(&mut damage, x - 240, y - 48, 480, 96, width, height); + + if let Some(selection) = selection { + let min_x = selection.start.0.min(selection.end.0).floor() as i32; + let min_y = selection.start.1.min(selection.end.1).floor() as i32; + let max_x = selection.start.0.max(selection.end.0).ceil() as i32; + let max_y = selection.start.1.max(selection.end.1).ceil() as i32; + let rect_width = max_x.saturating_sub(min_x); + let rect_height = max_y.saturating_sub(min_y); + push_clipped_damage( + &mut damage, + min_x - 4, + min_y - 4, + rect_width + 8, + 8, + width, + height, + ); + push_clipped_damage( + &mut damage, + min_x - 4, + max_y - 4, + rect_width + 8, + 8, + width, + height, + ); + push_clipped_damage( + &mut damage, + min_x - 4, + min_y - 4, + 8, + rect_height + 8, + width, + height, + ); + push_clipped_damage( + &mut damage, + max_x - 4, + min_y - 4, + 8, + rect_height + 8, + width, + height, + ); + } + damage +} + +fn push_clipped_damage( + damage: &mut Vec, + x: i32, + y: i32, + width: i32, + height: i32, + screen_width: i32, + screen_height: i32, +) { + let min_x = x.clamp(0, screen_width); + let min_y = y.clamp(0, screen_height); + let max_x = x.saturating_add(width).clamp(0, screen_width); + let max_y = y.saturating_add(height).clamp(0, screen_height); + if let Some(rect) = Rect::from_min_max(min_x, min_y, max_x, max_y) { + damage.push(rect); + } +} + +pub(super) fn normalized_rect(selection: RegionSelection) -> (f64, f64, f64, f64) { + let x = selection.start.0.min(selection.end.0); + let y = selection.start.1.min(selection.end.1); + ( + x, + y, + (selection.end.0 - selection.start.0).abs(), + (selection.end.1 - selection.start.1).abs(), + ) +} + +pub(super) fn pointer_panel_layout( + pointer: (f64, f64), + text_width: f64, + screen: (u32, u32), +) -> PointerPanelLayout { + let screen_width = f64::from(screen.0); + let screen_height = f64::from(screen.1); + let width = + (text_width + PANEL_PADDING_X * 2.0).min((screen_width - PANEL_MARGIN * 2.0).max(0.0)); + let height = PANEL_HEIGHT.min((screen_height - PANEL_MARGIN * 2.0).max(0.0)); + let mut x = pointer.0 + POINTER_GAP; + let mut y = pointer.1 + POINTER_GAP; + if x + width + PANEL_MARGIN > screen_width { + x = pointer.0 - POINTER_GAP - width; + } + if y + height + PANEL_MARGIN > screen_height { + y = pointer.1 - POINTER_GAP - height; + } + PointerPanelLayout { + x: x.clamp( + PANEL_MARGIN, + (screen_width - width - PANEL_MARGIN).max(PANEL_MARGIN), + ), + y: y.clamp( + PANEL_MARGIN, + (screen_height - height - PANEL_MARGIN).max(PANEL_MARGIN), + ), + width, + height, + } +} + +/// Park the size badge on the selection's top-left corner. The action bar is +/// painted after the badge, and it flips above the selection when it does not +/// fit below, so each placement is checked against the bar and skipped rather +/// than drawn under it. Candidates run outside-above, inside-top-left, +/// inside-top-right; inside-top-left is the fallback when a clamped bar covers +/// all three. +pub(super) fn selection_badge_layout( + rect: (f64, f64, f64, f64), + text_width: f64, + action_bar: Option, + screen: (u32, u32), +) -> PointerPanelLayout { + let screen_width = f64::from(screen.0); + let screen_height = f64::from(screen.1); + let width = + (text_width + PANEL_PADDING_X * 2.0).min((screen_width - PANEL_MARGIN * 2.0).max(0.0)); + let height = PANEL_HEIGHT.min((screen_height - PANEL_MARGIN * 2.0).max(0.0)); + let (rect_x, rect_y, rect_width, ..) = rect; + let clamp_x = |x: f64| { + x.clamp( + PANEL_MARGIN, + (screen_width - width - PANEL_MARGIN).max(PANEL_MARGIN), + ) + }; + let clamp_y = |y: f64| { + y.clamp( + PANEL_MARGIN, + (screen_height - height - PANEL_MARGIN).max(PANEL_MARGIN), + ) + }; + let left = clamp_x(rect_x); + let right = clamp_x(rect_x + rect_width - width); + let inside = clamp_y(rect_y + SELECTION_BADGE_GAP); + let above = rect_y - SELECTION_BADGE_GAP - height; + let fallback = (left, inside); + let (x, y) = [ + (above >= PANEL_MARGIN).then(|| (left, clamp_y(above))), + Some(fallback), + Some((right, inside)), + ] + .into_iter() + .flatten() + .find(|&(x, y)| !covered_by_action_bar(x, y, width, height, action_bar)) + .unwrap_or(fallback); + PointerPanelLayout { + x, + y, + width, + height, + } +} + +pub(super) fn covered_by_action_bar( + x: f64, + y: f64, + width: f64, + height: f64, + action_bar: Option, +) -> bool { + action_bar.is_some_and(|bar| { + x < bar.x + bar.width && bar.x < x + width && y < bar.y + bar.height && bar.y < y + height + }) +} diff --git a/src/ui/region_capture_picker/legend.rs b/src/ui/region_capture_picker/legend.rs new file mode 100644 index 000000000..9c9fea7d4 --- /dev/null +++ b/src/ui/region_capture_picker/legend.rs @@ -0,0 +1,69 @@ +use super::types::RegionCaptureWindowVisual; +use super::{PANEL_FILL, PANEL_RADIUS}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; +use crate::ui_text::UiTextEngine; + +const LEGEND_FONT_SIZE: f64 = 12.0; +pub(super) const AREA_LEGEND_TEXT: &str = + "Drag to select Shift: square Ctrl+A: all Esc: cancel"; +pub(super) const AREA_WITH_WINDOWS_LEGEND_TEXT: &str = + "Drag to select Shift: square Ctrl+A: all Space: window Esc: cancel"; +/// Recognition offers no square modifier, and `Ctrl+A` reads everything rather +/// than selecting everything, so it says what it does rather than borrowing +/// the capture picker's wording. +pub(crate) const OCR_LEGEND_TEXT: &str = "Drag to read text Ctrl+A: whole screen Esc: cancel"; +const WINDOW_LEGEND_TEXT: &str = + "Click: select Super+Arrows: choose Enter: select Space: area Esc: cancel"; + +pub(super) fn picker_legend_text(window: RegionCaptureWindowVisual<'_>) -> &'static str { + if window.active { + WINDOW_LEGEND_TEXT + } else if window.available { + AREA_WITH_WINDOWS_LEGEND_TEXT + } else { + AREA_LEGEND_TEXT + } +} + +/// The hint strip along the top of a region selector. Shared so every selector +/// teaches its keys the same way and in the same place. +pub(crate) fn render_region_legend( + engine: &UiTextEngine, + ctx: &cairo::Context, + screen: (u32, u32), + text: &str, +) { + let extents = text_extents_for_with_engine( + engine, + ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + LEGEND_FONT_SIZE, + text, + ); + let screen_width = f64::from(screen.0); + let screen_height = f64::from(screen.1); + let width = (extents.width() + 24.0).min((screen_width - 12.0).max(0.0)); + let height = 28.0_f64.min((screen_height - 12.0).max(0.0)); + if width <= 0.0 || height <= 0.0 { + return; + } + let x = ((screen_width - width) / 2.0).max(6.0); + let y = 12.0_f64.min((screen_height - height).max(0.0)); + let radius = PANEL_RADIUS.min(width / 2.0).min(height / 2.0); + ctx.set_source_rgba(PANEL_FILL.0, PANEL_FILL.1, PANEL_FILL.2, PANEL_FILL.3); + draw_rounded_rect(ctx, x, y, width, height, radius); + let _ = ctx.fill(); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.88); + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + ctx.set_font_size(LEGEND_FONT_SIZE); + let text_x = x + ((width - extents.width()) / 2.0).max(6.0) - extents.x_bearing(); + let baseline = y + (height - extents.height()) / 2.0 - extents.y_bearing(); + let _ = ctx.save(); + ctx.rectangle(x, y, width, height); + ctx.clip(); + ctx.move_to(text_x, baseline); + let _ = ctx.show_text(text); + let _ = ctx.restore(); +} diff --git a/src/ui/region_capture_picker/readout.rs b/src/ui/region_capture_picker/readout.rs new file mode 100644 index 000000000..30232ec3a --- /dev/null +++ b/src/ui/region_capture_picker/readout.rs @@ -0,0 +1,77 @@ +use super::layout::{PANEL_PADDING_X, pointer_panel_layout, selection_badge_layout}; +use super::{PANEL_FILL, PANEL_RADIUS}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; +use crate::ui::region_action_bar::RegionActionRect; +use crate::ui_text::UiTextEngine; + +const PANEL_BORDER: (f64, f64, f64, f64) = (1.0, 1.0, 1.0, 0.16); +pub(super) const READOUT_FONT_SIZE: f64 = 12.0; + +/// The measurement chip. `selection`, when present, anchors it to that +/// rectangle; otherwise it trails the pointer. +#[allow(clippy::too_many_arguments)] +pub(super) fn draw_readout_panel( + engine: &UiTextEngine, + ctx: &cairo::Context, + text: &str, + font_size: f64, + pointer: (f64, f64), + selection: Option<(f64, f64, f64, f64)>, + action_bar: Option, + screen: (u32, u32), + weight: cairo::FontWeight, +) { + let extents = text_extents_for_with_engine( + engine, + ctx, + "monospace", + cairo::FontSlant::Normal, + weight, + font_size, + text, + ); + let layout = match selection { + Some(rect) => selection_badge_layout(rect, extents.width(), action_bar, screen), + None => pointer_panel_layout(pointer, extents.width(), screen), + }; + if layout.width <= 0.0 || layout.height <= 0.0 { + return; + } + ctx.set_source_rgba(PANEL_FILL.0, PANEL_FILL.1, PANEL_FILL.2, PANEL_FILL.3); + let radius = PANEL_RADIUS + .min(layout.width / 2.0) + .min(layout.height / 2.0); + draw_rounded_rect(ctx, layout.x, layout.y, layout.width, layout.height, radius); + let _ = ctx.fill(); + ctx.set_source_rgba( + PANEL_BORDER.0, + PANEL_BORDER.1, + PANEL_BORDER.2, + PANEL_BORDER.3, + ); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + layout.x + 0.5, + layout.y + 0.5, + layout.width - 1.0, + layout.height - 1.0, + (radius - 0.5).max(0.0), + ); + let _ = ctx.stroke(); + + ctx.set_source_rgb(1.0, 1.0, 1.0); + ctx.select_font_face( + "monospace", + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); + ctx.set_font_size(font_size); + let baseline = layout.y + (layout.height - extents.height()) / 2.0 - extents.y_bearing(); + let _ = ctx.save(); + ctx.rectangle(layout.x, layout.y, layout.width, layout.height); + ctx.clip(); + ctx.move_to(layout.x + PANEL_PADDING_X - extents.x_bearing(), baseline); + let _ = ctx.show_text(text); + let _ = ctx.restore(); +} diff --git a/src/ui/region_capture_picker/selection.rs b/src/ui/region_capture_picker/selection.rs new file mode 100644 index 000000000..f35d18977 --- /dev/null +++ b/src/ui/region_capture_picker/selection.rs @@ -0,0 +1,82 @@ +use super::layout::normalized_rect; +use super::types::RegionCaptureWindowVisual; +use crate::input::state::RegionSelection; + +const SCRIM: (f64, f64, f64, f64) = (0.02, 0.03, 0.05, 0.48); + +pub(super) fn draw_scrim( + ctx: &cairo::Context, + width: f64, + height: f64, + effective_selection: Option, +) { + ctx.set_source_rgba(SCRIM.0, SCRIM.1, SCRIM.2, SCRIM.3); + ctx.rectangle(0.0, 0.0, width, height); + if let Some(selection) = effective_selection { + let (x, y, w, h) = normalized_rect(selection); + ctx.rectangle(x, y, w, h); + ctx.set_fill_rule(cairo::FillRule::EvenOdd); + } + let _ = ctx.fill(); + ctx.set_fill_rule(cairo::FillRule::Winding); +} + +pub(super) fn draw_selection_frame( + ctx: &cairo::Context, + x: f64, + y: f64, + w: f64, + h: f64, + corner_arms: bool, +) { + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.95); + ctx.set_line_width(1.0); + ctx.rectangle(x + 0.5, y + 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0)); + let _ = ctx.stroke(); + + if !corner_arms { + return; + } + let arm = (w.min(h) / 4.0).clamp(4.0, 20.0); + ctx.set_line_width(2.0); + ctx.set_line_cap(cairo::LineCap::Square); + for (corner_x, corner_y, dx, dy) in [ + (x, y, 1.0, 1.0), + (x + w, y, -1.0, 1.0), + (x, y + h, 1.0, -1.0), + (x + w, y + h, -1.0, -1.0), + ] { + ctx.move_to(corner_x + dx * arm, corner_y); + ctx.line_to(corner_x, corner_y); + ctx.line_to(corner_x, corner_y + dy * arm); + let _ = ctx.stroke(); + } +} + +pub(super) fn draw_window_target_frames( + ctx: &cairo::Context, + window: RegionCaptureWindowVisual<'_>, +) { + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.34); + ctx.set_line_width(1.0); + for target in window.targets { + let (x, y, width, height) = normalized_rect(*target); + ctx.rectangle( + x + 0.5, + y + 0.5, + (width - 1.0).max(0.0), + (height - 1.0).max(0.0), + ); + let _ = ctx.stroke(); + } +} + +pub(super) fn draw_crosshair(ctx: &cairo::Context, pointer: (f64, f64), screen: (f64, f64)) { + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.22); + ctx.set_line_width(1.0); + ctx.move_to(0.0, pointer.1 + 0.5); + ctx.line_to(screen.0, pointer.1 + 0.5); + ctx.move_to(pointer.0 + 0.5, 0.0); + ctx.line_to(pointer.0 + 0.5, screen.1); + let _ = ctx.stroke(); +} diff --git a/src/ui/region_capture_picker/tests.rs b/src/ui/region_capture_picker/tests.rs new file mode 100644 index 000000000..052b0eca8 --- /dev/null +++ b/src/ui/region_capture_picker/tests.rs @@ -0,0 +1,835 @@ +use super::layout::{ + PointerPanelLayout, covered_by_action_bar, pointer_panel_layout, selection_badge_layout, +}; +use super::legend::{AREA_LEGEND_TEXT, AREA_WITH_WINDOWS_LEGEND_TEXT}; +use super::*; +use crate::input::state::RegionSelection; +use crate::screen_pixels::PackedArgb32; +use crate::ui::region_action_bar::RegionActionRect; + +#[test] +fn size_readout_uses_export_pixel_units_and_multiplication_sign() { + assert_eq!(capture_size_text((0, 0)), "0 × 0"); + assert_eq!(capture_size_text((900, 620)), "900 × 620"); +} + +#[test] +fn legend_only_advertises_supported_window_controls() { + assert_eq!( + picker_legend_text(RegionCaptureWindowVisual { + available: false, + active: false, + targets: &[], + highlighted_target: None, + }), + "Drag to select Shift: square Ctrl+A: all Esc: cancel" + ); + assert_eq!( + picker_legend_text(RegionCaptureWindowVisual { + available: true, + active: false, + targets: &[], + highlighted_target: None, + }), + "Drag to select Shift: square Ctrl+A: all Space: window Esc: cancel" + ); + assert_eq!( + picker_legend_text(RegionCaptureWindowVisual { + available: true, + active: true, + targets: &[], + highlighted_target: None, + }), + "Click: select Super+Arrows: choose Enter: select Space: area Esc: cancel" + ); +} + +#[test] +fn every_selector_legend_names_the_keys_that_selector_actually_has() { + // Recognition has no square modifier, and its select-all reads rather + // than selects, so it must not borrow the capture wording. + assert!(OCR_LEGEND_TEXT.contains("Ctrl+A")); + assert!( + !OCR_LEGEND_TEXT.contains("Shift"), + "recognition offers no square modifier: {OCR_LEGEND_TEXT}" + ); + for legend in [ + AREA_LEGEND_TEXT, + AREA_WITH_WINDOWS_LEGEND_TEXT, + OCR_LEGEND_TEXT, + ] { + assert!(legend.contains("Esc"), "every selector says how to leave"); + } +} + +#[test] +fn the_shared_legend_paints_across_the_top_of_any_selector() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 400).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_legend(&UiTextEngine::default(), &ctx, (800, 400), OCR_LEGEND_TEXT); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; + + assert!(alpha(400, 24) > 0, "the strip sits along the top edge"); + assert_eq!(alpha(400, 300), 0, "and nowhere else"); +} + +#[test] +fn pointer_panel_prefers_below_right_then_flips_and_clamps() { + assert_eq!( + pointer_panel_layout((20.0, 30.0), 60.0, (400, 300)), + PointerPanelLayout { + x: 35.0, + y: 45.0, + width: 76.0, + height: 22.0, + } + ); + assert_eq!( + pointer_panel_layout((390.0, 290.0), 60.0, (400, 300)), + PointerPanelLayout { + x: 299.0, + y: 253.0, + width: 76.0, + height: 22.0, + } + ); + assert_eq!( + pointer_panel_layout((2.0, 2.0), 120.0, (80, 20)), + PointerPanelLayout { + x: 6.0, + y: 6.0, + width: 68.0, + height: 8.0, + } + ); +} + +#[test] +fn selected_area_is_cut_out_of_the_scrim() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &UiTextEngine::default(), + &ctx, + 40, + 40, + &RegionCapturePickerVisual { + selection: Some(RegionSelection { + start: (10.0, 10.0), + end: (30.0, 30.0), + }), + pointer: (30.0, 30.0), + measurement: None, + show_scrim: true, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + cut: Default::default(), + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; + assert!(alpha(2, 2) > 0, "outside the selection must be scrimmed"); + assert_eq!(alpha(20, 20), 0, "the selected pixels must remain clear"); +} + +#[test] +fn highlighted_window_is_cut_out_in_window_mode() { + let targets = [ + RegionSelection { + start: (4.0, 4.0), + end: (14.0, 14.0), + }, + RegionSelection { + start: (20.0, 20.0), + end: (36.0, 36.0), + }, + ]; + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &UiTextEngine::default(), + &ctx, + 40, + 40, + &RegionCapturePickerVisual { + selection: None, + pointer: (28.0, 28.0), + measurement: None, + show_scrim: true, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + cut: Default::default(), + window: RegionCaptureWindowVisual { + available: true, + active: true, + targets: &targets, + highlighted_target: Some(1), + }, + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; + assert!(alpha(1, 1) > 0, "outside windows remains scrimmed"); + assert!(alpha(9, 9) > 0, "an unhighlighted window remains scrimmed"); + assert_eq!(alpha(28, 28), 0, "highlighted window is the clear target"); +} + +#[test] +fn window_mode_omits_the_area_crosshair() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &UiTextEngine::default(), + &ctx, + 40, + 40, + &RegionCapturePickerVisual { + selection: None, + pointer: (20.0, 20.0), + measurement: None, + show_scrim: false, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + cut: Default::default(), + window: RegionCaptureWindowVisual { + available: true, + active: true, + targets: &[], + highlighted_target: None, + }, + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert_eq!(data[20 * stride + 20 * 4 + 3], 0); +} + +#[test] +fn highlighted_window_outline_is_stronger_than_other_targets() { + let targets = [ + RegionSelection { + start: (4.0, 4.0), + end: (16.0, 16.0), + }, + RegionSelection { + start: (24.0, 4.0), + end: (36.0, 16.0), + }, + ]; + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 20).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &UiTextEngine::default(), + &ctx, + 40, + 20, + &RegionCapturePickerVisual { + selection: None, + pointer: (20.0, 18.0), + measurement: None, + show_scrim: false, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + cut: Default::default(), + window: RegionCaptureWindowVisual { + available: true, + active: true, + targets: &targets, + highlighted_target: Some(1), + }, + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let edge_alpha = |x: usize| { + (3..=6) + .flat_map(|y| ((x - 1)..=(x + 1)).map(move |sample_x| (sample_x, y))) + .map(|(sample_x, y)| u32::from(data[y * stride + sample_x * 4 + 3])) + .sum::() + }; + assert!( + edge_alpha(24) > edge_alpha(4), + "the highlighted candidate must be visually stronger" + ); +} + +#[test] +fn measure_visual_leaves_the_screen_unscrimmed() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &UiTextEngine::default(), + &ctx, + 40, + 40, + &RegionCapturePickerVisual { + selection: None, + pointer: (20.0, 20.0), + measurement: Some("20, 20"), + show_scrim: false, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + cut: Default::default(), + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert_eq!(data[2 * stride + 2 * 4 + 3], 0, "no measure scrim"); + assert!(data[20 * stride + 20 * 4 + 3] > 0, "measure crosshair"); +} + +#[test] +fn measure_damage_uses_thin_chrome_regions_instead_of_the_full_surface() { + let damage = measure_picker_damage( + Some(RegionSelection { + start: (100.0, 80.0), + end: (700.0, 500.0), + }), + (400.0, 300.0), + (800, 600), + ); + + assert!(damage.len() >= 7); + assert!(damage.iter().all(|rect| { + rect.x >= 0 + && rect.y >= 0 + && rect.x + rect.width <= 800 + && rect.y + rect.height <= 600 + && (rect.width < 800 || rect.height < 600) + })); + assert!( + damage + .iter() + .any(|rect| rect.width == 800 && rect.height <= 5) + ); + assert!( + damage + .iter() + .any(|rect| rect.height == 600 && rect.width <= 5) + ); +} + +#[test] +fn crosshair_remains_visible_while_selecting() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &UiTextEngine::default(), + &ctx, + 40, + 40, + &RegionCapturePickerVisual { + selection: Some(RegionSelection { + start: (10.0, 10.0), + end: (30.0, 30.0), + }), + pointer: (20.0, 20.0), + measurement: None, + show_scrim: true, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + cut: Default::default(), + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert!( + data[20 * stride + 20 * 4 + 3] > 0, + "crosshair must be painted inside the clear selection" + ); +} + +#[test] +fn review_visual_composes_the_action_bar_after_the_scrim() { + let selection = RegionSelection { + start: (100.0, 100.0), + end: (300.0, 200.0), + }; + let bar = crate::ui::RegionActionBar::place(selection, (800, 600)); + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &UiTextEngine::default(), + &ctx, + 800, + 600, + &RegionCapturePickerVisual { + selection: Some(selection), + pointer: (200.0, 150.0), + measurement: Some("200 × 100"), + show_scrim: true, + review: true, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: Some(bar), + hovered_action: Some(crate::ui::RegionAction::Both), + include_drawings: true, + cut: Default::default(), + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert!(data[250 * stride + 40 * 4 + 3] > 0, "action bar surface"); + assert_eq!( + data[150 * stride + 200 * 4 + 3], + 0, + "review drops the targeting crosshair; the selection stays clear" + ); + assert!( + data[80 * stride + 110 * 4 + 3] > 0, + "the size badge parks above the selection's top-left corner" + ); +} + +#[test] +fn the_review_size_badge_drops_inside_a_selection_flush_with_the_top_edge() { + let above = selection_badge_layout((40.0, 120.0, 200.0, 100.0), 60.0, None, (400, 300)); + assert_eq!( + above, + PointerPanelLayout { + x: 40.0, + y: 92.0, + width: 76.0, + height: 22.0, + } + ); + + let flush = selection_badge_layout((40.0, 4.0, 200.0, 100.0), 60.0, None, (400, 300)); + assert_eq!( + flush, + PointerPanelLayout { + x: 40.0, + y: 10.0, + width: 76.0, + height: 22.0, + }, + "no room above: the badge drops just inside the rectangle" + ); + + let clamped = selection_badge_layout((380.0, 200.0, 20.0, 20.0), 60.0, None, (400, 300)); + assert_eq!(clamped.x, 318.0, "the badge stays on screen"); +} + +#[test] +fn a_bar_that_flipped_above_the_selection_pushes_the_badge_inside_it() { + // A selection low on the screen leaves no room for the bar below it, + // so the bar takes the space the badge would otherwise use. + let selection = RegionSelection { + start: (730.0, 560.0), + end: (790.0, 590.0), + }; + let bar = RegionActionBar::place(selection, (800, 600)); + let bounds = bar.bounds(); + assert!( + bounds.y + bounds.height < 560.0, + "precondition: the bar flipped above the selection" + ); + + let rect = normalized_rect(selection); + let badge = selection_badge_layout(rect, 60.0, Some(bounds), (800, 600)); + assert!( + !covered_by_action_bar(badge.x, badge.y, badge.width, badge.height, Some(bounds)), + "the badge must not be painted under the bar" + ); + assert!( + badge.y >= 560.0, + "it drops inside the rectangle instead of above it" + ); + + // Without the bar the same selection keeps the outside-above spot. + let unobstructed = selection_badge_layout(rect, 60.0, None, (800, 600)); + assert!(unobstructed.y < 560.0); +} + +/// The layout choice above only helps if the composed frame agrees: the +/// bar is painted after the badge, so a badge under it would simply +/// disappear. +#[test] +fn the_flipped_above_review_bar_never_paints_over_the_size_badge() { + let selection = RegionSelection { + start: (730.0, 560.0), + end: (790.0, 590.0), + }; + let bar = RegionActionBar::place(selection, (800, 600)); + let render = |measurement: Option<&str>| { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &UiTextEngine::default(), + &ctx, + 800, + 600, + &RegionCapturePickerVisual { + selection: Some(selection), + pointer: (760.0, 575.0), + measurement, + show_scrim: true, + review: true, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: Some(bar), + hovered_action: None, + include_drawings: false, + cut: Default::default(), + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap().to_vec(); + (data, stride) + }; + + let (with_badge, stride) = render(Some("60 × 30")); + let (without_badge, _) = render(None); + let bounds = bar.bounds(); + let mut visible_badge_pixels = 0usize; + for y in 0..600 { + for x in 0..800 { + let offset = y * stride + x * 4; + if with_badge[offset..offset + 4] == without_badge[offset..offset + 4] { + continue; + } + let inside_bar = (bounds.x..bounds.x + bounds.width).contains(&(x as f64)) + && (bounds.y..bounds.y + bounds.height).contains(&(y as f64)); + if !inside_bar { + visible_badge_pixels += 1; + } + } + } + // The badge box is 76 x 22; essentially all of it must survive. + assert!( + visible_badge_pixels > 1_000, + "only {visible_badge_pixels} badge pixels escaped the action bar" + ); +} + +#[test] +fn a_badge_blocked_on_the_left_slides_to_the_selections_right_edge() { + // A bar clamped over the top-left of a large selection: above and + // inside-left are both covered, inside-right is clear. + let bar = RegionActionRect::new(0.0, 0.0, 300.0, 90.0); + let badge = selection_badge_layout((20.0, 40.0, 600.0, 400.0), 60.0, Some(bar), (800, 600)); + assert!(!covered_by_action_bar( + badge.x, + badge.y, + badge.width, + badge.height, + Some(bar) + )); + assert_eq!(badge.x, 544.0, "right-aligned inside the selection"); +} + +#[test] +fn a_badge_with_no_clear_placement_falls_back_inside_the_selection() { + // A full-width bar over the whole rectangle leaves nothing clear; the + // badge still lands on the selection rather than somewhere arbitrary. + let bar = RegionActionRect::new(0.0, 0.0, 800.0, 600.0); + let rect = (20.0, 40.0, 600.0, 400.0); + assert_eq!( + selection_badge_layout(rect, 60.0, Some(bar), (800, 600)), + PointerPanelLayout { + x: 20.0, + y: 46.0, + width: 76.0, + height: 22.0, + }, + "inside the rectangle's top-left corner is the fallback" + ); +} + +#[test] +fn review_paints_grips_where_targeting_paints_corner_arms() { + let selection = RegionSelection { + start: (60.0, 60.0), + end: (240.0, 200.0), + }; + let render = |review: bool| { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 300, 260).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &UiTextEngine::default(), + &ctx, + 300, + 260, + &RegionCapturePickerVisual { + selection: Some(selection), + // Park the pointer in a corner: targeting still paints a + // crosshair, and its two lines must not cross the pixels + // this test samples. + pointer: (8.0, 252.0), + measurement: None, + // No scrim, so only chrome puts ink on the surface and + // the two frames can be compared pixel for pixel. + show_scrim: false, + review, + resize_handles: review.then(|| crate::ui::RegionResizeHandles::place(selection)), + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + cut: Default::default(), + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + (surface.data().unwrap().to_vec(), stride) + }; + + let (reviewing, stride) = render(true); + let (targeting, _) = render(false); + let alpha = |data: &[u8], x: usize, y: usize| data[y * stride + x * 4 + 3]; + + // An edge midpoint carries a grip only in Review, and the chip reaches + // above the frame line the two modes share. + assert!(alpha(&reviewing, 150, 57) > 0, "top edge grip"); + assert_eq!(alpha(&targeting, 150, 57), 0, "targeting has no edge grip"); + assert!(alpha(&reviewing, 60, 60) > 0, "top-left corner grip"); + + // The corner arms run inward along both edges from each corner. Review + // drops them so they cannot fight the corner grip for the same pixels; + // this row is past the grip but still inside the arm's reach. + assert!( + alpha(&targeting, 59, 75) > 0, + "targeting draws a corner arm below the top-left corner" + ); + assert_eq!( + alpha(&reviewing, 59, 75), + 0, + "review drops the arm; the grip covers the corner instead" + ); +} + +#[test] +fn capture_loupe_reuses_the_pixel_loupe_renderer_when_enabled() { + let visual = RegionCaptureLoupeVisual::when_enabled(true, (20.0, 30.0), (50.0, 50.0)) + .expect("enabled immutable option"); + assert!(RegionCaptureLoupeVisual::when_enabled(false, (20.0, 30.0), (50.0, 50.0),).is_none()); + + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 200).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_loupe(&ctx, (200, 200), visual, |_x, _y| { + Some(crate::draw::Color::new(1.0, 0.0, 0.0, 1.0)) + }); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert!(data[98 * stride + 88 * 4 + 3] > 0, "loupe center pixel"); + assert_eq!(data[5 * stride + 5 * 4 + 3], 0, "outside untouched"); +} + +#[test] +fn accepted_cut_preview_paints_before_the_scrim_hole() { + let pixels = PackedArgb32::new( + 2, + 1, + 8, + [0x33, 0x22, 0x11, 0xFF, 0xCC, 0xBB, 0xAA, 0xFF].to_vec(), + ) + .unwrap(); + let created = unsafe { + cairo::ImageSurface::create_for_data_unsafe( + pixels.data().as_ptr() as *mut u8, + cairo::Format::ARgb32, + 2, + 1, + 8, + ) + }; + assert!( + created.is_ok(), + "preview pixels must be a valid Cairo source: {created:?}" + ); + drop(created); + // Large enough that the 4–20px corner arms cannot cover the samples, + // and far enough from the 1px frame. 2×1 source scales 16× onto this + // 32×16 display: (18, 18) is inside the first source pixel, (34, 18) + // inside the second. + let display = RegionSelection { + start: (10.0, 10.0), + end: (42.0, 26.0), + }; + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 60, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &UiTextEngine::default(), + &ctx, + 60, + 40, + &RegionCapturePickerVisual { + selection: Some(display), + pointer: (4.0, 4.0), + measurement: None, + show_scrim: true, + review: true, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + cut: RegionCaptureCutVisual { + preview: Some(RegionCutPreviewVisual { + pixels: &pixels, + display, + }), + ..Default::default() + }, + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; + assert!(alpha(18, 18) > 0, "preview occupies the displayed output"); + assert!(alpha(2, 2) > 0, "vacated source is dimmed by the scrim"); + assert_eq!( + &data[18 * stride + 18 * 4..18 * stride + 18 * 4 + 4], + &[0x33, 0x22, 0x11, 0xFF], + "first source pixel fills the left half of the displayed output" + ); + assert_eq!( + &data[18 * stride + 34 * 4..18 * stride + 34 * 4 + 4], + &[0xCC, 0xBB, 0xAA, 0xFF], + "second source pixel fills the right half of the displayed output" + ); +} +#[test] +fn retained_text_owner_matches_fresh_picker_pixels_across_modes_and_density() { + let engine = UiTextEngine::default(); + let selection = RegionSelection { + start: (90.0, 70.0), + end: (480.0, 220.0), + }; + for density in [1, 2, 1] { + for (review, show_scrim) in [(false, true), (true, true), (false, false)] { + let paint = |engine: &UiTextEngine| { + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + 640 * density, + 480 * density, + ) + .unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + render_region_capture_picker( + engine, + &ctx, + 640, + 480, + &RegionCapturePickerVisual { + selection: Some(selection), + pointer: (480.0, 220.0), + measurement: Some("390 × 150"), + show_scrim, + review, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: review + .then(|| RegionActionBar::place(selection, (640, 480))), + hovered_action: None, + include_drawings: true, + cut: Default::default(), + window: RegionCaptureWindowVisual::disabled(), + }, + |_, _| None, + ); + render_region_legend(engine, &ctx, (640, 480), OCR_LEGEND_TEXT); + } + surface.data().unwrap().to_vec() + }; + let actual = paint(&engine); + assert!(actual.iter().any(|&byte| byte != 0)); + assert!( + actual == paint(&UiTextEngine::default()), + "retained picker pixels differ" + ); + } + } +} diff --git a/src/ui/region_capture_picker/types.rs b/src/ui/region_capture_picker/types.rs new file mode 100644 index 000000000..9d7763d0a --- /dev/null +++ b/src/ui/region_capture_picker/types.rs @@ -0,0 +1,102 @@ +use crate::capture::CutAxis; +use crate::input::SelectionHandle; +use crate::input::state::RegionSelection; +use crate::screen_pixels::PackedArgb32; +use crate::ui::region_action_bar::{ + RegionAction, RegionActionAvailability, RegionActionBar, RegionCutStatus, +}; +use crate::ui::region_resize_handles::RegionResizeHandles; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionCaptureWindowVisual<'a> { + pub available: bool, + pub active: bool, + pub targets: &'a [RegionSelection], + /// The pointer-hovered or keyboard-focused window candidate. + pub highlighted_target: Option, +} + +impl RegionCaptureWindowVisual<'_> { + #[cfg(test)] + pub(crate) const fn disabled() -> Self { + Self { + available: false, + active: false, + targets: &[], + highlighted_target: None, + } + } + + pub(super) fn highlighted_selection(self) -> Option { + self.active + .then(|| { + self.highlighted_target + .and_then(|index| self.targets.get(index).copied()) + }) + .flatten() + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionCapturePickerVisual<'a> { + pub selection: Option, + pub pointer: (f64, f64), + /// Authoritative pixel coordinates or size supplied by the picker owner. + pub measurement: Option<&'a str>, + pub show_scrim: bool, + pub show_legend: bool, + /// The selection is committed and awaiting a destination choice. Review + /// drops the targeting chrome: no crosshair, and the size badge anchors to + /// the rectangle rather than following the pointer. + pub review: bool, + /// Resize grips on the reviewed rectangle. Present only in Review, where + /// they replace the corner arms the targeting frame draws. + pub resize_handles: Option, + pub hovered_handle: Option, + pub loupe: Option, + pub action_bar: Option, + pub hovered_action: Option, + pub include_drawings: bool, + pub cut: RegionCaptureCutVisual<'a>, + pub window: RegionCaptureWindowVisual<'a>, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionCutPreviewVisual<'a> { + pub pixels: &'a PackedArgb32, + pub display: RegionSelection, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionCutDragVisual { + pub axis: CutAxis, + pub band: RegionSelection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub(crate) struct RegionCaptureCutVisual<'a> { + pub preview: Option>, + pub drag: Option, + pub availability: RegionActionAvailability, + pub cut_armed: bool, + pub status: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionCaptureLoupeVisual { + pub pointer: (f64, f64), + pub image_center: (f64, f64), +} + +impl RegionCaptureLoupeVisual { + pub(crate) fn when_enabled( + show_loupe: bool, + pointer: (f64, f64), + image_center: (f64, f64), + ) -> Option { + show_loupe.then_some(Self { + pointer, + image_center, + }) + } +} From d6a6a948b57423593e49bbe5c9622eb68dd33ff9 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:07:05 +0200 Subject: [PATCH 29/42] refactor(capture): separate review action layout and painting --- src/ui/region_action_bar.rs | 1198 +------------------------- src/ui/region_action_bar/controls.rs | 288 +++++++ src/ui/region_action_bar/layout.rs | 197 +++++ src/ui/region_action_bar/model.rs | 128 +++ src/ui/region_action_bar/render.rs | 164 ++++ src/ui/region_action_bar/tests.rs | 419 +++++++++ 6 files changed, 1208 insertions(+), 1186 deletions(-) create mode 100644 src/ui/region_action_bar/controls.rs create mode 100644 src/ui/region_action_bar/layout.rs create mode 100644 src/ui/region_action_bar/model.rs create mode 100644 src/ui/region_action_bar/render.rs create mode 100644 src/ui/region_action_bar/tests.rs diff --git a/src/ui/region_action_bar.rs b/src/ui/region_action_bar.rs index 6dc73147d..bfe3931cf 100644 --- a/src/ui/region_action_bar.rs +++ b/src/ui/region_action_bar.rs @@ -1,772 +1,19 @@ -use crate::input::state::RegionSelection; -use crate::ui::theme::{self, Rgba, overlay}; -use crate::ui_text::{UiTextEngine, UiTextStyle}; +//! Region review actions, their layout, and painting facade. -use super::primitives::{draw_keycap_with_engine, draw_rounded_rect, keycap_size_with_engine}; +mod controls; +mod layout; +mod model; +mod render; -const SURFACE_MARGIN: f64 = 8.0; -const SELECTION_GAP: f64 = 12.0; -const BAR_PADDING: f64 = 8.0; -const ITEM_GAP: f64 = 6.0; -/// Vertical gap between the action row and the drawings toggle. The hairline -/// divider is centred in it. -const ROW_GAP: f64 = 8.0; -const ACTION_ROW_HEIGHT: f64 = 38.0; -const EDIT_ROW_HEIGHT: f64 = 28.0; -const TOGGLE_ROW_HEIGHT: f64 = 26.0; -const STATUS_ROW_HEIGHT: f64 = 16.0; -const BAR_HEIGHT: f64 = BAR_PADDING * 2.0 - + ACTION_ROW_HEIGHT - + ROW_GAP - + EDIT_ROW_HEIGHT - + ROW_GAP - + TOGGLE_ROW_HEIGHT - + ROW_GAP - + STATUS_ROW_HEIGHT; -/// Resting width of one action control. The bar sizes itself from this instead -/// of stretching controls across an arbitrary fixed width. -const ACTION_ITEM_WIDTH: f64 = 74.0; -const BAR_WIDTH: f64 = BAR_PADDING * 2.0 + ACTION_ITEM_WIDTH * 4.0 + ITEM_GAP * 3.0; +pub(crate) use layout::{RegionActionBar, RegionActionRect}; +pub(crate) use model::{ + RegionAction, RegionActionAvailability, RegionActionBarVisual, RegionCutStatus, +}; +pub(crate) use render::render_region_action_bar; -const BAR_RADIUS: f64 = overlay::RADIUS_PANEL; -const ITEM_RADIUS: f64 = overlay::RADIUS_MD; -/// Downward-only two-layer drop shadow, matching the command palette frame, so -/// the bar reads as floating above the frozen screenshot rather than painted -/// into it. -const SHADOW_OFFSET: f64 = 8.0; -const SHADOW_SOFT: Rgba = (0.0, 0.0, 0.0, 0.20); +use crate::ui_text::UiTextStyle; -const LABEL_FONT_SIZE: f64 = 11.0; -const KEYCAP_FONT_SIZE: f64 = 8.5; const TOGGLE_FONT_SIZE: f64 = 10.5; -/// Gap between an action's label and the keycap chip under it. -const LABEL_KEYCAP_GAP: f64 = 3.0; - -const ITEM_BG: Rgba = (1.0, 1.0, 1.0, 0.06); -const ITEM_BORDER: Rgba = (1.0, 1.0, 1.0, 0.10); -const ITEM_BG_HOVER: Rgba = overlay::BG_HOVER; -const ITEM_BORDER_HOVER: Rgba = overlay::BORDER_FOCUS; -/// `Both` is what Enter does, so it carries the accent as the bar's default -/// action. Resting alpha stays below full so hover still reads as a change. -const PRIMARY_BG: Rgba = theme::rgba(theme::ACCENT_RGB, 0.80); -const PRIMARY_BG_HOVER: Rgba = theme::rgba(theme::ACCENT_RGB, 1.0); -const PRIMARY_BORDER: Rgba = theme::rgba(theme::ACCENT_BRIGHT_RGB, 0.45); - -const KEYCAP_BG: Rgba = (1.0, 1.0, 1.0, 0.10); -const KEYCAP_BG_ON_ACCENT: Rgba = (1.0, 1.0, 1.0, 0.20); - -const CHECKBOX_SIZE: f64 = 14.0; -const CHECKBOX_BORDER: Rgba = (1.0, 1.0, 1.0, 0.38); -const CHECKBOX_BG: Rgba = (1.0, 1.0, 1.0, 0.06); -const CHECKBOX_BG_CHECKED: Rgba = theme::rgba(theme::ACCENT_RGB, 0.95); -const TOGGLE_BG_HOVER: Rgba = (1.0, 1.0, 1.0, 0.07); - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RegionAction { - Copy, - Save, - Both, - Board, - CutBand, - UndoCut, - RedoCut, - ResetCuts, - ToggleIncludeDrawings, -} - -impl RegionAction { - pub(crate) const fn label(self) -> &'static str { - match self { - Self::Copy => "Copy", - Self::Save => "Save", - Self::Both => "Both", - Self::Board => "Board", - Self::CutBand => "Cut", - Self::UndoCut => "Undo", - Self::RedoCut => "Redo", - Self::ResetCuts => "Reset", - Self::ToggleIncludeDrawings => "Include drawings in exports", - } - } - - pub(crate) const fn shortcut(self) -> &'static str { - match self { - Self::Copy => "Ctrl+C", - Self::Save => "Ctrl+S", - Self::Both => "Enter", - Self::Board => "B", - Self::CutBand => "X", - Self::UndoCut => "Ctrl+Z", - Self::RedoCut => "Ctrl+Y", - Self::ResetCuts => "", - Self::ToggleIncludeDrawings => "D", - } - } - - /// Destinations that leave Review. Edit controls stay in the picker. - pub(crate) const fn is_terminal(self) -> bool { - matches!(self, Self::Copy | Self::Save | Self::Both | Self::Board) - } - - /// The accented default action: the one `Enter` submits. - const fn is_primary(self) -> bool { - matches!(self, Self::Both) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct RegionActionAvailability { - pub terminal: bool, - pub cut: bool, - pub undo: bool, - pub redo: bool, - pub reset: bool, -} - -impl RegionActionAvailability { - /// Resting Review bar: terminals and Cut enabled, history empty. - pub(crate) const DEFAULT: Self = Self { - terminal: true, - cut: true, - undo: false, - redo: false, - reset: false, - }; - - pub(crate) const fn allows(self, action: RegionAction) -> bool { - match action { - RegionAction::Copy | RegionAction::Save | RegionAction::Both | RegionAction::Board => { - self.terminal - } - RegionAction::CutBand => self.cut, - RegionAction::UndoCut => self.undo, - RegionAction::RedoCut => self.redo, - RegionAction::ResetCuts => self.reset, - RegionAction::ToggleIncludeDrawings => true, - } - } -} - -impl Default for RegionActionAvailability { - fn default() -> Self { - Self::DEFAULT - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RegionCutStatus { - Updating, - Failed, -} - -impl RegionCutStatus { - const fn message(self) -> &'static str { - match self { - Self::Updating => "Updating cut preview…", - Self::Failed => "Cut preview failed", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct RegionActionBarVisual { - pub hovered: Option, - pub include_drawings: bool, - pub availability: RegionActionAvailability, - pub cut_armed: bool, - pub status: Option, -} - -#[cfg(test)] -impl RegionActionBarVisual { - pub(crate) const fn simple(hovered: Option, include_drawings: bool) -> Self { - Self { - hovered, - include_drawings, - availability: RegionActionAvailability::DEFAULT, - cut_armed: false, - status: None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct RegionActionRect { - pub x: f64, - pub y: f64, - pub width: f64, - pub height: f64, -} - -impl RegionActionRect { - pub(crate) const fn new(x: f64, y: f64, width: f64, height: f64) -> Self { - Self { - x, - y, - width, - height, - } - } - - fn contains(self, point: (f64, f64)) -> bool { - point.0 >= self.x - && point.0 < self.x + self.width - && point.1 >= self.y - && point.1 < self.y + self.height - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -struct RegionActionItem { - action: RegionAction, - bounds: RegionActionRect, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct RegionActionBar { - bounds: RegionActionRect, - items: [RegionActionItem; 4], - edit: [RegionActionItem; 4], - toggle: RegionActionItem, -} - -impl RegionActionBar { - pub(crate) fn place(selection: RegionSelection, surface: (u32, u32)) -> Self { - let left = selection.start.0.min(selection.end.0); - let right = selection.start.0.max(selection.end.0); - let top = selection.start.1.min(selection.end.1); - let bottom = selection.start.1.max(selection.end.1); - let surface_width = f64::from(surface.0); - let surface_height = f64::from(surface.1); - let width = BAR_WIDTH.min((surface_width - SURFACE_MARGIN * 2.0).max(0.0)); - let height = BAR_HEIGHT.min((surface_height - SURFACE_MARGIN * 2.0).max(0.0)); - let x = ((left + right - width) / 2.0).clamp( - SURFACE_MARGIN, - (surface_width - width - SURFACE_MARGIN).max(SURFACE_MARGIN), - ); - let below = bottom + SELECTION_GAP; - let preferred_y = if below + height + SURFACE_MARGIN <= surface_height { - below - } else { - top - SELECTION_GAP - height - }; - let y = preferred_y.clamp( - SURFACE_MARGIN, - (surface_height - height - SURFACE_MARGIN).max(SURFACE_MARGIN), - ); - let bounds = RegionActionRect::new(x, y, width, height); - let width_scale = if BAR_WIDTH > 0.0 { - (width / BAR_WIDTH).clamp(0.0, 1.0) - } else { - 0.0 - }; - let height_scale = if BAR_HEIGHT > 0.0 { - (height / BAR_HEIGHT).clamp(0.0, 1.0) - } else { - 0.0 - }; - let pad_x = BAR_PADDING * width_scale; - let pad_y = BAR_PADDING * height_scale; - let item_gap = ITEM_GAP * width_scale; - let row_gap = ROW_GAP * height_scale; - let action_height = ACTION_ROW_HEIGHT * height_scale; - let edit_height = EDIT_ROW_HEIGHT * height_scale; - let toggle_height = TOGGLE_ROW_HEIGHT * height_scale; - let item_count = 4.0; - let item_width = - ((width - pad_x * 2.0 - item_gap * (item_count - 1.0)) / item_count).max(0.0); - let row_item = |index: usize, action, row_y, row_height| RegionActionItem { - action, - bounds: RegionActionRect::new( - x + pad_x + index as f64 * (item_width + item_gap), - row_y, - item_width, - row_height, - ), - }; - let action_y = y + pad_y; - let edit_y = action_y + action_height + row_gap; - let toggle_y = edit_y + edit_height + row_gap; - let toggle = RegionActionItem { - action: RegionAction::ToggleIncludeDrawings, - bounds: RegionActionRect::new( - x + pad_x, - toggle_y, - (width - pad_x * 2.0).max(0.0), - toggle_height, - ), - }; - Self { - bounds, - items: [ - row_item(0, RegionAction::Copy, action_y, action_height), - row_item(1, RegionAction::Save, action_y, action_height), - row_item(2, RegionAction::Both, action_y, action_height), - row_item(3, RegionAction::Board, action_y, action_height), - ], - edit: [ - row_item(0, RegionAction::CutBand, edit_y, edit_height), - row_item(1, RegionAction::UndoCut, edit_y, edit_height), - row_item(2, RegionAction::RedoCut, edit_y, edit_height), - row_item(3, RegionAction::ResetCuts, edit_y, edit_height), - ], - toggle, - } - } - - /// The painted frame, without its drop shadow. The picker uses it to keep - /// the Review size badge out from under the bar. - pub(crate) const fn bounds(&self) -> RegionActionRect { - self.bounds - } - - pub(crate) fn hit(&self, point: (f64, f64)) -> Option { - self.items - .iter() - .chain(self.edit.iter()) - .find(|item| item.bounds.contains(point)) - .map(|item| item.action) - .or_else(|| { - self.toggle - .bounds - .contains(point) - .then_some(self.toggle.action) - }) - } - - pub(crate) fn enabled_hit( - &self, - point: (f64, f64), - availability: RegionActionAvailability, - ) -> Option { - self.hit(point) - .filter(|&action| availability.allows(action)) - } - - pub(crate) fn contains(&self, point: (f64, f64)) -> bool { - self.bounds.contains(point) - } - - fn status_bounds(&self) -> Option { - let toggle = self.toggle.bounds; - if toggle.width <= 0.0 { - return None; - } - let pad_y = (self.items[0].bounds.y - self.bounds.y).max(0.0); - let row_gap = - (self.edit[0].bounds.y - self.items[0].bounds.y - self.items[0].bounds.height).max(0.0); - let y = toggle.y + toggle.height + row_gap; - let height = (self.bounds.y + self.bounds.height - pad_y - y).max(0.0); - (height > 0.0).then(|| RegionActionRect::new(toggle.x, y, toggle.width, height)) - } -} - -pub(crate) fn render_region_action_bar( - engine: &UiTextEngine, - ctx: &cairo::Context, - bar: &RegionActionBar, - visual: RegionActionBarVisual, -) { - let _ = ctx.save(); - draw_bar_frame(ctx, bar.bounds); - - for &item in &bar.items { - draw_action( - engine, - ctx, - item, - visual.hovered == Some(item.action), - visual.availability.allows(item.action), - false, - ); - } - draw_row_divider( - ctx, - bar.items[0].bounds, - bar.edit[0].bounds.y, - bar.toggle.bounds.width, - ); - for &item in &bar.edit { - draw_action( - engine, - ctx, - item, - visual.hovered == Some(item.action), - visual.availability.allows(item.action), - visual.cut_armed && item.action == RegionAction::CutBand, - ); - } - draw_row_divider( - ctx, - bar.edit[0].bounds, - bar.toggle.bounds.y, - bar.toggle.bounds.width, - ); - draw_toggle( - engine, - ctx, - bar.toggle, - visual.hovered, - visual.include_drawings, - ); - draw_status(engine, ctx, bar, visual.status); - let _ = ctx.restore(); -} - -fn draw_bar_frame(ctx: &cairo::Context, bounds: RegionActionRect) { - if bounds.width <= 0.0 || bounds.height <= 0.0 { - return; - } - for (offset, color) in [ - (SHADOW_OFFSET, SHADOW_SOFT), - (SHADOW_OFFSET * 0.5, overlay::SHADOW), - ] { - theme::set_color(ctx, color); - draw_rounded_rect( - ctx, - bounds.x, - bounds.y + offset, - bounds.width, - bounds.height, - BAR_RADIUS, - ); - let _ = ctx.fill(); - } - - theme::set_color(ctx, crate::ui::theme::popup::bg_context_menu()); - draw_rounded_rect( - ctx, - bounds.x, - bounds.y, - bounds.width, - bounds.height, - BAR_RADIUS, - ); - let _ = ctx.fill(); - - theme::set_color(ctx, crate::ui::theme::popup::border_context_menu()); - ctx.set_line_width(1.0); - draw_rounded_rect( - ctx, - bounds.x + 0.5, - bounds.y + 0.5, - bounds.width - 1.0, - bounds.height - 1.0, - BAR_RADIUS - 0.5, - ); - let _ = ctx.stroke(); -} - -/// Hairline between stacked rows, inset from the bar's padding so it reads as -/// a grouping rule rather than a border. -fn draw_row_divider(ctx: &cairo::Context, row: RegionActionRect, next_y: f64, width: f64) { - if width <= 0.0 || row.height <= 0.0 { - return; - } - let gap = (next_y - row.y - row.height).max(0.0); - if gap <= 0.0 { - return; - } - let y = (row.y + row.height + gap / 2.0).floor() + 0.5; - theme::set_color(ctx, overlay::DIVIDER_LIGHT); - ctx.set_line_width(1.0); - ctx.move_to(row.x, y); - ctx.line_to(row.x + width, y); - let _ = ctx.stroke(); -} - -fn draw_action( - engine: &UiTextEngine, - ctx: &cairo::Context, - item: RegionActionItem, - hovered: bool, - enabled: bool, - selected: bool, -) { - if item.bounds.width <= 0.0 || item.bounds.height <= 0.0 { - return; - } - let primary = item.action.is_primary(); - let (mut fill, mut border) = match (primary, hovered || selected) { - (true, false) => (PRIMARY_BG, PRIMARY_BORDER), - (true, true) => (PRIMARY_BG_HOVER, ITEM_BORDER_HOVER), - (false, false) => (ITEM_BG, ITEM_BORDER), - (false, true) => (ITEM_BG_HOVER, ITEM_BORDER_HOVER), - }; - if selected && !primary { - fill = theme::rgba(theme::ACCENT_RGB, 0.35); - border = ITEM_BORDER_HOVER; - } - if !enabled { - fill.3 *= 0.45; - border.3 *= 0.45; - } - - theme::set_color(ctx, fill); - draw_rounded_rect( - ctx, - item.bounds.x, - item.bounds.y, - item.bounds.width, - item.bounds.height, - ITEM_RADIUS, - ); - let _ = ctx.fill(); - - theme::set_color(ctx, border); - ctx.set_line_width(1.0); - draw_rounded_rect( - ctx, - item.bounds.x + 0.5, - item.bounds.y + 0.5, - (item.bounds.width - 1.0).max(0.0), - (item.bounds.height - 1.0).max(0.0), - (ITEM_RADIUS - 0.5).max(0.0), - ); - let _ = ctx.stroke(); - - draw_action_content(engine, ctx, item, primary, enabled); -} - -/// Label over keycap, the pair centred as one block so every control's text -/// sits on the same optical line regardless of ascenders or descenders. -fn draw_action_content( - engine: &UiTextEngine, - ctx: &cairo::Context, - item: RegionActionItem, - primary: bool, - enabled: bool, -) { - let _ = ctx.save(); - ctx.rectangle( - item.bounds.x, - item.bounds.y, - item.bounds.width, - item.bounds.height, - ); - ctx.clip(); - - let center_x = item.bounds.x + item.bounds.width / 2.0; - let label = item.action.label(); - let layout = engine.layout(ctx, label_style(), label, None); - let label_extents = layout.ink_extents(); - let shortcut = item.action.shortcut(); - let (keycap_width, keycap_height) = if shortcut.is_empty() { - (0.0, 0.0) - } else { - keycap_size_with_engine(engine, ctx, shortcut, KEYCAP_FONT_SIZE) - }; - - let stack_height = if shortcut.is_empty() { - label_extents.height() - } else { - label_extents.height() + LABEL_KEYCAP_GAP + keycap_height - }; - let stack_top = item.bounds.y + (item.bounds.height - stack_height) / 2.0; - let text_color = if !enabled { - overlay::TEXT_TERTIARY - } else if primary { - overlay::TEXT_WHITE - } else { - overlay::TEXT_PRIMARY - }; - - theme::set_color(ctx, text_color); - layout.show_at_baseline( - ctx, - center_x - label_extents.width() / 2.0 - label_extents.x_bearing(), - stack_top - label_extents.y_bearing(), - ); - - if !shortcut.is_empty() { - draw_keycap_with_engine( - engine, - ctx, - center_x - keycap_width / 2.0, - stack_top + label_extents.height() + LABEL_KEYCAP_GAP, - shortcut, - KEYCAP_FONT_SIZE, - if primary { - KEYCAP_BG_ON_ACCENT - } else { - KEYCAP_BG - }, - if primary { - overlay::TEXT_WHITE - } else { - overlay::TEXT_HINT - }, - ); - } - let _ = ctx.restore(); -} - -fn draw_toggle( - engine: &UiTextEngine, - ctx: &cairo::Context, - item: RegionActionItem, - hovered: Option, - checked: bool, -) { - if item.bounds.width <= 0.0 || item.bounds.height <= 0.0 { - return; - } - let _ = ctx.save(); - ctx.rectangle( - item.bounds.x, - item.bounds.y, - item.bounds.width, - item.bounds.height, - ); - ctx.clip(); - - // The row itself stays quiet: the checkbox carries the on/off state, so an - // enabled toggle no longer paints a full-width slab across the bar. - if hovered == Some(item.action) { - theme::set_color(ctx, TOGGLE_BG_HOVER); - draw_rounded_rect( - ctx, - item.bounds.x, - item.bounds.y, - item.bounds.width, - item.bounds.height, - ITEM_RADIUS, - ); - let _ = ctx.fill(); - } - - let box_size = CHECKBOX_SIZE.min(item.bounds.height - 2.0).max(0.0); - let box_x = item.bounds.x + 6.0; - let box_y = item.bounds.y + (item.bounds.height - box_size) / 2.0; - draw_checkbox(ctx, box_x, box_y, box_size, checked); - - let label = item.action.label(); - let layout = engine.layout(ctx, toggle_label_style(), label, None); - let extents = layout.ink_extents(); - theme::set_color( - ctx, - if checked { - overlay::TEXT_PRIMARY - } else { - overlay::TEXT_TERTIARY - }, - ); - layout.show_at_baseline( - ctx, - box_x + box_size + 8.0 - extents.x_bearing(), - item.bounds.y + (item.bounds.height - extents.height()) / 2.0 - extents.y_bearing(), - ); - - let (keycap_width, keycap_height) = - keycap_size_with_engine(engine, ctx, item.action.shortcut(), KEYCAP_FONT_SIZE); - draw_keycap_with_engine( - engine, - ctx, - item.bounds.x + item.bounds.width - 6.0 - keycap_width, - item.bounds.y + (item.bounds.height - keycap_height) / 2.0, - item.action.shortcut(), - KEYCAP_FONT_SIZE, - KEYCAP_BG, - overlay::TEXT_HINT, - ); - let _ = ctx.restore(); -} - -fn draw_checkbox(ctx: &cairo::Context, x: f64, y: f64, size: f64, checked: bool) { - if size <= 0.0 { - return; - } - theme::set_color( - ctx, - if checked { - CHECKBOX_BG_CHECKED - } else { - CHECKBOX_BG - }, - ); - draw_rounded_rect(ctx, x, y, size, size, overlay::RADIUS_SM); - let _ = ctx.fill(); - - theme::set_color( - ctx, - if checked { - theme::rgba(theme::ACCENT_BRIGHT_RGB, 0.8) - } else { - CHECKBOX_BORDER - }, - ); - ctx.set_line_width(1.0); - draw_rounded_rect( - ctx, - x + 0.5, - y + 0.5, - (size - 1.0).max(0.0), - (size - 1.0).max(0.0), - (overlay::RADIUS_SM - 0.5).max(0.0), - ); - let _ = ctx.stroke(); - - if !checked { - return; - } - theme::set_color(ctx, overlay::TEXT_WHITE); - ctx.set_line_width((size * 0.14).max(1.4)); - ctx.set_line_cap(cairo::LineCap::Round); - ctx.set_line_join(cairo::LineJoin::Round); - ctx.move_to(x + size * 0.26, y + size * 0.52); - ctx.line_to(x + size * 0.44, y + size * 0.70); - ctx.line_to(x + size * 0.76, y + size * 0.32); - let _ = ctx.stroke(); -} - -fn draw_status( - engine: &UiTextEngine, - ctx: &cairo::Context, - bar: &RegionActionBar, - status: Option, -) { - let Some(status) = status else { - return; - }; - let Some(row) = bar.status_bounds() else { - return; - }; - let font_size = (TOGGLE_FONT_SIZE * (row.height / STATUS_ROW_HEIGHT).min(1.0)).max(0.0); - if font_size < 1.0 { - return; - } - let _ = ctx.save(); - ctx.rectangle(row.x, row.y, row.width, row.height); - ctx.clip(); - let layout = engine.layout(ctx, status_label_style(font_size), status.message(), None); - let extents = layout.ink_extents(); - theme::set_color( - ctx, - match status { - RegionCutStatus::Updating => overlay::TEXT_HINT, - RegionCutStatus::Failed => overlay::TEXT_PRIMARY, - }, - ); - layout.show_at_baseline( - ctx, - row.x + (row.width - extents.width()) / 2.0 - extents.x_bearing(), - row.y + (row.height - extents.height()) / 2.0 - extents.y_bearing(), - ); - let _ = ctx.restore(); -} - -fn label_style() -> UiTextStyle<'static> { - UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Bold, - size: LABEL_FONT_SIZE, - } -} - -fn toggle_label_style() -> UiTextStyle<'static> { - status_label_style(TOGGLE_FONT_SIZE) -} fn status_label_style(size: f64) -> UiTextStyle<'static> { UiTextStyle { @@ -778,425 +25,4 @@ fn status_label_style(size: f64) -> UiTextStyle<'static> { } #[cfg(test)] -mod tests { - use super::*; - - fn sample_bar() -> RegionActionBar { - RegionActionBar::place( - RegionSelection { - start: (100.0, 100.0), - end: (300.0, 200.0), - }, - (800, 600), - ) - } - - fn rect_inside(inner: RegionActionRect, outer: RegionActionRect) -> bool { - inner.x + f64::EPSILON >= outer.x - && inner.y + f64::EPSILON >= outer.y - && inner.x + inner.width <= outer.x + outer.width + f64::EPSILON - && inner.y + inner.height <= outer.y + outer.height + f64::EPSILON - } - - fn assert_controls_stay_inside_bar(bar: &RegionActionBar) { - let bounds = bar.bounds(); - for item in bar.items.iter().chain(bar.edit.iter()) { - assert!( - rect_inside(item.bounds, bounds), - "{:?} at ({}, {}) {}x{} leaves bar {bounds:?}", - item.action, - item.bounds.x, - item.bounds.y, - item.bounds.width, - item.bounds.height - ); - } - assert!( - rect_inside(bar.toggle.bounds, bounds), - "toggle leaves bar {bounds:?}" - ); - for row in [&bar.items[..], &bar.edit[..]] { - for pair in row.windows(2) { - assert!( - pair[0].bounds.x + pair[0].bounds.width <= pair[1].bounds.x + f64::EPSILON, - "{:?} overlaps {:?}", - pair[0].action, - pair[1].action - ); - } - } - assert!( - bar.items[0].bounds.y + bar.items[0].bounds.height - <= bar.edit[0].bounds.y + f64::EPSILON - ); - assert!( - bar.edit[0].bounds.y + bar.edit[0].bounds.height <= bar.toggle.bounds.y + f64::EPSILON - ); - } - - #[test] - fn action_bar_prefers_below_then_flips_above_and_clamps_to_the_surface() { - let centered = sample_bar(); - assert_eq!( - centered.bounds(), - RegionActionRect::new(35.0, 212.0, 330.0, BAR_HEIGHT) - ); - - let flipped = RegionActionBar::place( - RegionSelection { - start: (730.0, 560.0), - end: (790.0, 590.0), - }, - (800, 600), - ); - assert_eq!( - flipped.bounds(), - RegionActionRect::new(462.0, 560.0 - SELECTION_GAP - BAR_HEIGHT, 330.0, BAR_HEIGHT) - ); - } - - #[test] - fn action_bar_hit_returns_typed_controls_and_rejects_gaps() { - let bar = sample_bar(); - let action_y = bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0; - let edit_y = bar.edit[0].bounds.y + bar.edit[0].bounds.height / 2.0; - let toggle_y = bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0; - - assert_eq!(bar.hit((80.0, action_y)), Some(RegionAction::Copy)); - assert_eq!(bar.hit((160.0, action_y)), Some(RegionAction::Save)); - assert_eq!(bar.hit((240.0, action_y)), Some(RegionAction::Both)); - assert_eq!(bar.hit((320.0, action_y)), Some(RegionAction::Board)); - assert_eq!(bar.hit((80.0, edit_y)), Some(RegionAction::CutBand)); - assert_eq!(bar.hit((160.0, edit_y)), Some(RegionAction::UndoCut)); - assert_eq!(bar.hit((240.0, edit_y)), Some(RegionAction::RedoCut)); - assert_eq!(bar.hit((320.0, edit_y)), Some(RegionAction::ResetCuts)); - assert_eq!( - bar.hit((200.0, toggle_y)), - Some(RegionAction::ToggleIncludeDrawings) - ); - assert_eq!(bar.hit((119.0, action_y)), None, "inter-item gap"); - assert!(bar.contains((119.0, action_y)), "bar gaps stay modal-owned"); - assert_eq!(bar.hit((20.0, 20.0)), None, "outside the bar"); - assert!(!bar.contains((20.0, 20.0))); - } - - #[test] - fn disabled_controls_still_consume_the_bar_but_return_no_enabled_action() { - let bar = sample_bar(); - let availability = RegionActionAvailability { - terminal: false, - cut: true, - undo: false, - redo: false, - reset: false, - }; - let action_y = bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0; - assert_eq!(bar.hit((80.0, action_y)), Some(RegionAction::Copy)); - assert_eq!(bar.enabled_hit((80.0, action_y), availability), None); - assert!(bar.contains((80.0, action_y))); - assert_eq!( - bar.enabled_hit( - ( - bar.edit[0].bounds.x + bar.edit[0].bounds.width / 2.0, - bar.edit[0].bounds.y + bar.edit[0].bounds.height / 2.0 - ), - availability - ), - Some(RegionAction::CutBand) - ); - } - - #[test] - fn action_bar_rows_never_overlap_and_stay_inside_the_padded_frame() { - let bar = sample_bar(); - let bounds = bar.bounds(); - let toggle = bar.toggle.bounds; - - for item in bar.items { - assert!(item.bounds.y >= bounds.y + BAR_PADDING); - assert!( - item.bounds.y + item.bounds.height <= bar.edit[0].bounds.y - ROW_GAP + f64::EPSILON - ); - assert!(item.bounds.x >= bounds.x + BAR_PADDING); - assert!(item.bounds.x + item.bounds.width <= bounds.x + bounds.width - BAR_PADDING); - assert_eq!(item.bounds.width, ACTION_ITEM_WIDTH); - } - for item in bar.edit { - assert!(item.bounds.y >= bar.items[0].bounds.y + bar.items[0].bounds.height); - assert!(item.bounds.y + item.bounds.height <= toggle.y - ROW_GAP + f64::EPSILON); - assert_eq!(item.bounds.width, ACTION_ITEM_WIDTH); - } - assert!(toggle.y + toggle.height <= bounds.y + bounds.height - BAR_PADDING); - } - - #[test] - fn narrow_and_short_surfaces_keep_controls_inside_the_bar() { - let selection = RegionSelection { - start: (10.0, 10.0), - end: (40.0, 30.0), - }; - for surface in [(200, 80), (80, 40), (40, 600), (800, 36)] { - let bar = RegionActionBar::place(selection, surface); - assert_controls_stay_inside_bar(&bar); - let action = bar.items[0].bounds; - if action.width > 1.0 && action.height > 1.0 { - assert_eq!( - bar.hit(( - action.x + action.width / 2.0, - action.y + action.height / 2.0 - )), - Some(RegionAction::Copy), - "typed hit on {surface:?}" - ); - } - } - } - - #[test] - fn action_bar_exposes_the_requested_labels_and_shortcuts() { - assert_eq!(RegionAction::Copy.label(), "Copy"); - assert_eq!(RegionAction::Copy.shortcut(), "Ctrl+C"); - assert_eq!(RegionAction::Save.label(), "Save"); - assert_eq!(RegionAction::Save.shortcut(), "Ctrl+S"); - assert_eq!(RegionAction::Both.label(), "Both"); - assert_eq!(RegionAction::Both.shortcut(), "Enter"); - assert_eq!(RegionAction::Board.label(), "Board"); - assert_eq!(RegionAction::Board.shortcut(), "B"); - assert_eq!(RegionAction::CutBand.label(), "Cut"); - assert_eq!(RegionAction::CutBand.shortcut(), "X"); - assert_eq!(RegionAction::UndoCut.shortcut(), "Ctrl+Z"); - assert_eq!(RegionAction::RedoCut.shortcut(), "Ctrl+Y"); - assert_eq!( - RegionAction::ToggleIncludeDrawings.label(), - "Include drawings in exports" - ); - assert_eq!(RegionAction::ToggleIncludeDrawings.shortcut(), "D"); - assert!(RegionAction::Copy.is_terminal()); - assert!(!RegionAction::CutBand.is_terminal()); - assert!(!RegionAction::ToggleIncludeDrawings.is_terminal()); - } - - #[test] - fn enter_is_the_only_accented_default_action() { - assert!(RegionAction::Both.is_primary()); - for action in [ - RegionAction::Copy, - RegionAction::Save, - RegionAction::Board, - RegionAction::CutBand, - RegionAction::UndoCut, - RegionAction::RedoCut, - RegionAction::ResetCuts, - RegionAction::ToggleIncludeDrawings, - ] { - assert!(!action.is_primary(), "{action:?} must stay neutral"); - } - } - - #[test] - fn rendering_paints_the_bar_and_each_control() { - let bar = sample_bar(); - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_action_bar( - &UiTextEngine::default(), - &ctx, - &bar, - RegionActionBarVisual::simple(Some(RegionAction::Both), true), - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; - let action_y = (bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0) as usize; - let toggle_y = (bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0) as usize; - - assert!(alpha(40, action_y) > 0, "bar surface"); - for x in [80, 160, 240, 320] { - assert!(alpha(x, action_y) > 0, "control at x={x}"); - } - assert!(alpha(56, toggle_y) > 0, "checked drawings checkbox"); - assert_eq!(alpha(20, 20), 0, "outside remains untouched"); - } - - #[test] - fn the_drawings_checkbox_carries_the_state_instead_of_a_full_width_slab() { - let bar = sample_bar(); - let toggle_y = (bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0) as usize; - let row_alpha = |checked: bool| { - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_action_bar( - &UiTextEngine::default(), - &ctx, - &bar, - RegionActionBarVisual::simple(None, checked), - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - ( - u32::from(data[toggle_y * stride + 300 * 4 + 3]), - u32::from(data[toggle_y * stride + 56 * 4 + 3]), - ) - }; - - let (off_row, off_box) = row_alpha(false); - let (on_row, on_box) = row_alpha(true); - assert_eq!(off_row, on_row, "the row background must not change"); - assert!(on_box > 0 && off_box > 0, "the box is drawn either way"); - } - - #[test] - fn updating_and_failed_preview_states_paint_status_text() { - let bar = sample_bar(); - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_action_bar( - &UiTextEngine::default(), - &ctx, - &bar, - RegionActionBarVisual { - hovered: None, - include_drawings: false, - availability: RegionActionAvailability { - terminal: false, - cut: true, - undo: true, - redo: false, - reset: true, - }, - cut_armed: true, - status: Some(RegionCutStatus::Updating), - }, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let data = surface.data().unwrap(); - let status = bar.status_bounds().unwrap(); - let status_y = (status.y + status.height / 2.0) as usize; - let alpha = data[status_y * stride + 200 * 4 + 3]; - assert!(alpha > 0, "status caption is visible"); - } - - fn paint_bar( - width: i32, - height: i32, - bar: RegionActionBar, - status: Option, - ) -> (usize, Vec) { - let mut surface = - cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - render_region_action_bar( - &UiTextEngine::default(), - &ctx, - &bar, - RegionActionBarVisual { - hovered: None, - include_drawings: false, - availability: RegionActionAvailability { - terminal: false, - cut: true, - undo: true, - redo: false, - reset: true, - }, - cut_armed: false, - status, - }, - ); - drop(ctx); - surface.flush(); - let stride = surface.stride() as usize; - let pixels = surface.data().unwrap().to_vec(); - (stride, pixels) - } - - #[test] - fn short_surface_status_paint_stays_inside_its_row() { - let selection = RegionSelection { - start: (10.0, 10.0), - end: (40.0, 30.0), - }; - for surface in [(800, 36), (800, 100), (200, 80)] { - let bar = RegionActionBar::place(selection, surface); - let width = i32::try_from(surface.0).unwrap(); - let height = i32::try_from(surface.1).unwrap(); - let (stride, without) = paint_bar(width, height, bar, None); - let (_, with_status) = paint_bar(width, height, bar, Some(RegionCutStatus::Failed)); - let row = bar.status_bounds(); - for y in 0..surface.1 as usize { - for x in 0..surface.0 as usize { - let offset = y * stride + x * 4; - if without[offset..offset + 4] == with_status[offset..offset + 4] { - continue; - } - let Some(row) = row else { - panic!("status painted with no status row on {surface:?}"); - }; - assert!( - row.contains((x as f64 + 0.5, y as f64 + 0.5)), - "status paint at ({x}, {y}) left the {row:?} row on {surface:?}" - ); - assert!( - bar.bounds.contains((x as f64 + 0.5, y as f64 + 0.5)), - "status paint at ({x}, {y}) left the bar on {surface:?}" - ); - } - } - } - } - #[test] - fn retained_text_owner_matches_fresh_bar_pixels_across_status_and_density() { - let engine = UiTextEngine::default(); - for density in [1, 2, 1] { - for status in [ - None, - Some(RegionCutStatus::Updating), - Some(RegionCutStatus::Failed), - ] { - let paint = |engine: &UiTextEngine| { - let mut surface = cairo::ImageSurface::create( - cairo::Format::ARgb32, - 800 * density, - 600 * density, - ) - .unwrap(); - { - let ctx = cairo::Context::new(&surface).unwrap(); - ctx.scale(f64::from(density), f64::from(density)); - render_region_action_bar( - engine, - &ctx, - &sample_bar(), - RegionActionBarVisual { - hovered: Some(RegionAction::CutBand), - include_drawings: status.is_none(), - availability: RegionActionAvailability { - terminal: status.is_none(), - cut: true, - undo: true, - redo: false, - reset: true, - }, - cut_armed: true, - status, - }, - ); - } - surface.data().unwrap().to_vec() - }; - let actual = paint(&engine); - assert!(actual.iter().any(|&byte| byte != 0)); - assert!( - actual == paint(&UiTextEngine::default()), - "retained action bar pixels differ" - ); - } - } - } -} +mod tests; diff --git a/src/ui/region_action_bar/controls.rs b/src/ui/region_action_bar/controls.rs new file mode 100644 index 000000000..e2d1fdb32 --- /dev/null +++ b/src/ui/region_action_bar/controls.rs @@ -0,0 +1,288 @@ +use super::layout::RegionActionItem; +use super::model::RegionAction; +use super::{TOGGLE_FONT_SIZE, status_label_style}; +use crate::ui::primitives::{draw_keycap_with_engine, draw_rounded_rect, keycap_size_with_engine}; +use crate::ui::theme::{self, Rgba, overlay}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; + +const ITEM_RADIUS: f64 = overlay::RADIUS_MD; +const LABEL_FONT_SIZE: f64 = 11.0; +const KEYCAP_FONT_SIZE: f64 = 8.5; +/// Gap between an action's label and the keycap chip under it. +const LABEL_KEYCAP_GAP: f64 = 3.0; + +const ITEM_BG: Rgba = (1.0, 1.0, 1.0, 0.06); +const ITEM_BORDER: Rgba = (1.0, 1.0, 1.0, 0.10); +const ITEM_BG_HOVER: Rgba = overlay::BG_HOVER; +const ITEM_BORDER_HOVER: Rgba = overlay::BORDER_FOCUS; +/// `Both` is what Enter does, so it carries the accent as the bar's default +/// action. Resting alpha stays below full so hover still reads as a change. +const PRIMARY_BG: Rgba = theme::rgba(theme::ACCENT_RGB, 0.80); +const PRIMARY_BG_HOVER: Rgba = theme::rgba(theme::ACCENT_RGB, 1.0); +const PRIMARY_BORDER: Rgba = theme::rgba(theme::ACCENT_BRIGHT_RGB, 0.45); + +const KEYCAP_BG: Rgba = (1.0, 1.0, 1.0, 0.10); +const KEYCAP_BG_ON_ACCENT: Rgba = (1.0, 1.0, 1.0, 0.20); + +const CHECKBOX_SIZE: f64 = 14.0; +const CHECKBOX_BORDER: Rgba = (1.0, 1.0, 1.0, 0.38); +const CHECKBOX_BG: Rgba = (1.0, 1.0, 1.0, 0.06); +const CHECKBOX_BG_CHECKED: Rgba = theme::rgba(theme::ACCENT_RGB, 0.95); +const TOGGLE_BG_HOVER: Rgba = (1.0, 1.0, 1.0, 0.07); + +pub(super) fn draw_action( + engine: &UiTextEngine, + ctx: &cairo::Context, + item: RegionActionItem, + hovered: bool, + enabled: bool, + selected: bool, +) { + if item.bounds.width <= 0.0 || item.bounds.height <= 0.0 { + return; + } + let primary = item.action.is_primary(); + let (mut fill, mut border) = match (primary, hovered || selected) { + (true, false) => (PRIMARY_BG, PRIMARY_BORDER), + (true, true) => (PRIMARY_BG_HOVER, ITEM_BORDER_HOVER), + (false, false) => (ITEM_BG, ITEM_BORDER), + (false, true) => (ITEM_BG_HOVER, ITEM_BORDER_HOVER), + }; + if selected && !primary { + fill = theme::rgba(theme::ACCENT_RGB, 0.35); + border = ITEM_BORDER_HOVER; + } + if !enabled { + fill.3 *= 0.45; + border.3 *= 0.45; + } + + theme::set_color(ctx, fill); + draw_rounded_rect( + ctx, + item.bounds.x, + item.bounds.y, + item.bounds.width, + item.bounds.height, + ITEM_RADIUS, + ); + let _ = ctx.fill(); + + theme::set_color(ctx, border); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + item.bounds.x + 0.5, + item.bounds.y + 0.5, + (item.bounds.width - 1.0).max(0.0), + (item.bounds.height - 1.0).max(0.0), + (ITEM_RADIUS - 0.5).max(0.0), + ); + let _ = ctx.stroke(); + + draw_action_content(engine, ctx, item, primary, enabled); +} + +/// Label over keycap, the pair centred as one block so every control's text +/// sits on the same optical line regardless of ascenders or descenders. +fn draw_action_content( + engine: &UiTextEngine, + ctx: &cairo::Context, + item: RegionActionItem, + primary: bool, + enabled: bool, +) { + let _ = ctx.save(); + ctx.rectangle( + item.bounds.x, + item.bounds.y, + item.bounds.width, + item.bounds.height, + ); + ctx.clip(); + + let center_x = item.bounds.x + item.bounds.width / 2.0; + let label = item.action.label(); + let layout = engine.layout(ctx, label_style(), label, None); + let label_extents = layout.ink_extents(); + let shortcut = item.action.shortcut(); + let (keycap_width, keycap_height) = if shortcut.is_empty() { + (0.0, 0.0) + } else { + keycap_size_with_engine(engine, ctx, shortcut, KEYCAP_FONT_SIZE) + }; + + let stack_height = if shortcut.is_empty() { + label_extents.height() + } else { + label_extents.height() + LABEL_KEYCAP_GAP + keycap_height + }; + let stack_top = item.bounds.y + (item.bounds.height - stack_height) / 2.0; + let text_color = if !enabled { + overlay::TEXT_TERTIARY + } else if primary { + overlay::TEXT_WHITE + } else { + overlay::TEXT_PRIMARY + }; + + theme::set_color(ctx, text_color); + layout.show_at_baseline( + ctx, + center_x - label_extents.width() / 2.0 - label_extents.x_bearing(), + stack_top - label_extents.y_bearing(), + ); + + if !shortcut.is_empty() { + draw_keycap_with_engine( + engine, + ctx, + center_x - keycap_width / 2.0, + stack_top + label_extents.height() + LABEL_KEYCAP_GAP, + shortcut, + KEYCAP_FONT_SIZE, + if primary { + KEYCAP_BG_ON_ACCENT + } else { + KEYCAP_BG + }, + if primary { + overlay::TEXT_WHITE + } else { + overlay::TEXT_HINT + }, + ); + } + let _ = ctx.restore(); +} + +pub(super) fn draw_toggle( + engine: &UiTextEngine, + ctx: &cairo::Context, + item: RegionActionItem, + hovered: Option, + checked: bool, +) { + if item.bounds.width <= 0.0 || item.bounds.height <= 0.0 { + return; + } + let _ = ctx.save(); + ctx.rectangle( + item.bounds.x, + item.bounds.y, + item.bounds.width, + item.bounds.height, + ); + ctx.clip(); + + // The row itself stays quiet: the checkbox carries the on/off state, so an + // enabled toggle no longer paints a full-width slab across the bar. + if hovered == Some(item.action) { + theme::set_color(ctx, TOGGLE_BG_HOVER); + draw_rounded_rect( + ctx, + item.bounds.x, + item.bounds.y, + item.bounds.width, + item.bounds.height, + ITEM_RADIUS, + ); + let _ = ctx.fill(); + } + + let box_size = CHECKBOX_SIZE.min(item.bounds.height - 2.0).max(0.0); + let box_x = item.bounds.x + 6.0; + let box_y = item.bounds.y + (item.bounds.height - box_size) / 2.0; + draw_checkbox(ctx, box_x, box_y, box_size, checked); + + let label = item.action.label(); + let layout = engine.layout(ctx, toggle_label_style(), label, None); + let extents = layout.ink_extents(); + theme::set_color( + ctx, + if checked { + overlay::TEXT_PRIMARY + } else { + overlay::TEXT_TERTIARY + }, + ); + layout.show_at_baseline( + ctx, + box_x + box_size + 8.0 - extents.x_bearing(), + item.bounds.y + (item.bounds.height - extents.height()) / 2.0 - extents.y_bearing(), + ); + + let (keycap_width, keycap_height) = + keycap_size_with_engine(engine, ctx, item.action.shortcut(), KEYCAP_FONT_SIZE); + draw_keycap_with_engine( + engine, + ctx, + item.bounds.x + item.bounds.width - 6.0 - keycap_width, + item.bounds.y + (item.bounds.height - keycap_height) / 2.0, + item.action.shortcut(), + KEYCAP_FONT_SIZE, + KEYCAP_BG, + overlay::TEXT_HINT, + ); + let _ = ctx.restore(); +} + +fn draw_checkbox(ctx: &cairo::Context, x: f64, y: f64, size: f64, checked: bool) { + if size <= 0.0 { + return; + } + theme::set_color( + ctx, + if checked { + CHECKBOX_BG_CHECKED + } else { + CHECKBOX_BG + }, + ); + draw_rounded_rect(ctx, x, y, size, size, overlay::RADIUS_SM); + let _ = ctx.fill(); + + theme::set_color( + ctx, + if checked { + theme::rgba(theme::ACCENT_BRIGHT_RGB, 0.8) + } else { + CHECKBOX_BORDER + }, + ); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + x + 0.5, + y + 0.5, + (size - 1.0).max(0.0), + (size - 1.0).max(0.0), + (overlay::RADIUS_SM - 0.5).max(0.0), + ); + let _ = ctx.stroke(); + + if !checked { + return; + } + theme::set_color(ctx, overlay::TEXT_WHITE); + ctx.set_line_width((size * 0.14).max(1.4)); + ctx.set_line_cap(cairo::LineCap::Round); + ctx.set_line_join(cairo::LineJoin::Round); + ctx.move_to(x + size * 0.26, y + size * 0.52); + ctx.line_to(x + size * 0.44, y + size * 0.70); + ctx.line_to(x + size * 0.76, y + size * 0.32); + let _ = ctx.stroke(); +} + +fn label_style() -> UiTextStyle<'static> { + UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: LABEL_FONT_SIZE, + } +} + +fn toggle_label_style() -> UiTextStyle<'static> { + status_label_style(TOGGLE_FONT_SIZE) +} diff --git a/src/ui/region_action_bar/layout.rs b/src/ui/region_action_bar/layout.rs new file mode 100644 index 000000000..49756591a --- /dev/null +++ b/src/ui/region_action_bar/layout.rs @@ -0,0 +1,197 @@ +use super::model::{RegionAction, RegionActionAvailability}; +use crate::input::state::RegionSelection; + +const SURFACE_MARGIN: f64 = 8.0; +pub(super) const SELECTION_GAP: f64 = 12.0; +pub(super) const BAR_PADDING: f64 = 8.0; +const ITEM_GAP: f64 = 6.0; +/// Vertical gap between the action row and the drawings toggle. The hairline +/// divider is centred in it. +pub(super) const ROW_GAP: f64 = 8.0; +const ACTION_ROW_HEIGHT: f64 = 38.0; +const EDIT_ROW_HEIGHT: f64 = 28.0; +const TOGGLE_ROW_HEIGHT: f64 = 26.0; +pub(super) const STATUS_ROW_HEIGHT: f64 = 16.0; +pub(super) const BAR_HEIGHT: f64 = BAR_PADDING * 2.0 + + ACTION_ROW_HEIGHT + + ROW_GAP + + EDIT_ROW_HEIGHT + + ROW_GAP + + TOGGLE_ROW_HEIGHT + + ROW_GAP + + STATUS_ROW_HEIGHT; +/// Resting width of one action control. The bar sizes itself from this instead +/// of stretching controls across an arbitrary fixed width. +pub(super) const ACTION_ITEM_WIDTH: f64 = 74.0; +const BAR_WIDTH: f64 = BAR_PADDING * 2.0 + ACTION_ITEM_WIDTH * 4.0 + ITEM_GAP * 3.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionActionRect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl RegionActionRect { + pub(crate) const fn new(x: f64, y: f64, width: f64, height: f64) -> Self { + Self { + x, + y, + width, + height, + } + } + + pub(super) fn contains(self, point: (f64, f64)) -> bool { + point.0 >= self.x + && point.0 < self.x + self.width + && point.1 >= self.y + && point.1 < self.y + self.height + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct RegionActionItem { + pub(super) action: RegionAction, + pub(super) bounds: RegionActionRect, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionActionBar { + pub(super) bounds: RegionActionRect, + pub(super) items: [RegionActionItem; 4], + pub(super) edit: [RegionActionItem; 4], + pub(super) toggle: RegionActionItem, +} + +impl RegionActionBar { + pub(crate) fn place(selection: RegionSelection, surface: (u32, u32)) -> Self { + let left = selection.start.0.min(selection.end.0); + let right = selection.start.0.max(selection.end.0); + let top = selection.start.1.min(selection.end.1); + let bottom = selection.start.1.max(selection.end.1); + let surface_width = f64::from(surface.0); + let surface_height = f64::from(surface.1); + let width = BAR_WIDTH.min((surface_width - SURFACE_MARGIN * 2.0).max(0.0)); + let height = BAR_HEIGHT.min((surface_height - SURFACE_MARGIN * 2.0).max(0.0)); + let x = ((left + right - width) / 2.0).clamp( + SURFACE_MARGIN, + (surface_width - width - SURFACE_MARGIN).max(SURFACE_MARGIN), + ); + let below = bottom + SELECTION_GAP; + let preferred_y = if below + height + SURFACE_MARGIN <= surface_height { + below + } else { + top - SELECTION_GAP - height + }; + let y = preferred_y.clamp( + SURFACE_MARGIN, + (surface_height - height - SURFACE_MARGIN).max(SURFACE_MARGIN), + ); + let bounds = RegionActionRect::new(x, y, width, height); + let width_scale = if BAR_WIDTH > 0.0 { + (width / BAR_WIDTH).clamp(0.0, 1.0) + } else { + 0.0 + }; + let height_scale = if BAR_HEIGHT > 0.0 { + (height / BAR_HEIGHT).clamp(0.0, 1.0) + } else { + 0.0 + }; + let pad_x = BAR_PADDING * width_scale; + let pad_y = BAR_PADDING * height_scale; + let item_gap = ITEM_GAP * width_scale; + let row_gap = ROW_GAP * height_scale; + let action_height = ACTION_ROW_HEIGHT * height_scale; + let edit_height = EDIT_ROW_HEIGHT * height_scale; + let toggle_height = TOGGLE_ROW_HEIGHT * height_scale; + let item_count = 4.0; + let item_width = + ((width - pad_x * 2.0 - item_gap * (item_count - 1.0)) / item_count).max(0.0); + let row_item = |index: usize, action, row_y, row_height| RegionActionItem { + action, + bounds: RegionActionRect::new( + x + pad_x + index as f64 * (item_width + item_gap), + row_y, + item_width, + row_height, + ), + }; + let action_y = y + pad_y; + let edit_y = action_y + action_height + row_gap; + let toggle_y = edit_y + edit_height + row_gap; + let toggle = RegionActionItem { + action: RegionAction::ToggleIncludeDrawings, + bounds: RegionActionRect::new( + x + pad_x, + toggle_y, + (width - pad_x * 2.0).max(0.0), + toggle_height, + ), + }; + Self { + bounds, + items: [ + row_item(0, RegionAction::Copy, action_y, action_height), + row_item(1, RegionAction::Save, action_y, action_height), + row_item(2, RegionAction::Both, action_y, action_height), + row_item(3, RegionAction::Board, action_y, action_height), + ], + edit: [ + row_item(0, RegionAction::CutBand, edit_y, edit_height), + row_item(1, RegionAction::UndoCut, edit_y, edit_height), + row_item(2, RegionAction::RedoCut, edit_y, edit_height), + row_item(3, RegionAction::ResetCuts, edit_y, edit_height), + ], + toggle, + } + } + + /// The painted frame, without its drop shadow. The picker uses it to keep + /// the Review size badge out from under the bar. + pub(crate) const fn bounds(&self) -> RegionActionRect { + self.bounds + } + + pub(crate) fn hit(&self, point: (f64, f64)) -> Option { + self.items + .iter() + .chain(self.edit.iter()) + .find(|item| item.bounds.contains(point)) + .map(|item| item.action) + .or_else(|| { + self.toggle + .bounds + .contains(point) + .then_some(self.toggle.action) + }) + } + + pub(crate) fn enabled_hit( + &self, + point: (f64, f64), + availability: RegionActionAvailability, + ) -> Option { + self.hit(point) + .filter(|&action| availability.allows(action)) + } + + pub(crate) fn contains(&self, point: (f64, f64)) -> bool { + self.bounds.contains(point) + } + + pub(super) fn status_bounds(&self) -> Option { + let toggle = self.toggle.bounds; + if toggle.width <= 0.0 { + return None; + } + let pad_y = (self.items[0].bounds.y - self.bounds.y).max(0.0); + let row_gap = + (self.edit[0].bounds.y - self.items[0].bounds.y - self.items[0].bounds.height).max(0.0); + let y = toggle.y + toggle.height + row_gap; + let height = (self.bounds.y + self.bounds.height - pad_y - y).max(0.0); + (height > 0.0).then(|| RegionActionRect::new(toggle.x, y, toggle.width, height)) + } +} diff --git a/src/ui/region_action_bar/model.rs b/src/ui/region_action_bar/model.rs new file mode 100644 index 000000000..3ed73a061 --- /dev/null +++ b/src/ui/region_action_bar/model.rs @@ -0,0 +1,128 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RegionAction { + Copy, + Save, + Both, + Board, + CutBand, + UndoCut, + RedoCut, + ResetCuts, + ToggleIncludeDrawings, +} + +impl RegionAction { + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Copy => "Copy", + Self::Save => "Save", + Self::Both => "Both", + Self::Board => "Board", + Self::CutBand => "Cut", + Self::UndoCut => "Undo", + Self::RedoCut => "Redo", + Self::ResetCuts => "Reset", + Self::ToggleIncludeDrawings => "Include drawings in exports", + } + } + + pub(crate) const fn shortcut(self) -> &'static str { + match self { + Self::Copy => "Ctrl+C", + Self::Save => "Ctrl+S", + Self::Both => "Enter", + Self::Board => "B", + Self::CutBand => "X", + Self::UndoCut => "Ctrl+Z", + Self::RedoCut => "Ctrl+Y", + Self::ResetCuts => "", + Self::ToggleIncludeDrawings => "D", + } + } + + /// Destinations that leave Review. Edit controls stay in the picker. + pub(crate) const fn is_terminal(self) -> bool { + matches!(self, Self::Copy | Self::Save | Self::Both | Self::Board) + } + + /// The accented default action: the one `Enter` submits. + pub(super) const fn is_primary(self) -> bool { + matches!(self, Self::Both) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RegionActionAvailability { + pub terminal: bool, + pub cut: bool, + pub undo: bool, + pub redo: bool, + pub reset: bool, +} + +impl RegionActionAvailability { + /// Resting Review bar: terminals and Cut enabled, history empty. + pub(crate) const DEFAULT: Self = Self { + terminal: true, + cut: true, + undo: false, + redo: false, + reset: false, + }; + + pub(crate) const fn allows(self, action: RegionAction) -> bool { + match action { + RegionAction::Copy | RegionAction::Save | RegionAction::Both | RegionAction::Board => { + self.terminal + } + RegionAction::CutBand => self.cut, + RegionAction::UndoCut => self.undo, + RegionAction::RedoCut => self.redo, + RegionAction::ResetCuts => self.reset, + RegionAction::ToggleIncludeDrawings => true, + } + } +} + +impl Default for RegionActionAvailability { + fn default() -> Self { + Self::DEFAULT + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RegionCutStatus { + Updating, + Failed, +} + +impl RegionCutStatus { + pub(super) const fn message(self) -> &'static str { + match self { + Self::Updating => "Updating cut preview…", + Self::Failed => "Cut preview failed", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RegionActionBarVisual { + pub hovered: Option, + pub include_drawings: bool, + pub availability: RegionActionAvailability, + pub cut_armed: bool, + pub status: Option, +} + +#[cfg(test)] +impl RegionActionBarVisual { + pub(crate) const fn simple(hovered: Option, include_drawings: bool) -> Self { + Self { + hovered, + include_drawings, + availability: RegionActionAvailability::DEFAULT, + cut_armed: false, + status: None, + } + } +} diff --git a/src/ui/region_action_bar/render.rs b/src/ui/region_action_bar/render.rs new file mode 100644 index 000000000..8d34e23d2 --- /dev/null +++ b/src/ui/region_action_bar/render.rs @@ -0,0 +1,164 @@ +use super::controls::{draw_action, draw_toggle}; +use super::layout::{RegionActionBar, RegionActionRect, STATUS_ROW_HEIGHT}; +use super::model::{RegionAction, RegionActionBarVisual, RegionCutStatus}; +use super::{TOGGLE_FONT_SIZE, status_label_style}; +use crate::ui::primitives::draw_rounded_rect; +use crate::ui::theme::{self, Rgba, overlay}; +use crate::ui_text::UiTextEngine; + +const BAR_RADIUS: f64 = overlay::RADIUS_PANEL; +/// Downward-only two-layer drop shadow, matching the command palette frame, so +/// the bar reads as floating above the frozen screenshot rather than painted +/// into it. +const SHADOW_OFFSET: f64 = 8.0; +const SHADOW_SOFT: Rgba = (0.0, 0.0, 0.0, 0.20); + +pub(crate) fn render_region_action_bar( + engine: &UiTextEngine, + ctx: &cairo::Context, + bar: &RegionActionBar, + visual: RegionActionBarVisual, +) { + let _ = ctx.save(); + draw_bar_frame(ctx, bar.bounds); + + for &item in &bar.items { + draw_action( + engine, + ctx, + item, + visual.hovered == Some(item.action), + visual.availability.allows(item.action), + false, + ); + } + draw_row_divider( + ctx, + bar.items[0].bounds, + bar.edit[0].bounds.y, + bar.toggle.bounds.width, + ); + for &item in &bar.edit { + draw_action( + engine, + ctx, + item, + visual.hovered == Some(item.action), + visual.availability.allows(item.action), + visual.cut_armed && item.action == RegionAction::CutBand, + ); + } + draw_row_divider( + ctx, + bar.edit[0].bounds, + bar.toggle.bounds.y, + bar.toggle.bounds.width, + ); + draw_toggle( + engine, + ctx, + bar.toggle, + visual.hovered, + visual.include_drawings, + ); + draw_status(engine, ctx, bar, visual.status); + let _ = ctx.restore(); +} + +fn draw_bar_frame(ctx: &cairo::Context, bounds: RegionActionRect) { + if bounds.width <= 0.0 || bounds.height <= 0.0 { + return; + } + for (offset, color) in [ + (SHADOW_OFFSET, SHADOW_SOFT), + (SHADOW_OFFSET * 0.5, overlay::SHADOW), + ] { + theme::set_color(ctx, color); + draw_rounded_rect( + ctx, + bounds.x, + bounds.y + offset, + bounds.width, + bounds.height, + BAR_RADIUS, + ); + let _ = ctx.fill(); + } + + theme::set_color(ctx, crate::ui::theme::popup::bg_context_menu()); + draw_rounded_rect( + ctx, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + BAR_RADIUS, + ); + let _ = ctx.fill(); + + theme::set_color(ctx, crate::ui::theme::popup::border_context_menu()); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + bounds.x + 0.5, + bounds.y + 0.5, + bounds.width - 1.0, + bounds.height - 1.0, + BAR_RADIUS - 0.5, + ); + let _ = ctx.stroke(); +} + +/// Hairline between stacked rows, inset from the bar's padding so it reads as +/// a grouping rule rather than a border. +fn draw_row_divider(ctx: &cairo::Context, row: RegionActionRect, next_y: f64, width: f64) { + if width <= 0.0 || row.height <= 0.0 { + return; + } + let gap = (next_y - row.y - row.height).max(0.0); + if gap <= 0.0 { + return; + } + let y = (row.y + row.height + gap / 2.0).floor() + 0.5; + theme::set_color(ctx, overlay::DIVIDER_LIGHT); + ctx.set_line_width(1.0); + ctx.move_to(row.x, y); + ctx.line_to(row.x + width, y); + let _ = ctx.stroke(); +} + +fn draw_status( + engine: &UiTextEngine, + ctx: &cairo::Context, + bar: &RegionActionBar, + status: Option, +) { + let Some(status) = status else { + return; + }; + let Some(row) = bar.status_bounds() else { + return; + }; + let font_size = (TOGGLE_FONT_SIZE * (row.height / STATUS_ROW_HEIGHT).min(1.0)).max(0.0); + if font_size < 1.0 { + return; + } + let _ = ctx.save(); + ctx.rectangle(row.x, row.y, row.width, row.height); + ctx.clip(); + let layout = engine.layout(ctx, status_label_style(font_size), status.message(), None); + let extents = layout.ink_extents(); + theme::set_color( + ctx, + match status { + RegionCutStatus::Updating => overlay::TEXT_HINT, + RegionCutStatus::Failed => overlay::TEXT_PRIMARY, + }, + ); + layout.show_at_baseline( + ctx, + row.x + (row.width - extents.width()) / 2.0 - extents.x_bearing(), + row.y + (row.height - extents.height()) / 2.0 - extents.y_bearing(), + ); + let _ = ctx.restore(); +} diff --git a/src/ui/region_action_bar/tests.rs b/src/ui/region_action_bar/tests.rs new file mode 100644 index 000000000..3812af940 --- /dev/null +++ b/src/ui/region_action_bar/tests.rs @@ -0,0 +1,419 @@ +use super::layout::{ACTION_ITEM_WIDTH, BAR_HEIGHT, BAR_PADDING, ROW_GAP, SELECTION_GAP}; +use super::*; +use crate::input::state::RegionSelection; +use crate::ui_text::UiTextEngine; + +fn sample_bar() -> RegionActionBar { + RegionActionBar::place( + RegionSelection { + start: (100.0, 100.0), + end: (300.0, 200.0), + }, + (800, 600), + ) +} + +fn rect_inside(inner: RegionActionRect, outer: RegionActionRect) -> bool { + inner.x + f64::EPSILON >= outer.x + && inner.y + f64::EPSILON >= outer.y + && inner.x + inner.width <= outer.x + outer.width + f64::EPSILON + && inner.y + inner.height <= outer.y + outer.height + f64::EPSILON +} + +fn assert_controls_stay_inside_bar(bar: &RegionActionBar) { + let bounds = bar.bounds(); + for item in bar.items.iter().chain(bar.edit.iter()) { + assert!( + rect_inside(item.bounds, bounds), + "{:?} at ({}, {}) {}x{} leaves bar {bounds:?}", + item.action, + item.bounds.x, + item.bounds.y, + item.bounds.width, + item.bounds.height + ); + } + assert!( + rect_inside(bar.toggle.bounds, bounds), + "toggle leaves bar {bounds:?}" + ); + for row in [&bar.items[..], &bar.edit[..]] { + for pair in row.windows(2) { + assert!( + pair[0].bounds.x + pair[0].bounds.width <= pair[1].bounds.x + f64::EPSILON, + "{:?} overlaps {:?}", + pair[0].action, + pair[1].action + ); + } + } + assert!( + bar.items[0].bounds.y + bar.items[0].bounds.height <= bar.edit[0].bounds.y + f64::EPSILON + ); + assert!(bar.edit[0].bounds.y + bar.edit[0].bounds.height <= bar.toggle.bounds.y + f64::EPSILON); +} + +#[test] +fn action_bar_prefers_below_then_flips_above_and_clamps_to_the_surface() { + let centered = sample_bar(); + assert_eq!( + centered.bounds(), + RegionActionRect::new(35.0, 212.0, 330.0, BAR_HEIGHT) + ); + + let flipped = RegionActionBar::place( + RegionSelection { + start: (730.0, 560.0), + end: (790.0, 590.0), + }, + (800, 600), + ); + assert_eq!( + flipped.bounds(), + RegionActionRect::new(462.0, 560.0 - SELECTION_GAP - BAR_HEIGHT, 330.0, BAR_HEIGHT) + ); +} + +#[test] +fn action_bar_hit_returns_typed_controls_and_rejects_gaps() { + let bar = sample_bar(); + let action_y = bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0; + let edit_y = bar.edit[0].bounds.y + bar.edit[0].bounds.height / 2.0; + let toggle_y = bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0; + + assert_eq!(bar.hit((80.0, action_y)), Some(RegionAction::Copy)); + assert_eq!(bar.hit((160.0, action_y)), Some(RegionAction::Save)); + assert_eq!(bar.hit((240.0, action_y)), Some(RegionAction::Both)); + assert_eq!(bar.hit((320.0, action_y)), Some(RegionAction::Board)); + assert_eq!(bar.hit((80.0, edit_y)), Some(RegionAction::CutBand)); + assert_eq!(bar.hit((160.0, edit_y)), Some(RegionAction::UndoCut)); + assert_eq!(bar.hit((240.0, edit_y)), Some(RegionAction::RedoCut)); + assert_eq!(bar.hit((320.0, edit_y)), Some(RegionAction::ResetCuts)); + assert_eq!( + bar.hit((200.0, toggle_y)), + Some(RegionAction::ToggleIncludeDrawings) + ); + assert_eq!(bar.hit((119.0, action_y)), None, "inter-item gap"); + assert!(bar.contains((119.0, action_y)), "bar gaps stay modal-owned"); + assert_eq!(bar.hit((20.0, 20.0)), None, "outside the bar"); + assert!(!bar.contains((20.0, 20.0))); +} + +#[test] +fn disabled_controls_still_consume_the_bar_but_return_no_enabled_action() { + let bar = sample_bar(); + let availability = RegionActionAvailability { + terminal: false, + cut: true, + undo: false, + redo: false, + reset: false, + }; + let action_y = bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0; + assert_eq!(bar.hit((80.0, action_y)), Some(RegionAction::Copy)); + assert_eq!(bar.enabled_hit((80.0, action_y), availability), None); + assert!(bar.contains((80.0, action_y))); + assert_eq!( + bar.enabled_hit( + ( + bar.edit[0].bounds.x + bar.edit[0].bounds.width / 2.0, + bar.edit[0].bounds.y + bar.edit[0].bounds.height / 2.0 + ), + availability + ), + Some(RegionAction::CutBand) + ); +} + +#[test] +fn action_bar_rows_never_overlap_and_stay_inside_the_padded_frame() { + let bar = sample_bar(); + let bounds = bar.bounds(); + let toggle = bar.toggle.bounds; + + for item in bar.items { + assert!(item.bounds.y >= bounds.y + BAR_PADDING); + assert!( + item.bounds.y + item.bounds.height <= bar.edit[0].bounds.y - ROW_GAP + f64::EPSILON + ); + assert!(item.bounds.x >= bounds.x + BAR_PADDING); + assert!(item.bounds.x + item.bounds.width <= bounds.x + bounds.width - BAR_PADDING); + assert_eq!(item.bounds.width, ACTION_ITEM_WIDTH); + } + for item in bar.edit { + assert!(item.bounds.y >= bar.items[0].bounds.y + bar.items[0].bounds.height); + assert!(item.bounds.y + item.bounds.height <= toggle.y - ROW_GAP + f64::EPSILON); + assert_eq!(item.bounds.width, ACTION_ITEM_WIDTH); + } + assert!(toggle.y + toggle.height <= bounds.y + bounds.height - BAR_PADDING); +} + +#[test] +fn narrow_and_short_surfaces_keep_controls_inside_the_bar() { + let selection = RegionSelection { + start: (10.0, 10.0), + end: (40.0, 30.0), + }; + for surface in [(200, 80), (80, 40), (40, 600), (800, 36)] { + let bar = RegionActionBar::place(selection, surface); + assert_controls_stay_inside_bar(&bar); + let action = bar.items[0].bounds; + if action.width > 1.0 && action.height > 1.0 { + assert_eq!( + bar.hit(( + action.x + action.width / 2.0, + action.y + action.height / 2.0 + )), + Some(RegionAction::Copy), + "typed hit on {surface:?}" + ); + } + } +} + +#[test] +fn action_bar_exposes_the_requested_labels_and_shortcuts() { + assert_eq!(RegionAction::Copy.label(), "Copy"); + assert_eq!(RegionAction::Copy.shortcut(), "Ctrl+C"); + assert_eq!(RegionAction::Save.label(), "Save"); + assert_eq!(RegionAction::Save.shortcut(), "Ctrl+S"); + assert_eq!(RegionAction::Both.label(), "Both"); + assert_eq!(RegionAction::Both.shortcut(), "Enter"); + assert_eq!(RegionAction::Board.label(), "Board"); + assert_eq!(RegionAction::Board.shortcut(), "B"); + assert_eq!(RegionAction::CutBand.label(), "Cut"); + assert_eq!(RegionAction::CutBand.shortcut(), "X"); + assert_eq!(RegionAction::UndoCut.shortcut(), "Ctrl+Z"); + assert_eq!(RegionAction::RedoCut.shortcut(), "Ctrl+Y"); + assert_eq!( + RegionAction::ToggleIncludeDrawings.label(), + "Include drawings in exports" + ); + assert_eq!(RegionAction::ToggleIncludeDrawings.shortcut(), "D"); + assert!(RegionAction::Copy.is_terminal()); + assert!(!RegionAction::CutBand.is_terminal()); + assert!(!RegionAction::ToggleIncludeDrawings.is_terminal()); +} + +#[test] +fn enter_is_the_only_accented_default_action() { + assert!(RegionAction::Both.is_primary()); + for action in [ + RegionAction::Copy, + RegionAction::Save, + RegionAction::Board, + RegionAction::CutBand, + RegionAction::UndoCut, + RegionAction::RedoCut, + RegionAction::ResetCuts, + RegionAction::ToggleIncludeDrawings, + ] { + assert!(!action.is_primary(), "{action:?} must stay neutral"); + } +} + +#[test] +fn rendering_paints_the_bar_and_each_control() { + let bar = sample_bar(); + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_action_bar( + &UiTextEngine::default(), + &ctx, + &bar, + RegionActionBarVisual::simple(Some(RegionAction::Both), true), + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; + let action_y = (bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0) as usize; + let toggle_y = (bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0) as usize; + + assert!(alpha(40, action_y) > 0, "bar surface"); + for x in [80, 160, 240, 320] { + assert!(alpha(x, action_y) > 0, "control at x={x}"); + } + assert!(alpha(56, toggle_y) > 0, "checked drawings checkbox"); + assert_eq!(alpha(20, 20), 0, "outside remains untouched"); +} + +#[test] +fn the_drawings_checkbox_carries_the_state_instead_of_a_full_width_slab() { + let bar = sample_bar(); + let toggle_y = (bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0) as usize; + let row_alpha = |checked: bool| { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_action_bar( + &UiTextEngine::default(), + &ctx, + &bar, + RegionActionBarVisual::simple(None, checked), + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + ( + u32::from(data[toggle_y * stride + 300 * 4 + 3]), + u32::from(data[toggle_y * stride + 56 * 4 + 3]), + ) + }; + + let (off_row, off_box) = row_alpha(false); + let (on_row, on_box) = row_alpha(true); + assert_eq!(off_row, on_row, "the row background must not change"); + assert!(on_box > 0 && off_box > 0, "the box is drawn either way"); +} + +#[test] +fn updating_and_failed_preview_states_paint_status_text() { + let bar = sample_bar(); + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_action_bar( + &UiTextEngine::default(), + &ctx, + &bar, + RegionActionBarVisual { + hovered: None, + include_drawings: false, + availability: RegionActionAvailability { + terminal: false, + cut: true, + undo: true, + redo: false, + reset: true, + }, + cut_armed: true, + status: Some(RegionCutStatus::Updating), + }, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let status = bar.status_bounds().unwrap(); + let status_y = (status.y + status.height / 2.0) as usize; + let alpha = data[status_y * stride + 200 * 4 + 3]; + assert!(alpha > 0, "status caption is visible"); +} + +fn paint_bar( + width: i32, + height: i32, + bar: RegionActionBar, + status: Option, +) -> (usize, Vec) { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_action_bar( + &UiTextEngine::default(), + &ctx, + &bar, + RegionActionBarVisual { + hovered: None, + include_drawings: false, + availability: RegionActionAvailability { + terminal: false, + cut: true, + undo: true, + redo: false, + reset: true, + }, + cut_armed: false, + status, + }, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let pixels = surface.data().unwrap().to_vec(); + (stride, pixels) +} + +#[test] +fn short_surface_status_paint_stays_inside_its_row() { + let selection = RegionSelection { + start: (10.0, 10.0), + end: (40.0, 30.0), + }; + for surface in [(800, 36), (800, 100), (200, 80)] { + let bar = RegionActionBar::place(selection, surface); + let width = i32::try_from(surface.0).unwrap(); + let height = i32::try_from(surface.1).unwrap(); + let (stride, without) = paint_bar(width, height, bar, None); + let (_, with_status) = paint_bar(width, height, bar, Some(RegionCutStatus::Failed)); + let row = bar.status_bounds(); + for y in 0..surface.1 as usize { + for x in 0..surface.0 as usize { + let offset = y * stride + x * 4; + if without[offset..offset + 4] == with_status[offset..offset + 4] { + continue; + } + let Some(row) = row else { + panic!("status painted with no status row on {surface:?}"); + }; + assert!( + row.contains((x as f64 + 0.5, y as f64 + 0.5)), + "status paint at ({x}, {y}) left the {row:?} row on {surface:?}" + ); + assert!( + bar.bounds.contains((x as f64 + 0.5, y as f64 + 0.5)), + "status paint at ({x}, {y}) left the bar on {surface:?}" + ); + } + } + } +} +#[test] +fn retained_text_owner_matches_fresh_bar_pixels_across_status_and_density() { + let engine = UiTextEngine::default(); + for density in [1, 2, 1] { + for status in [ + None, + Some(RegionCutStatus::Updating), + Some(RegionCutStatus::Failed), + ] { + let paint = |engine: &UiTextEngine| { + let mut surface = cairo::ImageSurface::create( + cairo::Format::ARgb32, + 800 * density, + 600 * density, + ) + .unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.scale(f64::from(density), f64::from(density)); + render_region_action_bar( + engine, + &ctx, + &sample_bar(), + RegionActionBarVisual { + hovered: Some(RegionAction::CutBand), + include_drawings: status.is_none(), + availability: RegionActionAvailability { + terminal: status.is_none(), + cut: true, + undo: true, + redo: false, + reset: true, + }, + cut_armed: true, + status, + }, + ); + } + surface.data().unwrap().to_vec() + }; + let actual = paint(&engine); + assert!(actual.iter().any(|&byte| byte != 0)); + assert!( + actual == paint(&UiTextEngine::default()), + "retained action bar pixels differ" + ); + } + } +} From d5e99459b19c927fa03bfd7e969373c5a2e2b4e5 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:10:42 +0200 Subject: [PATCH 30/42] refactor(capture): separate cut history and review actions --- .../state/region_capture/cut_review.rs | 1420 +---------------- .../region_capture/cut_review/actions.rs | 384 +++++ .../region_capture/cut_review/geometry.rs | 143 ++ .../region_capture/cut_review/history.rs | 354 ++++ .../state/region_capture/cut_review/model.rs | 122 ++ .../state/region_capture/cut_review/tests.rs | 484 ++++++ 6 files changed, 1503 insertions(+), 1404 deletions(-) create mode 100644 src/backend/wayland/state/region_capture/cut_review/actions.rs create mode 100644 src/backend/wayland/state/region_capture/cut_review/geometry.rs create mode 100644 src/backend/wayland/state/region_capture/cut_review/history.rs create mode 100644 src/backend/wayland/state/region_capture/cut_review/model.rs create mode 100644 src/backend/wayland/state/region_capture/cut_review/tests.rs diff --git a/src/backend/wayland/state/region_capture/cut_review.rs b/src/backend/wayland/state/region_capture/cut_review.rs index c743b232c..e3969c127 100644 --- a/src/backend/wayland/state/region_capture/cut_review.rs +++ b/src/backend/wayland/state/region_capture/cut_review.rs @@ -1,1408 +1,20 @@ -use crate::capture::{CutAxis, CutBand, output_size}; -use crate::input::InputState; -use crate::input::state::{RegionInputSource, RegionSelection}; -use crate::screen_pixels::{ImagePixelRect, ImagePoint, pixel_span}; -use crate::util::Rect; +//! Region cut values, edit history, geometry, and backend actions. -use super::super::screen_image::{ScreenSourceToken, screen_rect_for_native_extent}; -use super::*; -use crate::ui::{RegionActionAvailability, RegionCutStatus}; - -pub(super) const CUT_DRAG_THRESHOLD_PX: f64 = 4.0; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum PreviewApply { - Ignored, - Changed, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::backend::wayland) enum CutMode { - Idle, - Armed, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(in crate::backend::wayland) struct CutDrag { - pub(super) owner: RegionInputSource, - pub(super) start: (f64, f64), - pub(super) current: (f64, f64), - pub(super) axis: Option, -} - -#[derive(Debug, Clone, PartialEq)] -pub(in crate::backend::wayland) struct RegionReviewCorrelation { - pub(super) generation: u64, - pub(super) source: ScreenSourceToken, -} - -/// Board and overlay facts that distinguish two annotated region renders. -/// Fingerprint and snapshot construction share one of these so halo, Spotlight, -/// and board identity cannot describe different frames. -#[derive(Debug, Clone, PartialEq)] -pub(super) struct RegionAnnotatedRenderContext { - pub(super) board_id: String, - pub(super) page_index: usize, - pub(super) page_generation: u64, - pub(super) canvas_content_generation: u64, - pub(super) board_view_offset: (f64, f64), - pub(super) text_halo_enabled: bool, - pub(super) spotlight: crate::canvas_export::SpotlightPassSnapshot, -} - -#[derive(Debug, Clone, PartialEq)] -pub(in crate::backend::wayland) enum RegionRenderFingerprint { - Raw { - correlation: RegionReviewCorrelation, - source_rect: ImagePixelRect, - }, - Annotated { - correlation: RegionReviewCorrelation, - source_rect: ImagePixelRect, - context: RegionAnnotatedRenderContext, - }, -} - -impl RegionRenderFingerprint { - pub(super) fn correlation(&self) -> &RegionReviewCorrelation { - match self { - Self::Raw { correlation, .. } | Self::Annotated { correlation, .. } => correlation, - } - } - - pub(super) fn source_rect(&self) -> ImagePixelRect { - match self { - Self::Raw { source_rect, .. } | Self::Annotated { source_rect, .. } => *source_rect, - } - } - - pub(super) fn include_drawings(&self) -> bool { - matches!(self, Self::Annotated { .. }) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub(in crate::backend::wayland) struct CutPreviewKey { - pub(super) fingerprint: RegionRenderFingerprint, - pub(super) revision: u64, - pub(super) cuts: Vec, -} - -#[derive(Debug, Clone)] -pub(in crate::backend::wayland) struct RegionCutPreview { - pub(super) key: CutPreviewKey, - pub(super) pixels: std::sync::Arc, - pub(super) display: RegionSelection, -} - -#[derive(Debug, Clone)] -pub(in crate::backend::wayland) struct RegionCutBase { - pub(super) fingerprint: RegionRenderFingerprint, - pub(super) pixels: std::sync::Arc, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum CutCommit { - None, - Applied, - RejectedFullAxis, -} - -#[derive(Debug)] -pub(in crate::backend::wayland) struct RegionReviewEdits { - pub(super) correlation: RegionReviewCorrelation, - pub(super) source_rect: ImagePixelRect, - pub(super) mode: CutMode, - pub(super) drag: Option, - pub(super) cuts: Vec, - pub(super) redo: Vec, - pub(super) revision: u64, - pub(super) desired_preview: Option, - pub(super) ready_preview: Option, - pub(super) base_cache: Option, - pub(super) failed_revision: Option, -} - -pub(super) fn review_edits_for_active_region( - region: Option, - rect: ImagePixelRect, -) -> Option { - let Some(ActiveScreenRegion::Ready { - purpose: crate::input::state::RegionPurposeTag::CaptureInteractive, - generation, - source, - .. - }) = region - else { - return None; - }; - Some(RegionReviewEdits::new( - RegionReviewCorrelation { generation, source }, - rect, - )) -} - -impl RegionReviewEdits { - pub(super) fn new(correlation: RegionReviewCorrelation, source_rect: ImagePixelRect) -> Self { - Self { - correlation, - source_rect, - mode: CutMode::Idle, - drag: None, - cuts: Vec::new(), - redo: Vec::new(), - revision: 0, - desired_preview: None, - ready_preview: None, - base_cache: None, - failed_revision: None, - } - } - - pub(super) fn crop_locked(&self) -> bool { - !self.cuts.is_empty() - } - - pub(super) fn loupe_suppressed(&self) -> bool { - self.mode == CutMode::Armed || self.crop_locked() - } - - pub(super) fn preview_is_current(&self) -> bool { - match &self.desired_preview { - None => self.cuts.is_empty(), - Some(desired) => self - .ready_preview - .as_ref() - .is_some_and(|ready| ready.key == *desired), - } - } - - pub(super) fn can_start_cut_drag(&self) -> bool { - self.mode == CutMode::Armed && self.preview_is_current() && self.drag.is_none() - } - - /// A current failed preview must stay failed until undo, redo, or reset - /// changes the revision. Resubmitting the same revision churns workers. - pub(super) fn current_preview_failed(&self) -> bool { - self.desired_preview.as_ref().is_some_and(|desired| { - self.failed_revision - .is_some_and(|revision| revision == desired.revision) - }) - } - - pub(super) fn status(&self) -> Option { - if self.cuts.is_empty() { - return None; - } - if self - .failed_revision - .is_some_and(|revision| revision == self.revision) - { - return Some(RegionCutStatus::Failed); - } - if !self.preview_is_current() { - return Some(RegionCutStatus::Updating); - } - None - } - - pub(super) fn availability(&self) -> RegionActionAvailability { - let preview_ok = self.preview_is_current(); - RegionActionAvailability { - terminal: preview_ok, - cut: true, - undo: !self.cuts.is_empty(), - redo: !self.redo.is_empty(), - reset: self.crop_locked() || !self.redo.is_empty(), - } - } - - /// Toggle Cut arming. Returns the abandoned drag owner, if any, so the - /// caller can retire the matching `InputState` review-move owner. - pub(super) fn toggle_mode(&mut self) -> Option { - let abandoned = if self.mode == CutMode::Armed { - self.drag.take().map(|drag| drag.owner) - } else { - None - }; - self.mode = match self.mode { - CutMode::Idle => CutMode::Armed, - CutMode::Armed => CutMode::Idle, - }; - abandoned - } - - pub(super) fn disarm_mode(&mut self) -> bool { - if self.drag.is_some() { - self.drag = None; - return true; - } - if self.mode == CutMode::Armed { - self.mode = CutMode::Idle; - return true; - } - false - } - - fn bump_revision(&mut self) -> bool { - match self.revision.checked_add(1) { - Some(next) => { - self.revision = next; - true - } - None => false, - } - } - - pub(super) fn set_desired_from(&mut self, fingerprint: RegionRenderFingerprint) { - if self.cuts.is_empty() { - self.desired_preview = None; - self.ready_preview = None; - self.base_cache = None; - self.failed_revision = None; - return; - } - self.desired_preview = Some(CutPreviewKey { - fingerprint, - revision: self.revision, - cuts: self.cuts.clone(), - }); - } - - pub(super) fn output_size(&self) -> Option<(u32, u32)> { - output_size( - (self.source_rect.width(), self.source_rect.height()), - &self.cuts, - ) - .ok() - } - - pub(super) fn displayed_output_size(&self) -> Option<(u32, u32)> { - self.ready_preview - .as_ref() - .map(|preview| (preview.pixels.width(), preview.pixels.height())) - .or_else(|| { - if self.cuts.is_empty() { - Some((self.source_rect.width(), self.source_rect.height())) - } else { - None - } - }) - } - - pub(super) fn begin_drag(&mut self, owner: RegionInputSource, point: (f64, f64)) -> bool { - if !self.can_start_cut_drag() { - return false; - } - self.drag = Some(CutDrag { - owner, - start: point, - current: point, - axis: None, - }); - true - } - - pub(super) fn update_drag(&mut self, owner: RegionInputSource, point: (f64, f64)) -> bool { - let Some(drag) = self.drag.as_mut() else { - return false; - }; - if drag.owner != owner { - return false; - } - drag.current = point; - if drag.axis.is_none() { - let dx = point.0 - drag.start.0; - let dy = point.1 - drag.start.1; - if dx.hypot(dy) >= CUT_DRAG_THRESHOLD_PX { - drag.axis = Some(dominant_cut_axis(dx, dy)); - } - } - true - } - - pub(super) fn finish_drag( - &mut self, - owner: RegionInputSource, - point: (f64, f64), - display: RegionSelection, - fingerprint: RegionRenderFingerprint, - ) -> CutCommit { - if !self.drag.as_ref().is_some_and(|drag| drag.owner == owner) { - return CutCommit::None; - } - let _ = self.update_drag(owner, point); - let Some(drag) = self.drag.take() else { - return CutCommit::None; - }; - let Some(axis) = drag.axis else { - return CutCommit::None; - }; - let Some(output) = self.output_size() else { - return CutCommit::None; - }; - let Some(band) = quantized_cut(axis, display, output, drag.start, drag.current) else { - return CutCommit::None; - }; - let mut next_cuts = self.cuts.clone(); - next_cuts.push(band); - if output_size( - (self.source_rect.width(), self.source_rect.height()), - &next_cuts, - ) - .is_err() - { - return CutCommit::RejectedFullAxis; - } - if !self.bump_revision() { - return CutCommit::None; - } - self.cuts.push(band); - self.redo.clear(); - self.failed_revision = None; - self.set_desired_from(fingerprint); - CutCommit::Applied - } - - pub(super) fn undo(&mut self, fingerprint: RegionRenderFingerprint) -> bool { - let Some(cut) = self.cuts.pop() else { - return false; - }; - if !self.bump_revision() { - self.cuts.push(cut); - return false; - } - self.redo.push(cut); - self.drag = None; - self.failed_revision = None; - self.set_desired_from(fingerprint); - true - } - - pub(super) fn redo(&mut self, fingerprint: RegionRenderFingerprint) -> bool { - let Some(cut) = self.redo.pop() else { - return false; - }; - if !self.bump_revision() { - self.redo.push(cut); - return false; - } - self.cuts.push(cut); - self.drag = None; - self.failed_revision = None; - self.set_desired_from(fingerprint); - true - } - - pub(super) fn reset(&mut self) -> bool { - if self.cuts.is_empty() && self.redo.is_empty() && self.drag.is_none() { - return false; - } - if !self.bump_revision() { - return false; - } - self.drag = None; - self.cuts.clear(); - self.redo.clear(); - self.failed_revision = None; - self.set_desired_from(RegionRenderFingerprint::Raw { - correlation: self.correlation.clone(), - source_rect: self.source_rect, - }); - true - } - - pub(super) fn set_source_rect(&mut self, source_rect: ImagePixelRect) -> bool { - if self.crop_locked() || self.source_rect == source_rect { - return false; - } - self.source_rect = source_rect; - self.redo.clear(); - self.ready_preview = None; - self.base_cache = None; - self.desired_preview = None; - self.failed_revision = None; - true - } - - pub(super) fn invalidate_base(&mut self, fingerprint: RegionRenderFingerprint) { - if !self.cuts.is_empty() && !self.bump_revision() { - return; - } - self.base_cache = None; - self.ready_preview = None; - self.failed_revision = None; - self.set_desired_from(fingerprint); - } - - pub(super) fn mark_preview_failed(&mut self, key: &CutPreviewKey) -> bool { - if self.desired_preview.as_ref() != Some(key) { - return false; - } - self.failed_revision = Some(key.revision); - true - } -} - -pub(super) fn dominant_cut_axis(dx: f64, dy: f64) -> CutAxis { - if dy.abs() > dx.abs() { - CutAxis::Rows - } else { - CutAxis::Columns - } -} - -pub(super) fn output_display_for( - token: &ScreenSourceToken, - source_rect: ImagePixelRect, - cuts: &[CutBand], -) -> Option { - let size = output_size((source_rect.width(), source_rect.height()), cuts).ok()?; - native_extent_display(token, source_rect, size) -} - -pub(super) fn native_extent_display( - token: &ScreenSourceToken, - source_rect: ImagePixelRect, - size: (u32, u32), -) -> Option { - let rect = screen_rect_for_native_extent(token, (source_rect.x(), source_rect.y()), size)?; - Some(region_selection_from_rect(rect)) -} - -pub(super) fn region_selection_from_rect(rect: Rect) -> RegionSelection { - RegionSelection { - start: (f64::from(rect.x), f64::from(rect.y)), - end: ( - f64::from(rect.x.saturating_add(rect.width)), - f64::from(rect.y.saturating_add(rect.height)), - ), - } -} - -pub(super) fn display_contains(display: RegionSelection, point: (f64, f64)) -> bool { - let left = display.start.0.min(display.end.0); - let right = display.start.0.max(display.end.0); - let top = display.start.1.min(display.end.1); - let bottom = display.start.1.max(display.end.1); - point.0 >= left && point.0 < right && point.1 >= top && point.1 < bottom -} - -pub(super) fn logical_to_output_point( - display: RegionSelection, - output_size: (u32, u32), - point: (f64, f64), -) -> Option { - if output_size.0 == 0 || output_size.1 == 0 || !point.0.is_finite() || !point.1.is_finite() { - return None; - } - let left = display.start.0.min(display.end.0); - let right = display.start.0.max(display.end.0); - let top = display.start.1.min(display.end.1); - let bottom = display.start.1.max(display.end.1); - let width = right - left; - let height = bottom - top; - if width <= 0.0 || height <= 0.0 || !width.is_finite() || !height.is_finite() { - return None; - } - let x = ((point.0 - left) / width) * f64::from(output_size.0); - let y = ((point.1 - top) / height) * f64::from(output_size.1); - if !x.is_finite() || !y.is_finite() { - return None; - } - Some(ImagePoint::new( - x.clamp(0.0, f64::from(output_size.0)), - y.clamp(0.0, f64::from(output_size.1)), - )) -} - -fn quantized_cut( - axis: CutAxis, - display: RegionSelection, - output_size: (u32, u32), - start: (f64, f64), - current: (f64, f64), -) -> Option { - let first = logical_to_output_point(display, output_size, start)?; - let second = logical_to_output_point(display, output_size, current)?; - let span = pixel_span(first, second, output_size)?; - match axis { - CutAxis::Columns => { - CutBand::from_unordered_edges(axis, span.x(), span.x().checked_add(span.width())?).ok() - } - CutAxis::Rows => { - CutBand::from_unordered_edges(axis, span.y(), span.y().checked_add(span.height())?).ok() - } - } -} - -pub(super) fn cut_band_display( - display: RegionSelection, - output_size: (u32, u32), - axis: CutAxis, - start: u32, - end: u32, -) -> Option { - if end <= start || output_size.0 == 0 || output_size.1 == 0 { - return None; - } - let left = display.start.0.min(display.end.0); - let right = display.start.0.max(display.end.0); - let top = display.start.1.min(display.end.1); - let bottom = display.start.1.max(display.end.1); - let width = right - left; - let height = bottom - top; - match axis { - CutAxis::Columns => { - let x0 = left + f64::from(start) * width / f64::from(output_size.0); - let x1 = left + f64::from(end) * width / f64::from(output_size.0); - Some(RegionSelection { - start: (x0, top), - end: (x1, bottom), - }) - } - CutAxis::Rows => { - let y0 = top + f64::from(start) * height / f64::from(output_size.1); - let y1 = top + f64::from(end) * height / f64::from(output_size.1); - Some(RegionSelection { - start: (left, y0), - end: (right, y1), - }) - } - } -} - -fn retire_cut_drag_owner(input: &mut InputState, owner: Option) { - if let Some(owner) = owner { - let _ = input.finish_region_review_move(owner); - } -} - -fn apply_cut_history_change( - edits: &mut Option, - input: &mut InputState, - change: impl FnOnce(&mut RegionReviewEdits) -> bool, -) -> bool { - let owner = edits - .as_ref() - .and_then(|edits| edits.drag.map(|drag| drag.owner)); - let Some(edits) = edits.as_mut() else { - return false; - }; - if !change(edits) { - return false; - } - retire_cut_drag_owner(input, owner); - true -} - -impl WaylandState { - pub(in crate::backend::wayland) fn region_review_crop_locked(&self) -> bool { - self.region_capture - .review_edits() - .is_some_and(RegionReviewEdits::crop_locked) - } - - pub(in crate::backend::wayland) fn region_review_loupe_suppressed(&self) -> bool { - self.region_capture - .review_edits() - .is_some_and(RegionReviewEdits::loupe_suppressed) - } - - pub(in crate::backend::wayland) fn region_cut_displayed_selection( - &self, - ) -> Option { - let edits = self.region_capture.review_edits()?; - if let Some(preview) = &edits.ready_preview { - return Some(preview.display); - } - let token = self.region_picker_source_token()?; - output_display_for(&token, edits.source_rect, &[]) - } - - pub(in crate::backend::wayland) fn region_cut_availability(&self) -> RegionActionAvailability { - self.region_capture - .review_edits() - .map(RegionReviewEdits::availability) - .unwrap_or_default() - } - - pub(in crate::backend::wayland) fn region_cut_status(&self) -> Option { - self.region_capture - .review_edits() - .and_then(RegionReviewEdits::status) - } - - pub(in crate::backend::wayland) fn region_cut_mode_armed(&self) -> bool { - self.region_capture - .review_edits() - .is_some_and(|edits| edits.mode == CutMode::Armed) - } - - pub(super) fn create_region_review_edits(&mut self, rect: ImagePixelRect) { - self.region_capture.set_review_edits_for(rect); - } - - pub(super) fn mark_region_cut_ui_dirty(&mut self) { - self.input_state.dirty_tracker.mark_full(); - self.input_state.needs_redraw = true; - } - - pub(in crate::backend::wayland) fn apply_region_review_edit( - &mut self, - action: crate::ui::RegionAction, - ) -> bool { - match action { - crate::ui::RegionAction::CutBand => self.toggle_region_cut_mode(), - crate::ui::RegionAction::UndoCut => self.undo_region_cut(), - crate::ui::RegionAction::RedoCut => self.redo_region_cut(), - crate::ui::RegionAction::ResetCuts => self.reset_region_cuts(), - crate::ui::RegionAction::ToggleIncludeDrawings => { - self.toggle_region_picker_include_drawings() - } - crate::ui::RegionAction::Copy - | crate::ui::RegionAction::Save - | crate::ui::RegionAction::Both - | crate::ui::RegionAction::Board => false, - } - } - - fn retire_region_cut_drag_owner(&mut self, owner: Option) { - retire_cut_drag_owner(&mut self.input_state, owner); - } - - fn toggle_region_cut_mode(&mut self) -> bool { - let Some(edits) = self.region_capture.review_edits_mut() else { - return false; - }; - let owner = edits.toggle_mode(); - self.retire_region_cut_drag_owner(owner); - self.mark_region_cut_ui_dirty(); - true - } - - fn undo_region_cut(&mut self) -> bool { - let Some(fingerprint) = self.current_region_fingerprint() else { - return false; - }; - if !apply_cut_history_change( - self.region_capture.review_edits_slot_mut(), - &mut self.input_state, - |edits| edits.undo(fingerprint), - ) { - return false; - } - self.mark_region_cut_ui_dirty(); - self.schedule_region_cut_preview(); - true - } - - fn redo_region_cut(&mut self) -> bool { - let Some(fingerprint) = self.current_region_fingerprint() else { - return false; - }; - if !apply_cut_history_change( - self.region_capture.review_edits_slot_mut(), - &mut self.input_state, - |edits| edits.redo(fingerprint), - ) { - return false; - } - self.mark_region_cut_ui_dirty(); - self.schedule_region_cut_preview(); - true - } - - fn reset_region_cuts(&mut self) -> bool { - if !apply_cut_history_change( - self.region_capture.review_edits_slot_mut(), - &mut self.input_state, - RegionReviewEdits::reset, - ) { - return false; - } - self.mark_region_cut_ui_dirty(); - true - } - - pub(in crate::backend::wayland) fn try_begin_region_cut_drag( - &mut self, - owner: RegionInputSource, - point: (f64, f64), - ) -> bool { - let Some(display) = self.region_cut_displayed_selection() else { - return false; - }; - if !display_contains(display, point) { - return false; - } - let Some(edits) = self.region_capture.review_edits_mut() else { - return false; - }; - if !edits.begin_drag(owner, point) { - return false; - } - if !self.input_state.begin_region_review_move(owner) { - if let Some(edits) = self.region_capture.review_edits_mut() { - edits.drag = None; - } - return false; - } - self.mark_region_cut_ui_dirty(); - true - } - - pub(in crate::backend::wayland) fn update_region_cut_drag( - &mut self, - owner: RegionInputSource, - point: (f64, f64), - ) -> bool { - let Some(edits) = self.region_capture.review_edits_mut() else { - return false; - }; - if !edits.update_drag(owner, point) { - return false; - } - self.mark_region_cut_ui_dirty(); - true - } - - pub(in crate::backend::wayland) fn finish_region_cut_drag( - &mut self, - owner: RegionInputSource, - point: (f64, f64), - ) -> bool { - if !self - .region_capture - .review_edits() - .and_then(|edits| edits.drag) - .is_some_and(|drag| drag.owner == owner) - { - return false; - } - let Some(display) = self.region_cut_displayed_selection() else { - self.abandon_region_cut_drag(owner); - return true; - }; - let Some(fingerprint) = self.current_region_fingerprint() else { - self.abandon_region_cut_drag(owner); - return true; - }; - let Some(edits) = self.region_capture.review_edits_mut() else { - return false; - }; - let commit = edits.finish_drag(owner, point, display, fingerprint); - let _ = self.input_state.finish_region_review_move(owner); - match commit { - CutCommit::Applied => { - self.mark_region_cut_ui_dirty(); - self.schedule_region_cut_preview(); - } - CutCommit::RejectedFullAxis => { - self.input_state.push_toast( - crate::input::state::ToastPriority::Info, - "capture", - crate::input::state::Toast::warning( - "That cut would remove the entire remaining image.", - ), - ); - self.mark_region_cut_ui_dirty(); - } - CutCommit::None => self.mark_region_cut_ui_dirty(), - } - true - } - - pub(in crate::backend::wayland) fn abandon_region_cut_drag( - &mut self, - owner: RegionInputSource, - ) -> bool { - let Some(edits) = self.region_capture.review_edits_mut() else { - return false; - }; - let Some(drag) = edits.drag else { - return false; - }; - if drag.owner != owner { - return false; - } - edits.drag = None; - let _ = self.input_state.finish_region_review_move(owner); - self.mark_region_cut_ui_dirty(); - true - } - - pub(in crate::backend::wayland) fn handle_region_cut_escape(&mut self) -> bool { - let owner = self - .region_capture - .review_edits() - .and_then(|edits| edits.drag.map(|drag| drag.owner)); - let Some(edits) = self.region_capture.review_edits_mut() else { - return false; - }; - if !edits.disarm_mode() { - return false; - } - if let Some(owner) = owner { - let _ = self.input_state.finish_region_review_move(owner); - } - self.mark_region_cut_ui_dirty(); - true - } - - pub(super) fn sync_region_review_source_rect(&mut self) { - let Some(rect) = self.region_review_rect() else { - return; - }; - let Some(edits) = self.region_capture.review_edits_mut() else { - return; - }; - if edits.set_source_rect(rect) { - self.mark_region_cut_ui_dirty(); - } - } - - pub(in crate::backend::wayland) fn region_cut_preview_pixels( - &self, - ) -> Option<&crate::screen_pixels::PackedArgb32> { - self.region_capture - .review_edits() - .and_then(|edits| edits.ready_preview.as_ref()) - .map(|preview| preview.pixels.as_ref()) - } - - pub(in crate::backend::wayland) fn region_cut_drag_overlay( - &self, - ) -> Option<(CutAxis, RegionSelection)> { - let edits = self.region_capture.review_edits()?; - let drag = edits.drag?; - let axis = drag.axis?; - let display = self.region_cut_displayed_selection()?; - let output = edits.output_size()?; - let band = quantized_cut(axis, display, output, drag.start, drag.current)?; - debug_assert_eq!(band.axis(), axis); - cut_band_display(display, output, axis, band.start(), band.end()).map(|band| (axis, band)) - } - - pub(in crate::backend::wayland) fn consume_region_review_press( - &mut self, - owner: RegionInputSource, - point: (f64, f64), - ) -> RegionReviewPress { - if !self.input_state.region_state().is_review() { - return RegionReviewPress::NotReview; - } - if self.region_review_bar_contains(point) { - let suppress_release = if let Some(action) = self.region_review_action_at(point) { - let terminal = action.is_terminal(); - self.submit_region_review_action(action); - terminal - } else { - false - }; - return RegionReviewPress::Consumed { suppress_release }; - } - if self.try_begin_region_cut_drag(owner, point) { - return RegionReviewPress::Consumed { - suppress_release: false, - }; - } - if self.region_review_crop_locked() || self.region_cut_mode_armed() { - return RegionReviewPress::Consumed { - suppress_release: false, - }; - } - RegionReviewPress::Fallthrough - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(in crate::backend::wayland) enum RegionReviewPress { - NotReview, - Consumed { suppress_release: bool }, - Fallthrough, -} +mod actions; +mod geometry; +mod history; +mod model; +pub(in crate::backend::wayland) use actions::RegionReviewPress; +pub(super) use actions::review_edits_for_active_region; +pub(super) use geometry::native_extent_display; #[cfg(test)] -mod tests { - use super::*; - use crate::backend::wayland::state::screen_image::ScreenImageKind; - use crate::capture::CutAxis; - use wayland_client::protocol::wl_output::Transform; - - fn token() -> ScreenSourceToken { - ScreenSourceToken { - output_id: 1, - output_layout_generation: 1, - kind: ScreenImageKind::Frozen, - image_generation: 1, - image_size: (8, 8), - stride: 32, - surface: (8, 8), - output_scale: 1, - output_transform: Transform::Normal, - zoom_transformed: false, - zoom_scale: 1.0, - zoom_view_offset: (0.0, 0.0), - } - } - - fn fingerprint(rect: ImagePixelRect) -> RegionRenderFingerprint { - RegionRenderFingerprint::Raw { - correlation: RegionReviewCorrelation { - generation: 1, - source: token(), - }, - source_rect: rect, - } - } - - fn edits() -> RegionReviewEdits { - let rect = ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(); - RegionReviewEdits::new( - RegionReviewCorrelation { - generation: 1, - source: token(), - }, - rect, - ) - } - - fn display() -> RegionSelection { - RegionSelection { - start: (0.0, 0.0), - end: (8.0, 8.0), - } - } - - #[test] - fn arming_does_not_change_history() { - let mut edits = edits(); - edits.toggle_mode(); - assert_eq!(edits.mode, CutMode::Armed); - assert!(edits.cuts.is_empty()); - edits.toggle_mode(); - assert_eq!(edits.mode, CutMode::Idle); - assert!(edits.cuts.is_empty()); - } - - #[test] - fn sub_threshold_drag_commits_nothing() { - let mut edits = edits(); - edits.toggle_mode(); - assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); - assert!(edits.update_drag(RegionInputSource::Pointer, (3.0, 1.0))); - assert_eq!( - edits.finish_drag( - RegionInputSource::Pointer, - (3.0, 1.0), - display(), - fingerprint(edits.source_rect) - ), - CutCommit::None - ); - assert!(edits.cuts.is_empty()); - } +pub(super) use model::{CutCommit, CutMode}; +pub(in crate::backend::wayland) use model::{ + CutPreviewKey, RegionCutBase, RegionCutPreview, RegionRenderFingerprint, + RegionReviewCorrelation, RegionReviewEdits, +}; +pub(super) use model::{PreviewApply, RegionAnnotatedRenderContext}; - #[test] - fn axis_locks_once_past_the_threshold() { - let mut edits = edits(); - edits.toggle_mode(); - assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0))); - assert!(edits.update_drag(RegionInputSource::Pointer, (6.0, 1.0))); - assert_eq!(edits.drag.unwrap().axis, Some(CutAxis::Columns)); - assert!(edits.update_drag(RegionInputSource::Pointer, (6.0, 20.0))); - assert_eq!(edits.drag.unwrap().axis, Some(CutAxis::Columns)); - } - - #[test] - fn wrong_owner_cannot_update_or_finish_a_drag() { - let mut edits = edits(); - edits.toggle_mode(); - assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0))); - assert!(!edits.update_drag(RegionInputSource::Touch, (6.0, 0.0))); - assert_eq!( - edits.finish_drag( - RegionInputSource::Touch, - (6.0, 0.0), - display(), - fingerprint(edits.source_rect) - ), - CutCommit::None - ); - assert!(edits.drag.is_some()); - } - - #[test] - fn valid_commit_appends_clears_redo_and_increments_revision() { - let mut edits = edits(); - edits.toggle_mode(); - assert!(edits.begin_drag(RegionInputSource::Pointer, (2.0, 0.0))); - assert!(edits.update_drag(RegionInputSource::Pointer, (7.0, 0.0))); - assert_eq!( - edits.finish_drag( - RegionInputSource::Pointer, - (7.0, 0.0), - display(), - fingerprint(edits.source_rect) - ), - CutCommit::Applied - ); - assert_eq!(edits.cuts.len(), 1); - assert!(edits.redo.is_empty()); - assert_eq!(edits.revision, 1); - assert!(!edits.preview_is_current()); - } - - #[test] - fn full_axis_commit_is_rejected_without_a_revision_change() { - let mut edits = edits(); - edits.toggle_mode(); - assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0))); - assert!(edits.update_drag(RegionInputSource::Pointer, (8.0, 0.0))); - assert_eq!( - edits.finish_drag( - RegionInputSource::Pointer, - (8.0, 0.0), - display(), - fingerprint(edits.source_rect) - ), - CutCommit::RejectedFullAxis - ); - assert!(edits.cuts.is_empty()); - assert_eq!(edits.revision, 0); - } - - #[test] - fn undo_redo_and_new_commit_clear_redo() { - let mut edits = edits(); - let fingerprint = fingerprint(edits.source_rect); - edits - .cuts - .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); - edits.revision = 1; - assert!(edits.undo(fingerprint.clone())); - assert!(edits.cuts.is_empty()); - assert_eq!(edits.redo.len(), 1); - assert!(edits.redo(fingerprint.clone())); - assert_eq!(edits.cuts.len(), 1); - assert!(edits.undo(fingerprint.clone())); - edits.toggle_mode(); - assert!(edits.begin_drag(RegionInputSource::Pointer, (2.0, 0.0))); - assert!(edits.update_drag(RegionInputSource::Pointer, (7.0, 0.0))); - assert_eq!( - edits.finish_drag( - RegionInputSource::Pointer, - (7.0, 0.0), - display(), - fingerprint - ), - CutCommit::Applied - ); - assert!(edits.redo.is_empty()); - } - - #[test] - fn undo_and_redo_abandon_an_in_flight_drag() { - let mut edits = edits(); - let fingerprint = fingerprint(edits.source_rect); - edits - .cuts - .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); - edits.revision = 1; - edits.set_desired_from(fingerprint.clone()); - let desired = edits.desired_preview.clone().unwrap(); - edits.ready_preview = Some(RegionCutPreview { - key: desired, - pixels: std::sync::Arc::new( - crate::screen_pixels::PackedArgb32::new(7, 8, 28, vec![0; 28 * 8]).unwrap(), - ), - display: display(), - }); - edits.toggle_mode(); - assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); - assert!(edits.undo(fingerprint.clone())); - assert!(edits.drag.is_none()); - assert_eq!( - edits.finish_drag( - RegionInputSource::Pointer, - (7.0, 0.0), - display(), - fingerprint.clone() - ), - CutCommit::None - ); - - assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); - assert!(edits.redo(fingerprint.clone())); - assert!(edits.drag.is_none()); - assert_eq!( - edits.finish_drag( - RegionInputSource::Pointer, - (7.0, 0.0), - display(), - fingerprint - ), - CutCommit::None - ); - } - - #[test] - fn undo_with_nothing_to_undo_leaves_an_in_flight_drag() { - let mut edits = edits(); - edits.toggle_mode(); - assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); - assert!(!edits.undo(fingerprint(edits.source_rect))); - assert!(edits.drag.is_some()); - } - - fn review_input() -> crate::input::InputState { - let mut input = crate::input::state::test_support::make_test_input_state(); - input.activate_region_review( - crate::input::state::RegionPurposeTag::CaptureInteractive, - 1, - display(), - ); - input - } - - fn edits_with_current_preview() -> RegionReviewEdits { - let mut edits = edits(); - let fingerprint = fingerprint(edits.source_rect); - edits - .cuts - .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); - edits.revision = 1; - edits.set_desired_from(fingerprint); - let desired = edits.desired_preview.clone().unwrap(); - edits.ready_preview = Some(RegionCutPreview { - key: desired, - pixels: std::sync::Arc::new( - crate::screen_pixels::PackedArgb32::new(7, 8, 28, vec![0; 28 * 8]).unwrap(), - ), - display: display(), - }); - edits - } - - #[test] - fn undo_and_redo_retire_pointer_touch_and_tablet_owners_before_release() { - for owner in [ - RegionInputSource::Pointer, - RegionInputSource::Touch, - RegionInputSource::Stylus, - ] { - let mut input = review_input(); - let mut edits = Some(edits_with_current_preview()); - edits.as_mut().unwrap().toggle_mode(); - assert!(edits.as_mut().unwrap().begin_drag(owner, (1.0, 1.0))); - assert!(input.begin_region_review_move(owner)); - assert!(input.region_selection_is_owned_by(owner)); - - let fingerprint = fingerprint(edits.as_ref().unwrap().source_rect); - assert!(apply_cut_history_change(&mut edits, &mut input, |edits| { - edits.undo(fingerprint.clone()) - })); - assert!(edits.as_ref().unwrap().drag.is_none()); - assert!( - !input.region_selection_is_owned_by(owner), - "{owner:?} must be retired before release" - ); - assert_eq!( - edits.as_mut().unwrap().finish_drag( - owner, - (7.0, 0.0), - display(), - fingerprint.clone() - ), - CutCommit::None, - "{owner:?} release must not commit after undo" - ); - assert!(!input.finish_region_review_move(owner)); - - assert!(edits.as_mut().unwrap().begin_drag(owner, (1.0, 1.0))); - assert!(input.begin_region_review_move(owner)); - assert!(apply_cut_history_change(&mut edits, &mut input, |edits| { - edits.redo(fingerprint.clone()) - })); - assert!(edits.as_ref().unwrap().drag.is_none()); - assert!(!input.region_selection_is_owned_by(owner)); - assert_eq!( - edits - .as_mut() - .unwrap() - .finish_drag(owner, (7.0, 0.0), display(), fingerprint), - CutCommit::None - ); - } - } - - #[test] - fn toggling_cut_mode_off_during_a_drag_returns_the_owner() { - let mut edits = edits(); - edits.toggle_mode(); - assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); - assert_eq!(edits.toggle_mode(), Some(RegionInputSource::Pointer)); - assert_eq!(edits.mode, CutMode::Idle); - assert!(edits.drag.is_none()); - } - - #[test] - fn revision_exhaustion_leaves_reset_and_invalidate_untouched() { - let mut edits = edits(); - let cut = CutBand::new(CutAxis::Columns, 1, 2).unwrap(); - edits.cuts.push(cut); - edits.revision = u64::MAX; - edits.failed_revision = Some(u64::MAX); - assert!(!edits.reset()); - assert_eq!(edits.cuts, [cut]); - assert_eq!(edits.failed_revision, Some(u64::MAX)); - - let fingerprint = fingerprint(edits.source_rect); - edits.invalidate_base(fingerprint); - assert_eq!(edits.cuts, [cut]); - assert_eq!(edits.failed_revision, Some(u64::MAX)); - assert_eq!(edits.revision, u64::MAX); - } - - #[test] - fn undoing_the_last_cut_unlocks_the_crop() { - let mut edits = edits(); - let fingerprint = fingerprint(edits.source_rect); - edits - .cuts - .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); - edits.revision = 1; - assert!(edits.crop_locked()); - assert!(edits.undo(fingerprint)); - assert!(!edits.crop_locked()); - } - - #[test] - fn a_failed_current_preview_stays_failed_until_the_revision_changes() { - let mut edits = edits(); - let desired = CutPreviewKey { - fingerprint: fingerprint(edits.source_rect), - revision: 3, - cuts: vec![CutBand::new(CutAxis::Columns, 1, 2).unwrap()], - }; - edits.cuts = desired.cuts.clone(); - edits.revision = 3; - edits.desired_preview = Some(desired.clone()); - assert!(edits.mark_preview_failed(&desired)); - assert!(edits.current_preview_failed()); - edits.failed_revision = None; - edits.revision = 4; - edits.desired_preview = Some(CutPreviewKey { - revision: 4, - ..desired - }); - assert!(!edits.current_preview_failed()); - } - - #[test] - fn reset_clears_history_and_unlocks_the_crop() { - let mut edits = edits(); - edits.cuts.push(CutBand::new(CutAxis::Rows, 1, 2).unwrap()); - edits - .redo - .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); - edits.mode = CutMode::Armed; - assert!(edits.reset()); - assert!(edits.cuts.is_empty()); - assert!(edits.redo.is_empty()); - assert!(!edits.crop_locked()); - assert!(edits.preview_is_current()); - } - - #[test] - fn source_rect_cannot_change_while_cuts_exist() { - let mut edits = edits(); - let next = ImagePixelRect::new(1, 1, 4, 4, (8, 8)).unwrap(); - assert!(edits.set_source_rect(next)); - edits - .cuts - .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); - assert!(!edits.set_source_rect(ImagePixelRect::new(0, 0, 4, 4, (8, 8)).unwrap())); - assert_eq!(edits.source_rect, next); - } - - #[test] - fn cut_start_is_rejected_while_preview_is_pending() { - let mut edits = edits(); - edits.toggle_mode(); - edits - .cuts - .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); - edits.revision = 1; - edits.set_desired_from(fingerprint(edits.source_rect)); - assert!(!edits.preview_is_current()); - assert!(!edits.can_start_cut_drag()); - } - - #[test] - fn loupe_is_suppressed_when_armed_or_cuts_exist() { - let mut edits = edits(); - assert!(!edits.loupe_suppressed()); - edits.toggle_mode(); - assert!(edits.loupe_suppressed()); - edits.toggle_mode(); - edits - .cuts - .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); - assert!(edits.loupe_suppressed()); - } - - #[test] - fn column_cut_preserves_top_left_and_height() { - let token = token(); - let rect = ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(); - let full = output_display_for(&token, rect, &[]).unwrap(); - let cut = output_display_for( - &token, - rect, - &[CutBand::new(CutAxis::Columns, 2, 4).unwrap()], - ) - .unwrap(); - assert_eq!(cut.start, full.start); - assert_eq!(cut.end.1, full.end.1); - assert!(cut.end.0 < full.end.0); - } - - #[test] - fn dominant_axis_ties_choose_columns() { - assert_eq!(dominant_cut_axis(4.0, 4.0), CutAxis::Columns); - assert_eq!(dominant_cut_axis(4.0, 5.0), CutAxis::Rows); - } - - #[test] - fn pointer_edges_map_to_the_inclusive_pixel_edge_domain() { - let display = display(); - assert_eq!( - logical_to_output_point(display, (8, 8), (0.0, 0.0)).map(|point| (point.x, point.y)), - Some((0.0, 0.0)) - ); - assert_eq!( - logical_to_output_point(display, (8, 8), (8.0, 8.0)).map(|point| (point.x, point.y)), - Some((8.0, 8.0)) - ); - let clamped = logical_to_output_point(display, (8, 8), (-2.0, 20.0)).unwrap(); - assert_eq!((clamped.x, clamped.y), (0.0, 8.0)); - } - - #[test] - fn composed_board_origin_stays_put_while_size_contracts() { - let source = crate::canvas_export::CanvasExportRect::new(10.0, 20.0, 80.0, 40.0).unwrap(); - let composed = - crate::backend::wayland::state::region_capture::world_rect_for_composed_region( - source, - (8, 8), - (6, 4), - ) - .unwrap(); - assert_eq!(composed.x, 10.0); - assert_eq!(composed.y, 20.0); - assert_eq!(composed.width, 60.0); - assert_eq!(composed.height, 20.0); - } -} +#[cfg(test)] +mod tests; diff --git a/src/backend/wayland/state/region_capture/cut_review/actions.rs b/src/backend/wayland/state/region_capture/cut_review/actions.rs new file mode 100644 index 000000000..c05f79912 --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_review/actions.rs @@ -0,0 +1,384 @@ +use super::geometry::{cut_band_display, display_contains, output_display_for, quantized_cut}; +use super::model::{CutCommit, CutMode, RegionReviewCorrelation, RegionReviewEdits}; +use crate::backend::wayland::state::WaylandState; +use crate::backend::wayland::state::region_capture::ActiveScreenRegion; +use crate::capture::CutAxis; +use crate::input::InputState; +use crate::input::state::{RegionInputSource, RegionSelection}; +use crate::screen_pixels::ImagePixelRect; +use crate::ui::{RegionActionAvailability, RegionCutStatus}; + +pub(in crate::backend::wayland::state::region_capture) fn review_edits_for_active_region( + region: Option, + rect: ImagePixelRect, +) -> Option { + let Some(ActiveScreenRegion::Ready { + purpose: crate::input::state::RegionPurposeTag::CaptureInteractive, + generation, + source, + .. + }) = region + else { + return None; + }; + Some(RegionReviewEdits::new( + RegionReviewCorrelation { generation, source }, + rect, + )) +} + +fn retire_cut_drag_owner(input: &mut InputState, owner: Option) { + if let Some(owner) = owner { + let _ = input.finish_region_review_move(owner); + } +} + +pub(super) fn apply_cut_history_change( + edits: &mut Option, + input: &mut InputState, + change: impl FnOnce(&mut RegionReviewEdits) -> bool, +) -> bool { + let owner = edits + .as_ref() + .and_then(|edits| edits.drag.map(|drag| drag.owner)); + let Some(edits) = edits.as_mut() else { + return false; + }; + if !change(edits) { + return false; + } + retire_cut_drag_owner(input, owner); + true +} + +impl WaylandState { + pub(in crate::backend::wayland) fn region_review_crop_locked(&self) -> bool { + self.region_capture + .review_edits() + .is_some_and(RegionReviewEdits::crop_locked) + } + + pub(in crate::backend::wayland) fn region_review_loupe_suppressed(&self) -> bool { + self.region_capture + .review_edits() + .is_some_and(RegionReviewEdits::loupe_suppressed) + } + + pub(in crate::backend::wayland) fn region_cut_displayed_selection( + &self, + ) -> Option { + let edits = self.region_capture.review_edits()?; + if let Some(preview) = &edits.ready_preview { + return Some(preview.display); + } + let token = self.region_picker_source_token()?; + output_display_for(&token, edits.source_rect, &[]) + } + + pub(in crate::backend::wayland) fn region_cut_availability(&self) -> RegionActionAvailability { + self.region_capture + .review_edits() + .map(RegionReviewEdits::availability) + .unwrap_or_default() + } + + pub(in crate::backend::wayland) fn region_cut_status(&self) -> Option { + self.region_capture + .review_edits() + .and_then(RegionReviewEdits::status) + } + + pub(in crate::backend::wayland) fn region_cut_mode_armed(&self) -> bool { + self.region_capture + .review_edits() + .is_some_and(|edits| edits.mode == CutMode::Armed) + } + + pub(in crate::backend::wayland::state::region_capture) fn create_region_review_edits( + &mut self, + rect: ImagePixelRect, + ) { + self.region_capture.set_review_edits_for(rect); + } + + pub(in crate::backend::wayland::state::region_capture) fn mark_region_cut_ui_dirty(&mut self) { + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + } + + pub(in crate::backend::wayland) fn apply_region_review_edit( + &mut self, + action: crate::ui::RegionAction, + ) -> bool { + match action { + crate::ui::RegionAction::CutBand => self.toggle_region_cut_mode(), + crate::ui::RegionAction::UndoCut => self.undo_region_cut(), + crate::ui::RegionAction::RedoCut => self.redo_region_cut(), + crate::ui::RegionAction::ResetCuts => self.reset_region_cuts(), + crate::ui::RegionAction::ToggleIncludeDrawings => { + self.toggle_region_picker_include_drawings() + } + crate::ui::RegionAction::Copy + | crate::ui::RegionAction::Save + | crate::ui::RegionAction::Both + | crate::ui::RegionAction::Board => false, + } + } + + fn retire_region_cut_drag_owner(&mut self, owner: Option) { + retire_cut_drag_owner(&mut self.input_state, owner); + } + + fn toggle_region_cut_mode(&mut self) -> bool { + let Some(edits) = self.region_capture.review_edits_mut() else { + return false; + }; + let owner = edits.toggle_mode(); + self.retire_region_cut_drag_owner(owner); + self.mark_region_cut_ui_dirty(); + true + } + + fn undo_region_cut(&mut self) -> bool { + let Some(fingerprint) = self.current_region_fingerprint() else { + return false; + }; + if !apply_cut_history_change( + self.region_capture.review_edits_slot_mut(), + &mut self.input_state, + |edits| edits.undo(fingerprint), + ) { + return false; + } + self.mark_region_cut_ui_dirty(); + self.schedule_region_cut_preview(); + true + } + + fn redo_region_cut(&mut self) -> bool { + let Some(fingerprint) = self.current_region_fingerprint() else { + return false; + }; + if !apply_cut_history_change( + self.region_capture.review_edits_slot_mut(), + &mut self.input_state, + |edits| edits.redo(fingerprint), + ) { + return false; + } + self.mark_region_cut_ui_dirty(); + self.schedule_region_cut_preview(); + true + } + + fn reset_region_cuts(&mut self) -> bool { + if !apply_cut_history_change( + self.region_capture.review_edits_slot_mut(), + &mut self.input_state, + RegionReviewEdits::reset, + ) { + return false; + } + self.mark_region_cut_ui_dirty(); + true + } + + pub(in crate::backend::wayland) fn try_begin_region_cut_drag( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> bool { + let Some(display) = self.region_cut_displayed_selection() else { + return false; + }; + if !display_contains(display, point) { + return false; + } + let Some(edits) = self.region_capture.review_edits_mut() else { + return false; + }; + if !edits.begin_drag(owner, point) { + return false; + } + if !self.input_state.begin_region_review_move(owner) { + if let Some(edits) = self.region_capture.review_edits_mut() { + edits.drag = None; + } + return false; + } + self.mark_region_cut_ui_dirty(); + true + } + + pub(in crate::backend::wayland) fn update_region_cut_drag( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> bool { + let Some(edits) = self.region_capture.review_edits_mut() else { + return false; + }; + if !edits.update_drag(owner, point) { + return false; + } + self.mark_region_cut_ui_dirty(); + true + } + + pub(in crate::backend::wayland) fn finish_region_cut_drag( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> bool { + if !self + .region_capture + .review_edits() + .and_then(|edits| edits.drag) + .is_some_and(|drag| drag.owner == owner) + { + return false; + } + let Some(display) = self.region_cut_displayed_selection() else { + self.abandon_region_cut_drag(owner); + return true; + }; + let Some(fingerprint) = self.current_region_fingerprint() else { + self.abandon_region_cut_drag(owner); + return true; + }; + let Some(edits) = self.region_capture.review_edits_mut() else { + return false; + }; + let commit = edits.finish_drag(owner, point, display, fingerprint); + let _ = self.input_state.finish_region_review_move(owner); + match commit { + CutCommit::Applied => { + self.mark_region_cut_ui_dirty(); + self.schedule_region_cut_preview(); + } + CutCommit::RejectedFullAxis => { + self.input_state.push_toast( + crate::input::state::ToastPriority::Info, + "capture", + crate::input::state::Toast::warning( + "That cut would remove the entire remaining image.", + ), + ); + self.mark_region_cut_ui_dirty(); + } + CutCommit::None => self.mark_region_cut_ui_dirty(), + } + true + } + + pub(in crate::backend::wayland) fn abandon_region_cut_drag( + &mut self, + owner: RegionInputSource, + ) -> bool { + let Some(edits) = self.region_capture.review_edits_mut() else { + return false; + }; + let Some(drag) = edits.drag else { + return false; + }; + if drag.owner != owner { + return false; + } + edits.drag = None; + let _ = self.input_state.finish_region_review_move(owner); + self.mark_region_cut_ui_dirty(); + true + } + + pub(in crate::backend::wayland) fn handle_region_cut_escape(&mut self) -> bool { + let owner = self + .region_capture + .review_edits() + .and_then(|edits| edits.drag.map(|drag| drag.owner)); + let Some(edits) = self.region_capture.review_edits_mut() else { + return false; + }; + if !edits.disarm_mode() { + return false; + } + if let Some(owner) = owner { + let _ = self.input_state.finish_region_review_move(owner); + } + self.mark_region_cut_ui_dirty(); + true + } + + pub(in crate::backend::wayland::state::region_capture) fn sync_region_review_source_rect( + &mut self, + ) { + let Some(rect) = self.region_review_rect() else { + return; + }; + let Some(edits) = self.region_capture.review_edits_mut() else { + return; + }; + if edits.set_source_rect(rect) { + self.mark_region_cut_ui_dirty(); + } + } + + pub(in crate::backend::wayland) fn region_cut_preview_pixels( + &self, + ) -> Option<&crate::screen_pixels::PackedArgb32> { + self.region_capture + .review_edits() + .and_then(|edits| edits.ready_preview.as_ref()) + .map(|preview| preview.pixels.as_ref()) + } + + pub(in crate::backend::wayland) fn region_cut_drag_overlay( + &self, + ) -> Option<(CutAxis, RegionSelection)> { + let edits = self.region_capture.review_edits()?; + let drag = edits.drag?; + let axis = drag.axis?; + let display = self.region_cut_displayed_selection()?; + let output = edits.output_size()?; + let band = quantized_cut(axis, display, output, drag.start, drag.current)?; + debug_assert_eq!(band.axis(), axis); + cut_band_display(display, output, axis, band.start(), band.end()).map(|band| (axis, band)) + } + + pub(in crate::backend::wayland) fn consume_region_review_press( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> RegionReviewPress { + if !self.input_state.region_state().is_review() { + return RegionReviewPress::NotReview; + } + if self.region_review_bar_contains(point) { + let suppress_release = if let Some(action) = self.region_review_action_at(point) { + let terminal = action.is_terminal(); + self.submit_region_review_action(action); + terminal + } else { + false + }; + return RegionReviewPress::Consumed { suppress_release }; + } + if self.try_begin_region_cut_drag(owner, point) { + return RegionReviewPress::Consumed { + suppress_release: false, + }; + } + if self.region_review_crop_locked() || self.region_cut_mode_armed() { + return RegionReviewPress::Consumed { + suppress_release: false, + }; + } + RegionReviewPress::Fallthrough + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum RegionReviewPress { + NotReview, + Consumed { suppress_release: bool }, + Fallthrough, +} diff --git a/src/backend/wayland/state/region_capture/cut_review/geometry.rs b/src/backend/wayland/state/region_capture/cut_review/geometry.rs new file mode 100644 index 000000000..270350bcd --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_review/geometry.rs @@ -0,0 +1,143 @@ +use crate::backend::wayland::state::screen_image::{ + ScreenSourceToken, screen_rect_for_native_extent, +}; +use crate::capture::{CutAxis, CutBand, output_size}; +use crate::input::state::RegionSelection; +use crate::screen_pixels::{ImagePixelRect, ImagePoint, pixel_span}; +use crate::util::Rect; + +pub(in crate::backend::wayland::state::region_capture) fn dominant_cut_axis( + dx: f64, + dy: f64, +) -> CutAxis { + if dy.abs() > dx.abs() { + CutAxis::Rows + } else { + CutAxis::Columns + } +} + +pub(in crate::backend::wayland::state::region_capture) fn output_display_for( + token: &ScreenSourceToken, + source_rect: ImagePixelRect, + cuts: &[CutBand], +) -> Option { + let size = output_size((source_rect.width(), source_rect.height()), cuts).ok()?; + native_extent_display(token, source_rect, size) +} + +pub(in crate::backend::wayland::state::region_capture) fn native_extent_display( + token: &ScreenSourceToken, + source_rect: ImagePixelRect, + size: (u32, u32), +) -> Option { + let rect = screen_rect_for_native_extent(token, (source_rect.x(), source_rect.y()), size)?; + Some(region_selection_from_rect(rect)) +} + +pub(in crate::backend::wayland::state::region_capture) fn region_selection_from_rect( + rect: Rect, +) -> RegionSelection { + RegionSelection { + start: (f64::from(rect.x), f64::from(rect.y)), + end: ( + f64::from(rect.x.saturating_add(rect.width)), + f64::from(rect.y.saturating_add(rect.height)), + ), + } +} + +pub(in crate::backend::wayland::state::region_capture) fn display_contains( + display: RegionSelection, + point: (f64, f64), +) -> bool { + let left = display.start.0.min(display.end.0); + let right = display.start.0.max(display.end.0); + let top = display.start.1.min(display.end.1); + let bottom = display.start.1.max(display.end.1); + point.0 >= left && point.0 < right && point.1 >= top && point.1 < bottom +} + +pub(in crate::backend::wayland::state::region_capture) fn logical_to_output_point( + display: RegionSelection, + output_size: (u32, u32), + point: (f64, f64), +) -> Option { + if output_size.0 == 0 || output_size.1 == 0 || !point.0.is_finite() || !point.1.is_finite() { + return None; + } + let left = display.start.0.min(display.end.0); + let right = display.start.0.max(display.end.0); + let top = display.start.1.min(display.end.1); + let bottom = display.start.1.max(display.end.1); + let width = right - left; + let height = bottom - top; + if width <= 0.0 || height <= 0.0 || !width.is_finite() || !height.is_finite() { + return None; + } + let x = ((point.0 - left) / width) * f64::from(output_size.0); + let y = ((point.1 - top) / height) * f64::from(output_size.1); + if !x.is_finite() || !y.is_finite() { + return None; + } + Some(ImagePoint::new( + x.clamp(0.0, f64::from(output_size.0)), + y.clamp(0.0, f64::from(output_size.1)), + )) +} + +pub(super) fn quantized_cut( + axis: CutAxis, + display: RegionSelection, + output_size: (u32, u32), + start: (f64, f64), + current: (f64, f64), +) -> Option { + let first = logical_to_output_point(display, output_size, start)?; + let second = logical_to_output_point(display, output_size, current)?; + let span = pixel_span(first, second, output_size)?; + match axis { + CutAxis::Columns => { + CutBand::from_unordered_edges(axis, span.x(), span.x().checked_add(span.width())?).ok() + } + CutAxis::Rows => { + CutBand::from_unordered_edges(axis, span.y(), span.y().checked_add(span.height())?).ok() + } + } +} + +pub(in crate::backend::wayland::state::region_capture) fn cut_band_display( + display: RegionSelection, + output_size: (u32, u32), + axis: CutAxis, + start: u32, + end: u32, +) -> Option { + if end <= start || output_size.0 == 0 || output_size.1 == 0 { + return None; + } + let left = display.start.0.min(display.end.0); + let right = display.start.0.max(display.end.0); + let top = display.start.1.min(display.end.1); + let bottom = display.start.1.max(display.end.1); + let width = right - left; + let height = bottom - top; + match axis { + CutAxis::Columns => { + let x0 = left + f64::from(start) * width / f64::from(output_size.0); + let x1 = left + f64::from(end) * width / f64::from(output_size.0); + Some(RegionSelection { + start: (x0, top), + end: (x1, bottom), + }) + } + CutAxis::Rows => { + let y0 = top + f64::from(start) * height / f64::from(output_size.1); + let y1 = top + f64::from(end) * height / f64::from(output_size.1); + Some(RegionSelection { + start: (left, y0), + end: (right, y1), + }) + } + } +} diff --git a/src/backend/wayland/state/region_capture/cut_review/history.rs b/src/backend/wayland/state/region_capture/cut_review/history.rs new file mode 100644 index 000000000..fd5a88647 --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_review/history.rs @@ -0,0 +1,354 @@ +use super::geometry::{dominant_cut_axis, quantized_cut}; +use super::model::{ + CutCommit, CutDrag, CutMode, CutPreviewKey, RegionRenderFingerprint, RegionReviewCorrelation, + RegionReviewEdits, +}; +use crate::capture::output_size; +use crate::input::state::{RegionInputSource, RegionSelection}; +use crate::screen_pixels::ImagePixelRect; +use crate::ui::{RegionActionAvailability, RegionCutStatus}; + +const CUT_DRAG_THRESHOLD_PX: f64 = 4.0; + +impl RegionReviewEdits { + pub(in crate::backend::wayland::state::region_capture) fn new( + correlation: RegionReviewCorrelation, + source_rect: ImagePixelRect, + ) -> Self { + Self { + correlation, + source_rect, + mode: CutMode::Idle, + drag: None, + cuts: Vec::new(), + redo: Vec::new(), + revision: 0, + desired_preview: None, + ready_preview: None, + base_cache: None, + failed_revision: None, + } + } + + pub(in crate::backend::wayland::state::region_capture) fn crop_locked(&self) -> bool { + !self.cuts.is_empty() + } + + pub(in crate::backend::wayland::state::region_capture) fn loupe_suppressed(&self) -> bool { + self.mode == CutMode::Armed || self.crop_locked() + } + + pub(in crate::backend::wayland::state::region_capture) fn preview_is_current(&self) -> bool { + match &self.desired_preview { + None => self.cuts.is_empty(), + Some(desired) => self + .ready_preview + .as_ref() + .is_some_and(|ready| ready.key == *desired), + } + } + + pub(in crate::backend::wayland::state::region_capture) fn can_start_cut_drag(&self) -> bool { + self.mode == CutMode::Armed && self.preview_is_current() && self.drag.is_none() + } + + /// A current failed preview must stay failed until undo, redo, or reset + /// changes the revision. Resubmitting the same revision churns workers. + pub(in crate::backend::wayland::state::region_capture) fn current_preview_failed( + &self, + ) -> bool { + self.desired_preview.as_ref().is_some_and(|desired| { + self.failed_revision + .is_some_and(|revision| revision == desired.revision) + }) + } + + pub(in crate::backend::wayland::state::region_capture) fn status( + &self, + ) -> Option { + if self.cuts.is_empty() { + return None; + } + if self + .failed_revision + .is_some_and(|revision| revision == self.revision) + { + return Some(RegionCutStatus::Failed); + } + if !self.preview_is_current() { + return Some(RegionCutStatus::Updating); + } + None + } + + pub(in crate::backend::wayland::state::region_capture) fn availability( + &self, + ) -> RegionActionAvailability { + let preview_ok = self.preview_is_current(); + RegionActionAvailability { + terminal: preview_ok, + cut: true, + undo: !self.cuts.is_empty(), + redo: !self.redo.is_empty(), + reset: self.crop_locked() || !self.redo.is_empty(), + } + } + + /// Toggle Cut arming. Returns the abandoned drag owner, if any, so the + /// caller can retire the matching `InputState` review-move owner. + pub(in crate::backend::wayland::state::region_capture) fn toggle_mode( + &mut self, + ) -> Option { + let abandoned = if self.mode == CutMode::Armed { + self.drag.take().map(|drag| drag.owner) + } else { + None + }; + self.mode = match self.mode { + CutMode::Idle => CutMode::Armed, + CutMode::Armed => CutMode::Idle, + }; + abandoned + } + + pub(in crate::backend::wayland::state::region_capture) fn disarm_mode(&mut self) -> bool { + if self.drag.is_some() { + self.drag = None; + return true; + } + if self.mode == CutMode::Armed { + self.mode = CutMode::Idle; + return true; + } + false + } + + fn bump_revision(&mut self) -> bool { + match self.revision.checked_add(1) { + Some(next) => { + self.revision = next; + true + } + None => false, + } + } + + pub(in crate::backend::wayland::state::region_capture) fn set_desired_from( + &mut self, + fingerprint: RegionRenderFingerprint, + ) { + if self.cuts.is_empty() { + self.desired_preview = None; + self.ready_preview = None; + self.base_cache = None; + self.failed_revision = None; + return; + } + self.desired_preview = Some(CutPreviewKey { + fingerprint, + revision: self.revision, + cuts: self.cuts.clone(), + }); + } + + pub(in crate::backend::wayland::state::region_capture) fn output_size( + &self, + ) -> Option<(u32, u32)> { + output_size( + (self.source_rect.width(), self.source_rect.height()), + &self.cuts, + ) + .ok() + } + + pub(in crate::backend::wayland::state::region_capture) fn displayed_output_size( + &self, + ) -> Option<(u32, u32)> { + self.ready_preview + .as_ref() + .map(|preview| (preview.pixels.width(), preview.pixels.height())) + .or_else(|| { + if self.cuts.is_empty() { + Some((self.source_rect.width(), self.source_rect.height())) + } else { + None + } + }) + } + + pub(in crate::backend::wayland::state::region_capture) fn begin_drag( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> bool { + if !self.can_start_cut_drag() { + return false; + } + self.drag = Some(CutDrag { + owner, + start: point, + current: point, + axis: None, + }); + true + } + + pub(in crate::backend::wayland::state::region_capture) fn update_drag( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> bool { + let Some(drag) = self.drag.as_mut() else { + return false; + }; + if drag.owner != owner { + return false; + } + drag.current = point; + if drag.axis.is_none() { + let dx = point.0 - drag.start.0; + let dy = point.1 - drag.start.1; + if dx.hypot(dy) >= CUT_DRAG_THRESHOLD_PX { + drag.axis = Some(dominant_cut_axis(dx, dy)); + } + } + true + } + + pub(in crate::backend::wayland::state::region_capture) fn finish_drag( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + display: RegionSelection, + fingerprint: RegionRenderFingerprint, + ) -> CutCommit { + if !self.drag.as_ref().is_some_and(|drag| drag.owner == owner) { + return CutCommit::None; + } + let _ = self.update_drag(owner, point); + let Some(drag) = self.drag.take() else { + return CutCommit::None; + }; + let Some(axis) = drag.axis else { + return CutCommit::None; + }; + let Some(output) = self.output_size() else { + return CutCommit::None; + }; + let Some(band) = quantized_cut(axis, display, output, drag.start, drag.current) else { + return CutCommit::None; + }; + let mut next_cuts = self.cuts.clone(); + next_cuts.push(band); + if output_size( + (self.source_rect.width(), self.source_rect.height()), + &next_cuts, + ) + .is_err() + { + return CutCommit::RejectedFullAxis; + } + if !self.bump_revision() { + return CutCommit::None; + } + self.cuts.push(band); + self.redo.clear(); + self.failed_revision = None; + self.set_desired_from(fingerprint); + CutCommit::Applied + } + + pub(in crate::backend::wayland::state::region_capture) fn undo( + &mut self, + fingerprint: RegionRenderFingerprint, + ) -> bool { + let Some(cut) = self.cuts.pop() else { + return false; + }; + if !self.bump_revision() { + self.cuts.push(cut); + return false; + } + self.redo.push(cut); + self.drag = None; + self.failed_revision = None; + self.set_desired_from(fingerprint); + true + } + + pub(in crate::backend::wayland::state::region_capture) fn redo( + &mut self, + fingerprint: RegionRenderFingerprint, + ) -> bool { + let Some(cut) = self.redo.pop() else { + return false; + }; + if !self.bump_revision() { + self.redo.push(cut); + return false; + } + self.cuts.push(cut); + self.drag = None; + self.failed_revision = None; + self.set_desired_from(fingerprint); + true + } + + pub(in crate::backend::wayland::state::region_capture) fn reset(&mut self) -> bool { + if self.cuts.is_empty() && self.redo.is_empty() && self.drag.is_none() { + return false; + } + if !self.bump_revision() { + return false; + } + self.drag = None; + self.cuts.clear(); + self.redo.clear(); + self.failed_revision = None; + self.set_desired_from(RegionRenderFingerprint::Raw { + correlation: self.correlation.clone(), + source_rect: self.source_rect, + }); + true + } + + pub(in crate::backend::wayland::state::region_capture) fn set_source_rect( + &mut self, + source_rect: ImagePixelRect, + ) -> bool { + if self.crop_locked() || self.source_rect == source_rect { + return false; + } + self.source_rect = source_rect; + self.redo.clear(); + self.ready_preview = None; + self.base_cache = None; + self.desired_preview = None; + self.failed_revision = None; + true + } + + pub(in crate::backend::wayland::state::region_capture) fn invalidate_base( + &mut self, + fingerprint: RegionRenderFingerprint, + ) { + if !self.cuts.is_empty() && !self.bump_revision() { + return; + } + self.base_cache = None; + self.ready_preview = None; + self.failed_revision = None; + self.set_desired_from(fingerprint); + } + + pub(in crate::backend::wayland::state::region_capture) fn mark_preview_failed( + &mut self, + key: &CutPreviewKey, + ) -> bool { + if self.desired_preview.as_ref() != Some(key) { + return false; + } + self.failed_revision = Some(key.revision); + true + } +} diff --git a/src/backend/wayland/state/region_capture/cut_review/model.rs b/src/backend/wayland/state/region_capture/cut_review/model.rs new file mode 100644 index 000000000..ee8fac60d --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_review/model.rs @@ -0,0 +1,122 @@ +use crate::backend::wayland::state::screen_image::ScreenSourceToken; +use crate::capture::{CutAxis, CutBand}; +use crate::input::state::{RegionInputSource, RegionSelection}; +use crate::screen_pixels::ImagePixelRect; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland::state::region_capture) enum PreviewApply { + Ignored, + Changed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum CutMode { + Idle, + Armed, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::backend::wayland) struct CutDrag { + pub(in crate::backend::wayland::state::region_capture) owner: RegionInputSource, + pub(in crate::backend::wayland::state::region_capture) start: (f64, f64), + pub(in crate::backend::wayland::state::region_capture) current: (f64, f64), + pub(in crate::backend::wayland::state::region_capture) axis: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(in crate::backend::wayland) struct RegionReviewCorrelation { + pub(in crate::backend::wayland::state::region_capture) generation: u64, + pub(in crate::backend::wayland::state::region_capture) source: ScreenSourceToken, +} + +/// Board and overlay facts that distinguish two annotated region renders. +/// Fingerprint and snapshot construction share one of these so halo, Spotlight, +/// and board identity cannot describe different frames. +#[derive(Debug, Clone, PartialEq)] +pub(in crate::backend::wayland::state::region_capture) struct RegionAnnotatedRenderContext { + pub(in crate::backend::wayland::state::region_capture) board_id: String, + pub(in crate::backend::wayland::state::region_capture) page_index: usize, + pub(in crate::backend::wayland::state::region_capture) page_generation: u64, + pub(in crate::backend::wayland::state::region_capture) canvas_content_generation: u64, + pub(in crate::backend::wayland::state::region_capture) board_view_offset: (f64, f64), + pub(in crate::backend::wayland::state::region_capture) text_halo_enabled: bool, + pub(in crate::backend::wayland::state::region_capture) spotlight: + crate::canvas_export::SpotlightPassSnapshot, +} + +#[derive(Debug, Clone, PartialEq)] +pub(in crate::backend::wayland) enum RegionRenderFingerprint { + Raw { + correlation: RegionReviewCorrelation, + source_rect: ImagePixelRect, + }, + Annotated { + correlation: RegionReviewCorrelation, + source_rect: ImagePixelRect, + context: RegionAnnotatedRenderContext, + }, +} + +impl RegionRenderFingerprint { + pub(in crate::backend::wayland::state::region_capture) fn correlation( + &self, + ) -> &RegionReviewCorrelation { + match self { + Self::Raw { correlation, .. } | Self::Annotated { correlation, .. } => correlation, + } + } + + pub(in crate::backend::wayland::state::region_capture) fn source_rect(&self) -> ImagePixelRect { + match self { + Self::Raw { source_rect, .. } | Self::Annotated { source_rect, .. } => *source_rect, + } + } + + pub(in crate::backend::wayland::state::region_capture) fn include_drawings(&self) -> bool { + matches!(self, Self::Annotated { .. }) + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(in crate::backend::wayland) struct CutPreviewKey { + pub(in crate::backend::wayland::state::region_capture) fingerprint: RegionRenderFingerprint, + pub(in crate::backend::wayland::state::region_capture) revision: u64, + pub(in crate::backend::wayland::state::region_capture) cuts: Vec, +} + +#[derive(Debug, Clone)] +pub(in crate::backend::wayland) struct RegionCutPreview { + pub(in crate::backend::wayland::state::region_capture) key: CutPreviewKey, + pub(in crate::backend::wayland::state::region_capture) pixels: + std::sync::Arc, + pub(in crate::backend::wayland::state::region_capture) display: RegionSelection, +} + +#[derive(Debug, Clone)] +pub(in crate::backend::wayland) struct RegionCutBase { + pub(in crate::backend::wayland::state::region_capture) fingerprint: RegionRenderFingerprint, + pub(in crate::backend::wayland::state::region_capture) pixels: + std::sync::Arc, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland::state::region_capture) enum CutCommit { + None, + Applied, + RejectedFullAxis, +} + +#[derive(Debug)] +pub(in crate::backend::wayland) struct RegionReviewEdits { + pub(in crate::backend::wayland::state::region_capture) correlation: RegionReviewCorrelation, + pub(in crate::backend::wayland::state::region_capture) source_rect: ImagePixelRect, + pub(in crate::backend::wayland::state::region_capture) mode: CutMode, + pub(in crate::backend::wayland::state::region_capture) drag: Option, + pub(in crate::backend::wayland::state::region_capture) cuts: Vec, + pub(in crate::backend::wayland::state::region_capture) redo: Vec, + pub(in crate::backend::wayland::state::region_capture) revision: u64, + pub(in crate::backend::wayland::state::region_capture) desired_preview: Option, + pub(in crate::backend::wayland::state::region_capture) ready_preview: Option, + pub(in crate::backend::wayland::state::region_capture) base_cache: Option, + pub(in crate::backend::wayland::state::region_capture) failed_revision: Option, +} diff --git a/src/backend/wayland/state/region_capture/cut_review/tests.rs b/src/backend/wayland/state/region_capture/cut_review/tests.rs new file mode 100644 index 000000000..56e3d2b89 --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_review/tests.rs @@ -0,0 +1,484 @@ +use super::actions::apply_cut_history_change; +use super::geometry::{dominant_cut_axis, logical_to_output_point, output_display_for}; +use super::model::{CutCommit, CutMode}; +use super::*; +use crate::backend::wayland::state::screen_image::ScreenImageKind; +use crate::backend::wayland::state::screen_image::ScreenSourceToken; +use crate::capture::CutAxis; +use crate::capture::CutBand; +use crate::input::state::{RegionInputSource, RegionSelection}; +use crate::screen_pixels::ImagePixelRect; +use wayland_client::protocol::wl_output::Transform; + +fn token() -> ScreenSourceToken { + ScreenSourceToken { + output_id: 1, + output_layout_generation: 1, + kind: ScreenImageKind::Frozen, + image_generation: 1, + image_size: (8, 8), + stride: 32, + surface: (8, 8), + output_scale: 1, + output_transform: Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + } +} + +fn fingerprint(rect: ImagePixelRect) -> RegionRenderFingerprint { + RegionRenderFingerprint::Raw { + correlation: RegionReviewCorrelation { + generation: 1, + source: token(), + }, + source_rect: rect, + } +} + +fn edits() -> RegionReviewEdits { + let rect = ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(); + RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 1, + source: token(), + }, + rect, + ) +} + +fn display() -> RegionSelection { + RegionSelection { + start: (0.0, 0.0), + end: (8.0, 8.0), + } +} + +#[test] +fn arming_does_not_change_history() { + let mut edits = edits(); + edits.toggle_mode(); + assert_eq!(edits.mode, CutMode::Armed); + assert!(edits.cuts.is_empty()); + edits.toggle_mode(); + assert_eq!(edits.mode, CutMode::Idle); + assert!(edits.cuts.is_empty()); +} + +#[test] +fn sub_threshold_drag_commits_nothing() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (3.0, 1.0))); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (3.0, 1.0), + display(), + fingerprint(edits.source_rect) + ), + CutCommit::None + ); + assert!(edits.cuts.is_empty()); +} + +#[test] +fn axis_locks_once_past_the_threshold() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (6.0, 1.0))); + assert_eq!(edits.drag.unwrap().axis, Some(CutAxis::Columns)); + assert!(edits.update_drag(RegionInputSource::Pointer, (6.0, 20.0))); + assert_eq!(edits.drag.unwrap().axis, Some(CutAxis::Columns)); +} + +#[test] +fn wrong_owner_cannot_update_or_finish_a_drag() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0))); + assert!(!edits.update_drag(RegionInputSource::Touch, (6.0, 0.0))); + assert_eq!( + edits.finish_drag( + RegionInputSource::Touch, + (6.0, 0.0), + display(), + fingerprint(edits.source_rect) + ), + CutCommit::None + ); + assert!(edits.drag.is_some()); +} + +#[test] +fn valid_commit_appends_clears_redo_and_increments_revision() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (2.0, 0.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (7.0, 0.0))); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (7.0, 0.0), + display(), + fingerprint(edits.source_rect) + ), + CutCommit::Applied + ); + assert_eq!(edits.cuts.len(), 1); + assert!(edits.redo.is_empty()); + assert_eq!(edits.revision, 1); + assert!(!edits.preview_is_current()); +} + +#[test] +fn full_axis_commit_is_rejected_without_a_revision_change() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (8.0, 0.0))); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (8.0, 0.0), + display(), + fingerprint(edits.source_rect) + ), + CutCommit::RejectedFullAxis + ); + assert!(edits.cuts.is_empty()); + assert_eq!(edits.revision, 0); +} + +#[test] +fn undo_redo_and_new_commit_clear_redo() { + let mut edits = edits(); + let fingerprint = fingerprint(edits.source_rect); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.revision = 1; + assert!(edits.undo(fingerprint.clone())); + assert!(edits.cuts.is_empty()); + assert_eq!(edits.redo.len(), 1); + assert!(edits.redo(fingerprint.clone())); + assert_eq!(edits.cuts.len(), 1); + assert!(edits.undo(fingerprint.clone())); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (2.0, 0.0))); + assert!(edits.update_drag(RegionInputSource::Pointer, (7.0, 0.0))); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (7.0, 0.0), + display(), + fingerprint + ), + CutCommit::Applied + ); + assert!(edits.redo.is_empty()); +} + +#[test] +fn undo_and_redo_abandon_an_in_flight_drag() { + let mut edits = edits(); + let fingerprint = fingerprint(edits.source_rect); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.revision = 1; + edits.set_desired_from(fingerprint.clone()); + let desired = edits.desired_preview.clone().unwrap(); + edits.ready_preview = Some(RegionCutPreview { + key: desired, + pixels: std::sync::Arc::new( + crate::screen_pixels::PackedArgb32::new(7, 8, 28, vec![0; 28 * 8]).unwrap(), + ), + display: display(), + }); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); + assert!(edits.undo(fingerprint.clone())); + assert!(edits.drag.is_none()); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (7.0, 0.0), + display(), + fingerprint.clone() + ), + CutCommit::None + ); + + assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); + assert!(edits.redo(fingerprint.clone())); + assert!(edits.drag.is_none()); + assert_eq!( + edits.finish_drag( + RegionInputSource::Pointer, + (7.0, 0.0), + display(), + fingerprint + ), + CutCommit::None + ); +} + +#[test] +fn undo_with_nothing_to_undo_leaves_an_in_flight_drag() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); + assert!(!edits.undo(fingerprint(edits.source_rect))); + assert!(edits.drag.is_some()); +} + +fn review_input() -> crate::input::InputState { + let mut input = crate::input::state::test_support::make_test_input_state(); + input.activate_region_review( + crate::input::state::RegionPurposeTag::CaptureInteractive, + 1, + display(), + ); + input +} + +fn edits_with_current_preview() -> RegionReviewEdits { + let mut edits = edits(); + let fingerprint = fingerprint(edits.source_rect); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.revision = 1; + edits.set_desired_from(fingerprint); + let desired = edits.desired_preview.clone().unwrap(); + edits.ready_preview = Some(RegionCutPreview { + key: desired, + pixels: std::sync::Arc::new( + crate::screen_pixels::PackedArgb32::new(7, 8, 28, vec![0; 28 * 8]).unwrap(), + ), + display: display(), + }); + edits +} + +#[test] +fn undo_and_redo_retire_pointer_touch_and_tablet_owners_before_release() { + for owner in [ + RegionInputSource::Pointer, + RegionInputSource::Touch, + RegionInputSource::Stylus, + ] { + let mut input = review_input(); + let mut edits = Some(edits_with_current_preview()); + edits.as_mut().unwrap().toggle_mode(); + assert!(edits.as_mut().unwrap().begin_drag(owner, (1.0, 1.0))); + assert!(input.begin_region_review_move(owner)); + assert!(input.region_selection_is_owned_by(owner)); + + let fingerprint = fingerprint(edits.as_ref().unwrap().source_rect); + assert!(apply_cut_history_change(&mut edits, &mut input, |edits| { + edits.undo(fingerprint.clone()) + })); + assert!(edits.as_ref().unwrap().drag.is_none()); + assert!( + !input.region_selection_is_owned_by(owner), + "{owner:?} must be retired before release" + ); + assert_eq!( + edits + .as_mut() + .unwrap() + .finish_drag(owner, (7.0, 0.0), display(), fingerprint.clone()), + CutCommit::None, + "{owner:?} release must not commit after undo" + ); + assert!(!input.finish_region_review_move(owner)); + + assert!(edits.as_mut().unwrap().begin_drag(owner, (1.0, 1.0))); + assert!(input.begin_region_review_move(owner)); + assert!(apply_cut_history_change(&mut edits, &mut input, |edits| { + edits.redo(fingerprint.clone()) + })); + assert!(edits.as_ref().unwrap().drag.is_none()); + assert!(!input.region_selection_is_owned_by(owner)); + assert_eq!( + edits + .as_mut() + .unwrap() + .finish_drag(owner, (7.0, 0.0), display(), fingerprint), + CutCommit::None + ); + } +} + +#[test] +fn toggling_cut_mode_off_during_a_drag_returns_the_owner() { + let mut edits = edits(); + edits.toggle_mode(); + assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0))); + assert_eq!(edits.toggle_mode(), Some(RegionInputSource::Pointer)); + assert_eq!(edits.mode, CutMode::Idle); + assert!(edits.drag.is_none()); +} + +#[test] +fn revision_exhaustion_leaves_reset_and_invalidate_untouched() { + let mut edits = edits(); + let cut = CutBand::new(CutAxis::Columns, 1, 2).unwrap(); + edits.cuts.push(cut); + edits.revision = u64::MAX; + edits.failed_revision = Some(u64::MAX); + assert!(!edits.reset()); + assert_eq!(edits.cuts, [cut]); + assert_eq!(edits.failed_revision, Some(u64::MAX)); + + let fingerprint = fingerprint(edits.source_rect); + edits.invalidate_base(fingerprint); + assert_eq!(edits.cuts, [cut]); + assert_eq!(edits.failed_revision, Some(u64::MAX)); + assert_eq!(edits.revision, u64::MAX); +} + +#[test] +fn undoing_the_last_cut_unlocks_the_crop() { + let mut edits = edits(); + let fingerprint = fingerprint(edits.source_rect); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.revision = 1; + assert!(edits.crop_locked()); + assert!(edits.undo(fingerprint)); + assert!(!edits.crop_locked()); +} + +#[test] +fn a_failed_current_preview_stays_failed_until_the_revision_changes() { + let mut edits = edits(); + let desired = CutPreviewKey { + fingerprint: fingerprint(edits.source_rect), + revision: 3, + cuts: vec![CutBand::new(CutAxis::Columns, 1, 2).unwrap()], + }; + edits.cuts = desired.cuts.clone(); + edits.revision = 3; + edits.desired_preview = Some(desired.clone()); + assert!(edits.mark_preview_failed(&desired)); + assert!(edits.current_preview_failed()); + edits.failed_revision = None; + edits.revision = 4; + edits.desired_preview = Some(CutPreviewKey { + revision: 4, + ..desired + }); + assert!(!edits.current_preview_failed()); +} + +#[test] +fn reset_clears_history_and_unlocks_the_crop() { + let mut edits = edits(); + edits.cuts.push(CutBand::new(CutAxis::Rows, 1, 2).unwrap()); + edits + .redo + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.mode = CutMode::Armed; + assert!(edits.reset()); + assert!(edits.cuts.is_empty()); + assert!(edits.redo.is_empty()); + assert!(!edits.crop_locked()); + assert!(edits.preview_is_current()); +} + +#[test] +fn source_rect_cannot_change_while_cuts_exist() { + let mut edits = edits(); + let next = ImagePixelRect::new(1, 1, 4, 4, (8, 8)).unwrap(); + assert!(edits.set_source_rect(next)); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + assert!(!edits.set_source_rect(ImagePixelRect::new(0, 0, 4, 4, (8, 8)).unwrap())); + assert_eq!(edits.source_rect, next); +} + +#[test] +fn cut_start_is_rejected_while_preview_is_pending() { + let mut edits = edits(); + edits.toggle_mode(); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + edits.revision = 1; + edits.set_desired_from(fingerprint(edits.source_rect)); + assert!(!edits.preview_is_current()); + assert!(!edits.can_start_cut_drag()); +} + +#[test] +fn loupe_is_suppressed_when_armed_or_cuts_exist() { + let mut edits = edits(); + assert!(!edits.loupe_suppressed()); + edits.toggle_mode(); + assert!(edits.loupe_suppressed()); + edits.toggle_mode(); + edits + .cuts + .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap()); + assert!(edits.loupe_suppressed()); +} + +#[test] +fn column_cut_preserves_top_left_and_height() { + let token = token(); + let rect = ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(); + let full = output_display_for(&token, rect, &[]).unwrap(); + let cut = output_display_for( + &token, + rect, + &[CutBand::new(CutAxis::Columns, 2, 4).unwrap()], + ) + .unwrap(); + assert_eq!(cut.start, full.start); + assert_eq!(cut.end.1, full.end.1); + assert!(cut.end.0 < full.end.0); +} + +#[test] +fn dominant_axis_ties_choose_columns() { + assert_eq!(dominant_cut_axis(4.0, 4.0), CutAxis::Columns); + assert_eq!(dominant_cut_axis(4.0, 5.0), CutAxis::Rows); +} + +#[test] +fn pointer_edges_map_to_the_inclusive_pixel_edge_domain() { + let display = display(); + assert_eq!( + logical_to_output_point(display, (8, 8), (0.0, 0.0)).map(|point| (point.x, point.y)), + Some((0.0, 0.0)) + ); + assert_eq!( + logical_to_output_point(display, (8, 8), (8.0, 8.0)).map(|point| (point.x, point.y)), + Some((8.0, 8.0)) + ); + let clamped = logical_to_output_point(display, (8, 8), (-2.0, 20.0)).unwrap(); + assert_eq!((clamped.x, clamped.y), (0.0, 8.0)); +} + +#[test] +fn composed_board_origin_stays_put_while_size_contracts() { + let source = crate::canvas_export::CanvasExportRect::new(10.0, 20.0, 80.0, 40.0).unwrap(); + let composed = crate::backend::wayland::state::region_capture::world_rect_for_composed_region( + source, + (8, 8), + (6, 4), + ) + .unwrap(); + assert_eq!(composed.x, 10.0); + assert_eq!(composed.y, 20.0); + assert_eq!(composed.width, 60.0); + assert_eq!(composed.height, 20.0); +} From 1511695b34f554083f9a3d79bf28c9e06cce6ad1 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:12:29 +0200 Subject: [PATCH 31/42] refactor(text): share measurement across shape painting and exports --- src/canvas_export/mod.rs | 7 +- src/canvas_export/page.rs | 20 ++- src/canvas_export/pdf.rs | 5 + src/canvas_export/png.rs | 10 +- src/canvas_export/region.rs | 2 + src/canvas_export/tests/cache_tests.rs | 98 ++++++++++- src/draw/mod.rs | 10 +- src/draw/render/context.rs | 64 ++++++- src/draw/render/context/text_tests.rs | 166 ++++++++++++++++++ src/draw/render/mod.rs | 12 +- src/draw/render/selection.rs | 21 ++- src/draw/render/shapes.rs | 38 +++-- src/draw/render/text.rs | 206 ++++++++++++++++++++++- src/draw/shape/arrow_label.rs | 30 +--- src/draw/shape/mod.rs | 12 +- src/draw/shape/step_marker.rs | 18 +- src/draw/shape/tests.rs | 26 ++- src/draw/shape/text.rs | 7 +- src/draw/shape/text_cache.rs | 28 +-- src/draw/shape/text_cache/owner/tests.rs | 15 +- src/draw/shape/text_cache/tests.rs | 22 ++- src/input/hit_test/tests.rs | 46 ++++- 22 files changed, 712 insertions(+), 151 deletions(-) create mode 100644 src/draw/render/context/text_tests.rs diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs index 1d78c0a4f..bf8d27394 100644 --- a/src/canvas_export/mod.rs +++ b/src/canvas_export/mod.rs @@ -25,7 +25,7 @@ mod tests { use std::sync::Arc; use super::*; - use crate::canvas_export::page::draw_canvas_page; + use crate::canvas_export::page::draw_canvas_page_with_measurer; use crate::canvas_export::png::render_canvas_surface; use crate::config::{PdfExportConfig, RenderColorMappingConfig, RenderProfileConfig}; use crate::draw::{BLACK, BlurStyle, FontDescriptor, Frame, RED, Shape, WHITE}; @@ -178,7 +178,7 @@ mod tests { } #[test] - fn draw_canvas_page_uses_explicit_output_scale() { + fn draw_canvas_page_with_measurer_uses_explicit_output_scale() { let mut frame = Frame::new(); frame.add_shape(Shape::Rect { x: 4, @@ -193,7 +193,8 @@ mod tests { cairo::ImageSurface::create(cairo::Format::ARgb32, 20, 20).expect("surface"); { let ctx = cairo::Context::new(&surface).expect("context"); - draw_canvas_page( + draw_canvas_page_with_measurer( + &crate::draw::TextMeasurer::default(), &mut crate::draw::RenderCtx::new(&ctx, &mut crate::draw::RenderCaches::default()), &page_snapshot(frame), 2.0, diff --git a/src/canvas_export/page.rs b/src/canvas_export/page.rs index cda9153d9..025ed3c58 100644 --- a/src/canvas_export/page.rs +++ b/src/canvas_export/page.rs @@ -96,7 +96,8 @@ impl CanvasExportRect { } } -pub fn draw_canvas_page( +pub fn draw_canvas_page_with_measurer( + measurer: &crate::draw::TextMeasurer, render: &mut RenderCtx<'_, '_>, page: &CanvasPageExportSnapshot, output_scale: f64, @@ -125,6 +126,7 @@ pub fn draw_canvas_page( (f64::from(page.viewport_height) * output_scale).ceil() as u32, ); let rendered = draw_canvas_page_region( + measurer, render, page, &backdrop, @@ -137,7 +139,9 @@ pub fn draw_canvas_page( rendered } +#[allow(clippy::too_many_arguments)] pub(crate) fn draw_canvas_page_region( + measurer: &crate::draw::TextMeasurer, render: &mut RenderCtx<'_, '_>, page: &CanvasPageExportSnapshot, backdrop: &ExportBackdrop, @@ -161,8 +165,14 @@ pub(crate) fn draw_canvas_page_region( destination.height / source.height, ); ctx.translate(-source.x, -source.y); - let rendered = - draw_canvas_page_contents(render, page, backdrop, paint_backdrop, fallback_target_size); + let rendered = draw_canvas_page_contents( + measurer, + render, + page, + backdrop, + paint_backdrop, + fallback_target_size, + ); let _ = ctx.restore(); rendered } @@ -381,6 +391,7 @@ impl ExportBackdrop { } fn draw_canvas_page_contents( + measurer: &crate::draw::TextMeasurer, render: &mut RenderCtx<'_, '_>, page: &CanvasPageExportSnapshot, backdrop: &ExportBackdrop, @@ -422,7 +433,8 @@ fn draw_canvas_page_contents( }, &replay_ctx, ), - other => render.render_shape_over_with_halo( + other => render.render_shape_over_with_halo_with_measurer( + measurer, other, known_background_luminance, page.text_halo_enabled, diff --git a/src/canvas_export/pdf.rs b/src/canvas_export/pdf.rs index 3042498c8..d50da323b 100644 --- a/src/canvas_export/pdf.rs +++ b/src/canvas_export/pdf.rs @@ -144,6 +144,7 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca .map_err(|err| CaptureError::ImageError(format!("Failed to create PDF context: {err}")))?; let mut caches = RenderCaches::default(); + let measurer = crate::draw::TextMeasurer::default(); let ui_text = crate::ui_text::UiTextEngine::default(); for page in &snapshot.pages { let layout = page.layout; @@ -157,6 +158,7 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca let backdrop = ExportBackdrop::new(&page.page.backdrop)?; if frame_has_magnified_spotlight(&page.page.frame) { render_magnified_page_raster( + &measurer, &mut RenderCtx::new(&ctx, &mut caches), &page.page, &backdrop, @@ -168,6 +170,7 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca CanvasExportBackdropSnapshot::PersistedImage { .. } ); draw_canvas_page_region( + &measurer, &mut RenderCtx::new(&ctx, &mut caches), &page.page, &backdrop, @@ -203,6 +206,7 @@ pub fn render_board_pdf(snapshot: &BoardPdfExportSnapshot) -> Result, Ca } fn render_magnified_page_raster( + measurer: &crate::draw::TextMeasurer, render: &mut RenderCtx<'_, '_>, page: &CanvasPageExportSnapshot, backdrop: &ExportBackdrop, @@ -220,6 +224,7 @@ fn render_magnified_page_raster( )) })?; draw_canvas_page_region( + measurer, &mut RenderCtx::new(&raster_ctx, render.caches), page, backdrop, diff --git a/src/canvas_export/png.rs b/src/canvas_export/png.rs index 19c02e68a..14d99123a 100644 --- a/src/canvas_export/png.rs +++ b/src/canvas_export/png.rs @@ -5,7 +5,7 @@ use crate::util::Rect; use super::page::{ CanvasExportBackdropSnapshot, CanvasPageExportSnapshot, SpotlightPassSnapshot, - draw_canvas_page, validate_spotlight_magnifier_source, + draw_canvas_page_with_measurer, validate_spotlight_magnifier_source, }; #[derive(Debug, Clone)] @@ -78,6 +78,7 @@ pub(crate) fn render_canvas_surface( snapshot: &CanvasExportSnapshot, ) -> Result { let mut caches = RenderCaches::default(); + let measurer = crate::draw::TextMeasurer::default(); let viewport = snapshot.viewport; let scale = viewport.scale.max(1); let physical_width = viewport.logical_width.saturating_mul(scale as u32); @@ -100,7 +101,12 @@ pub(crate) fn render_canvas_surface( })?; let page = canvas_page_from_snapshot(snapshot); - draw_canvas_page(&mut RenderCtx::new(&ctx, &mut caches), &page, scale as f64)?; + draw_canvas_page_with_measurer( + &measurer, + &mut RenderCtx::new(&ctx, &mut caches), + &page, + scale as f64, + )?; } if let Some(profile) = snapshot.render_profile.as_ref() { diff --git a/src/canvas_export/region.rs b/src/canvas_export/region.rs index 2f67f6751..06aaaf5a4 100644 --- a/src/canvas_export/region.rs +++ b/src/canvas_export/region.rs @@ -163,6 +163,7 @@ pub(crate) fn render_canvas_region_pixels( snapshot: CanvasRegionExportSnapshot, ) -> Result { let mut caches = RenderCaches::default(); + let measurer = crate::draw::TextMeasurer::default(); let working_selection = snapshot .source .magnifier_working_selection(snapshot.selection, &snapshot.frame) @@ -212,6 +213,7 @@ pub(crate) fn render_canvas_region_pixels( ) .expect("validated non-empty destination"); draw_canvas_page_region( + &measurer, &mut RenderCtx::new(&ctx, &mut caches), &page, &backdrop, diff --git a/src/canvas_export/tests/cache_tests.rs b/src/canvas_export/tests/cache_tests.rs index 5607a544c..8c1a70b61 100644 --- a/src/canvas_export/tests/cache_tests.rs +++ b/src/canvas_export/tests/cache_tests.rs @@ -1,6 +1,8 @@ use std::sync::Arc; -use super::page::{CanvasExportBackdropSnapshot, CanvasPageExportSnapshot, draw_canvas_page}; +use super::page::{ + CanvasExportBackdropSnapshot, CanvasPageExportSnapshot, draw_canvas_page_with_measurer, +}; use crate::draw::{BlurStyle, EmbeddedImage, Frame, RenderCaches, RenderCtx, Shape}; fn page(frame: Frame, backdrop: CanvasExportBackdropSnapshot) -> CanvasPageExportSnapshot { @@ -20,7 +22,13 @@ fn pixels(page: &CanvasPageExportSnapshot, caches: &mut RenderCaches) -> Vec let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 20, 20).unwrap(); { let cairo = cairo::Context::new(&surface).unwrap(); - draw_canvas_page(&mut RenderCtx::new(&cairo, caches), page, 1.0).unwrap(); + draw_canvas_page_with_measurer( + &crate::draw::TextMeasurer::default(), + &mut RenderCtx::new(&cairo, caches), + page, + 1.0, + ) + .unwrap(); } surface.flush(); surface.data().unwrap().to_vec() @@ -325,3 +333,89 @@ fn png_and_region_entries_preserve_embedded_image_and_text_pixels() { ); } } + +#[test] +fn mixed_image_and_wrapped_text_pages_reuse_measurement_across_output_scales() { + let image = cairo::ImageSurface::create(cairo::Format::ARgb32, 2, 2).unwrap(); + let ctx = cairo::Context::new(&image).unwrap(); + ctx.set_source_rgb(0.1, 0.4, 0.8); + ctx.paint().unwrap(); + let mut png = Vec::new(); + image.write_to_png(&mut png).unwrap(); + let mut frame = Frame::new(); + frame.add_shape(Shape::Image { + x: 5, + y: 5, + w: 190, + h: 100, + data: EmbeddedImage { + mime_type: "image/png".into(), + width: 2, + height: 2, + bytes: png.into(), + }, + }); + frame.add_shape(Shape::Text { + x: 20, + y: 35, + text: "Page 測試 العربية wrapped text".into(), + color: crate::draw::WHITE, + size: 16.0, + font_descriptor: crate::draw::FontDescriptor::default(), + background_enabled: false, + wrap_width: Some(140), + }); + let mut page = page( + frame, + CanvasExportBackdropSnapshot::Solid(crate::draw::WHITE), + ); + page.viewport_width = 220; + page.viewport_height = 130; + let mut image_only = page.clone(); + image_only + .frame + .shapes + .retain(|shape| matches!(shape.shape, Shape::Image { .. })); + let paint = |page: &CanvasPageExportSnapshot, + measurer: &crate::draw::TextMeasurer, + caches: &mut RenderCaches, + scale: i32| { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 220 * scale, 130 * scale).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + draw_canvas_page_with_measurer( + measurer, + &mut RenderCtx::new(&ctx, caches), + page, + scale as f64, + ) + .unwrap(); + } + surface.data().unwrap().to_vec() + }; + let measurer = crate::draw::TextMeasurer::default(); + let mut caches = RenderCaches::default(); + for scale in [1, 2, 1] { + let actual = paint(&page, &measurer, &mut caches, scale); + let fresh = paint( + &page, + &crate::draw::TextMeasurer::default(), + &mut RenderCaches::default(), + scale, + ); + assert!(actual == fresh, "mixed export page at scale {scale}"); + let baseline = paint(&image_only, &measurer, &mut RenderCaches::default(), scale); + assert!( + actual != baseline, + "text must paint over the image at scale {scale}" + ); + assert!( + actual + .as_chunks::<4>() + .0 + .iter() + .any(|pixel| pixel[..3] != [255, 255, 255]) + ); + } +} diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 87ee62a10..0195751ae 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -40,10 +40,12 @@ pub use render::{ SpotlightMagnifierSource, SpotlightPass, SpotlightRegion, SpotlightSnapshotStrategy, caret_line_width, caret_outline_width, painted_background_luminance, perceived_luminance, render_blur_rect, render_board_background, render_click_highlight, render_freehand_borrowed, - render_marker_stroke_borrowed, render_selection_halo, render_selection_handles, render_shape, - render_shape_over, render_shape_over_with_halo, render_shape_with_halo, - render_spotlight_magnification_pass, render_spotlight_pass, render_sticky_note, render_text, - render_text_over_with_halo, render_text_with_halo, selection_handle_rects, + render_marker_stroke_borrowed, render_selection_halo, render_selection_halo_with_measurer, + render_selection_handles, render_shape, render_shape_over, render_shape_over_with_halo, + render_shape_with_halo, render_spotlight_magnification_pass, render_spotlight_pass, + render_sticky_note, render_sticky_note_with_measurer, render_text, render_text_over_with_halo, + render_text_over_with_halo_with_measurer, render_text_with_halo, + render_text_with_halo_with_measurer, render_text_with_measurer, selection_handle_rects, spotlight_regions_for_frame, sticky_note_foreground, text_outline_color, }; pub(crate) use shape::with_legacy_measurer; diff --git a/src/draw/render/context.rs b/src/draw/render/context.rs index 3eaeaa312..e4194f871 100644 --- a/src/draw/render/context.rs +++ b/src/draw/render/context.rs @@ -27,15 +27,52 @@ impl<'c, 'r> RenderCtx<'c, 'r> { } pub fn render_shape(&mut self, shape: &Shape) { - self.render_shape_with_halo(shape, true); + crate::draw::with_legacy_measurer(|measurer| { + self.render_shape_with_measurer(measurer, shape) + }) + } + + pub fn render_shape_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + shape: &Shape, + ) { + self.render_shape_with_halo_with_measurer(measurer, shape, true); } pub fn render_shape_with_halo(&mut self, shape: &Shape, text_halo_enabled: bool) { - self.render_shape_over_with_halo(shape, None, text_halo_enabled); + crate::draw::with_legacy_measurer(|measurer| { + self.render_shape_with_halo_with_measurer(measurer, shape, text_halo_enabled) + }) + } + + pub fn render_shape_with_halo_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + shape: &Shape, + text_halo_enabled: bool, + ) { + self.render_shape_over_with_halo_with_measurer(measurer, shape, None, text_halo_enabled); } pub fn render_shape_over(&mut self, shape: &Shape, known_background_luminance: Option) { - self.render_shape_over_with_halo(shape, known_background_luminance, true); + crate::draw::with_legacy_measurer(|measurer| { + self.render_shape_over_with_measurer(measurer, shape, known_background_luminance) + }) + } + + pub fn render_shape_over_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + shape: &Shape, + known_background_luminance: Option, + ) { + self.render_shape_over_with_halo_with_measurer( + measurer, + shape, + known_background_luminance, + true, + ); } pub fn render_shape_over_with_halo( @@ -43,8 +80,26 @@ impl<'c, 'r> RenderCtx<'c, 'r> { shape: &Shape, known_background_luminance: Option, text_halo_enabled: bool, + ) { + crate::draw::with_legacy_measurer(|measurer| { + self.render_shape_over_with_halo_with_measurer( + measurer, + shape, + known_background_luminance, + text_halo_enabled, + ) + }) + } + + pub fn render_shape_over_with_halo_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + shape: &Shape, + known_background_luminance: Option, + text_halo_enabled: bool, ) { super::shapes::render_shape_with_cache( + measurer, &mut self.caches.images, self.cairo, shape, @@ -64,3 +119,6 @@ impl<'c, 'r> RenderCtx<'c, 'r> { #[cfg(test)] mod tests; + +#[cfg(test)] +mod text_tests; diff --git a/src/draw/render/context/text_tests.rs b/src/draw/render/context/text_tests.rs new file mode 100644 index 000000000..30257e558 --- /dev/null +++ b/src/draw/render/context/text_tests.rs @@ -0,0 +1,166 @@ +use super::{RenderCaches, RenderCtx}; +use crate::draw::{ + ArrowLabel, ArrowStyle, FontDescriptor, Frame, RED, Shape, StepMarkerLabel, TextMeasurer, + YELLOW, +}; + +fn scene() -> Frame { + let font = FontDescriptor::default(); + let mut frame = Frame::new(); + frame.add_shape(Shape::Text { + x: 20, + y: 45, + text: "Wrapped العربية 測試 words across lines".into(), + color: RED, + size: 20.0, + font_descriptor: font.clone(), + background_enabled: true, + wrap_width: Some(150), + }); + frame.add_shape(Shape::StickyNote { + x: 200, + y: 45, + text: "Note 測試 words".into(), + background: YELLOW, + size: 18.0, + font_descriptor: font.clone(), + wrap_width: Some(120), + }); + frame.add_shape(Shape::Arrow { + x1: 30, + y1: 210, + x2: 330, + y2: 210, + color: RED, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.2, + label: Some(ArrowLabel { + value: 72, + size: 24.0, + font_descriptor: font.clone(), + }), + }); + frame.add_shape(Shape::StepMarker { + x: 220, + y: 290, + color: RED, + label: StepMarkerLabel { + value: 108, + size: 22.0, + font_descriptor: font, + }, + }); + frame +} + +fn pixels( + measurer: &TextMeasurer, + caches: &mut RenderCaches, + frame: &Frame, + density: i32, + halo: bool, + legacy: bool, +) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 400 * density, 380 * density).unwrap(); + surface.set_device_scale(density as f64, density as f64); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.set_source_rgb(1.0, 1.0, 1.0); + ctx.paint().unwrap(); + ctx.translate(3.0, 4.0); + let before = ctx.matrix(); + let mut render = RenderCtx::new(&ctx, caches); + for shape in &frame.shapes { + if legacy { + render.render_shape_with_halo(&shape.shape, halo); + crate::draw::render_selection_halo(&ctx, shape); + } else { + render.render_shape_with_halo_with_measurer(measurer, &shape.shape, halo); + crate::draw::render_selection_halo_with_measurer(measurer, &ctx, shape); + } + } + assert_eq!(ctx.matrix(), before); + } + surface.data().unwrap().to_vec() +} + +#[test] +fn retained_shape_measurement_matches_fresh_and_legacy_paint_and_preserves_bounds() { + let frame = scene(); + let measurer = TextMeasurer::default(); + let mut caches = RenderCaches::default(); + let bounds: Vec<_> = frame + .shapes + .iter() + .map(|shape| shape.bounding_box_with(&measurer)) + .collect(); + for density in [1, 2, 1] { + for halo in [true, false] { + let actual = pixels(&measurer, &mut caches, &frame, density, halo, false); + let fresh = pixels( + &TextMeasurer::default(), + &mut RenderCaches::default(), + &frame, + density, + halo, + false, + ); + assert!( + actual == fresh, + "fresh shape pixels: density {density}, halo {halo}" + ); + let legacy = pixels( + &measurer, + &mut RenderCaches::default(), + &frame, + density, + halo, + true, + ); + assert!( + actual == legacy, + "legacy shape pixels: density {density}, halo {halo}" + ); + assert_eq!( + frame + .shapes + .iter() + .map(|shape| shape.bounding_box_with(&measurer)) + .collect::>(), + bounds + ); + } + } +} + +#[test] +fn empty_sticky_note_preview_keeps_its_background_with_explicit_measurement() { + let paint = |measurer: &TextMeasurer| { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 100).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + super::super::text::render_sticky_note_preview_with_measurer( + measurer, + &ctx, + 20, + 40, + "", + YELLOW, + 20.0, + &FontDescriptor::default(), + Some(100), + ); + } + surface.data().unwrap().to_vec() + }; + let measurer = TextMeasurer::default(); + let first = paint(&measurer); + assert!(first.iter().any(|byte| *byte != 0)); + assert!(first == paint(&measurer)); + assert!(first == paint(&TextMeasurer::default())); +} diff --git a/src/draw/render/mod.rs b/src/draw/render/mod.rs index 358e04824..b9df8958d 100644 --- a/src/draw/render/mod.rs +++ b/src/draw/render/mod.rs @@ -26,7 +26,10 @@ pub use highlight::render_click_highlight; pub use pressure_strokes::render_freehand_pressure_borrowed; pub(crate) use pressure_strokes::render_freehand_pressure_preview_borrowed; pub(crate) use primitives::{render_polygon_preview, with_saved_state}; -pub use selection::{render_selection_halo, render_selection_handles, selection_handle_rects}; +pub use selection::{ + render_selection_halo, render_selection_halo_with_measurer, render_selection_handles, + selection_handle_rects, +}; pub use shapes::{ render_shape, render_shape_over, render_shape_over_with_halo, render_shape_with_halo, }; @@ -40,7 +43,10 @@ pub(crate) use strokes::render_eraser_stroke; pub use strokes::{render_freehand_borrowed, render_marker_stroke_borrowed}; pub(crate) use text::render_sticky_note_preview; pub use text::{ - caret_line_width, caret_outline_width, render_sticky_note, render_text, render_text_over, - render_text_over_with_halo, render_text_with_halo, sticky_note_foreground, text_outline_color, + caret_line_width, caret_outline_width, render_sticky_note, render_sticky_note_with_measurer, + render_text, render_text_over, render_text_over_with_halo, + render_text_over_with_halo_with_measurer, render_text_over_with_measurer, + render_text_with_halo, render_text_with_halo_with_measurer, render_text_with_measurer, + sticky_note_foreground, text_outline_color, }; pub use types::EraserReplayContext; diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs index 0694c2746..e21d2036d 100644 --- a/src/draw/render/selection.rs +++ b/src/draw/render/selection.rs @@ -3,7 +3,7 @@ use super::primitives::{render_arrow, render_ellipse, render_line, render_polygo use super::spotlight::{SpotlightRegion, render_spotlight_outline}; use super::strokes::render_freehand_borrowed; use crate::draw::frame::DrawnShape; -use crate::draw::shape::{step_marker_outline_thickness, step_marker_radius}; +use crate::draw::shape::{step_marker_outline_thickness, step_marker_radius_with}; use crate::draw::{Color, Shape}; use crate::util::Rect; @@ -30,6 +30,16 @@ const SELECTION_GLOW: Color = Color { /// Renders a selection halo overlay for a drawn shape. pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { + crate::draw::with_legacy_measurer(|measurer| { + render_selection_halo_with_measurer(measurer, ctx, drawn) + }) +} + +pub fn render_selection_halo_with_measurer( + measurer: &crate::draw::TextMeasurer, + ctx: &cairo::Context, + drawn: &DrawnShape, +) { let glow = SELECTION_GLOW; let outline_width = 4.0; @@ -123,7 +133,7 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { ); } Shape::BlurRect { .. } => { - if let Some(bounds) = drawn.bounding_box() { + if let Some(bounds) = drawn.bounding_box_with(measurer) { let padding = 3.0; let x = bounds.x as f64 - padding; let y = bounds.y as f64 - padding; @@ -142,7 +152,8 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { render_freehand_borrowed(ctx, points, glow, thick + outline_width); } Shape::StepMarker { x, y, label, .. } => { - let radius = step_marker_radius(label.value, label.size, &label.font_descriptor); + let radius = + step_marker_radius_with(measurer, label.value, label.size, &label.font_descriptor); let outline = step_marker_outline_thickness(label.size); let halo_radius = radius + outline_width; let fill = Color { @@ -165,7 +176,7 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { render_freehand_borrowed(ctx, points, glow, outline); } Shape::Text { .. } | Shape::Image { .. } => { - if let Some(bounds) = drawn.bounding_box() { + if let Some(bounds) = drawn.bounding_box_with(measurer) { let padding = 4.0; let x = bounds.x as f64 - padding; let y = bounds.y as f64 - padding; @@ -181,7 +192,7 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { } } Shape::StickyNote { .. } => { - if let Some(bounds) = drawn.bounding_box() { + if let Some(bounds) = drawn.bounding_box_with(measurer) { let padding = 4.0; let x = bounds.x as f64 - padding; let y = bounds.y as f64 - padding; diff --git a/src/draw/render/shapes.rs b/src/draw/render/shapes.rs index 24adbcb28..ce13a0ef7 100644 --- a/src/draw/render/shapes.rs +++ b/src/draw/render/shapes.rs @@ -4,12 +4,11 @@ use super::image::render_image_shape; use super::pressure_strokes::render_packed_freehand_pressure_borrowed; use super::primitives::{render_arrow, render_ellipse, render_line, render_polygon, render_rect}; use super::strokes::{render_freehand_borrowed, render_marker_stroke_borrowed}; -use super::text::{render_sticky_note, render_text_over_with_halo}; +use super::text::{render_sticky_note_with_measurer, render_text_over_with_halo_with_measurer}; use crate::draw::Color; use crate::draw::shape::{ ARROW_LABEL_BACKGROUND, ArrowLabel, ArrowStyle, Shape, StepMarkerLabel, arrow_label_ends, - arrow_label_layout, measure_text_with_context, step_marker_outline_thickness, - step_marker_radius, + arrow_label_layout_with, step_marker_outline_thickness, step_marker_radius_with, }; #[derive(Clone, Copy)] @@ -84,6 +83,7 @@ pub fn render_shape_over_with_halo( } pub(super) fn render_shape_with_cache( + measurer: &crate::draw::TextMeasurer, images: &mut super::image::ImageSurfaceCache, ctx: &cairo::Context, shape: &Shape, @@ -161,6 +161,7 @@ pub(super) fn render_shape_with_cache( label, } => { render_arrow_shape( + measurer, ctx, ArrowRenderSpec { start: (*x1, *y1), @@ -207,7 +208,8 @@ pub(super) fn render_shape_with_cache( background_enabled, wrap_width, } => { - render_text_over_with_halo( + render_text_over_with_halo_with_measurer( + measurer, ctx, *x, *y, @@ -223,6 +225,7 @@ pub(super) fn render_shape_with_cache( } Shape::StepMarker { x, y, color, label } => { render_step_marker_shape( + measurer, ctx, StepMarkerRenderSpec { center: (*x, *y), @@ -241,7 +244,8 @@ pub(super) fn render_shape_with_cache( font_descriptor, wrap_width, } => { - render_sticky_note( + render_sticky_note_with_measurer( + measurer, ctx, *x, *y, @@ -268,7 +272,12 @@ pub(super) fn render_shape_with_cache( } } -fn render_arrow_shape(ctx: &cairo::Context, arrow: ArrowRenderSpec<'_>, text: ShapeTextOptions) { +fn render_arrow_shape( + measurer: &crate::draw::TextMeasurer, + ctx: &cairo::Context, + arrow: ArrowRenderSpec<'_>, + text: ShapeTextOptions, +) { // Only the label needs these: `render_arrow` reads `head_at_end` itself. // `Double` deliberately ignores the flag here, matching the outline it // draws either way. @@ -298,7 +307,8 @@ fn render_arrow_shape(ctx: &cairo::Context, arrow: ArrowRenderSpec<'_>, text: Sh return; }; let label_text = label.value.to_string(); - let Some(layout) = arrow_label_layout( + let Some(layout) = arrow_label_layout_with( + measurer, tip_x, tip_y, tail_x, @@ -311,7 +321,8 @@ fn render_arrow_shape(ctx: &cairo::Context, arrow: ArrowRenderSpec<'_>, text: Sh ) else { return; }; - render_text_over_with_halo( + render_text_over_with_halo_with_measurer( + measurer, ctx, layout.x, layout.y, @@ -327,12 +338,14 @@ fn render_arrow_shape(ctx: &cairo::Context, arrow: ArrowRenderSpec<'_>, text: Sh } fn render_step_marker_shape( + measurer: &crate::draw::TextMeasurer, ctx: &cairo::Context, marker: StepMarkerRenderSpec<'_>, text: ShapeTextOptions, ) { let label_text = marker.label.value.to_string(); - let radius = step_marker_radius( + let radius = step_marker_radius_with( + measurer, marker.label.value, marker.label.size, &marker.label.font_descriptor, @@ -389,9 +402,7 @@ fn render_step_marker_shape( .label .font_descriptor .to_pango_string(marker.label.size); - let Some(metrics) = - measure_text_with_context(ctx, &label_text, &font_desc, marker.label.size, None) - else { + let Some(metrics) = measurer.measure(&label_text, &font_desc, marker.label.size, None) else { return; }; let center_offset_x = metrics.ink_x + metrics.ink_width / 2.0; @@ -399,7 +410,8 @@ fn render_step_marker_shape( let baseline_x = (f64::from(marker.center.0) - center_offset_x).round() as i32; let baseline_y = (f64::from(marker.center.1) - center_offset_y + metrics.baseline).round() as i32; - render_text_over_with_halo( + render_text_over_with_halo_with_measurer( + measurer, ctx, baseline_x, baseline_y, diff --git a/src/draw/render/text.rs b/src/draw/render/text.rs index 6d46ef861..977f90adf 100644 --- a/src/draw/render/text.rs +++ b/src/draw/render/text.rs @@ -1,7 +1,7 @@ use super::backdrop_probe; use crate::draw::shape::{ - TextMeasurement, measure_text_with_context, sticky_note_layout, sticky_note_layout_text, - sticky_note_text_layout, + TextMeasurement, sticky_note_layout, sticky_note_layout_text, + sticky_note_text_layout_with_measurer, }; use crate::draw::{Color, FontDescriptor}; use std::f64::consts::{FRAC_PI_2, PI}; @@ -37,7 +37,37 @@ pub fn render_text( background_enabled: bool, wrap_width: Option, ) { - render_text_with_halo( + crate::draw::with_legacy_measurer(|measurer| { + render_text_with_measurer( + measurer, + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + ) + }) +} + +#[allow(clippy::too_many_arguments)] +pub fn render_text_with_measurer( + measurer: &crate::draw::TextMeasurer, + ctx: &cairo::Context, + x: i32, + y: i32, + text: &str, + color: Color, + size: f64, + font_descriptor: &FontDescriptor, + background_enabled: bool, + wrap_width: Option, +) { + render_text_with_halo_with_measurer( + measurer, ctx, x, y, @@ -65,7 +95,39 @@ pub fn render_text_with_halo( wrap_width: Option, halo_enabled: bool, ) { - render_text_over_with_halo( + crate::draw::with_legacy_measurer(|measurer| { + render_text_with_halo_with_measurer( + measurer, + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + halo_enabled, + ) + }) +} + +#[allow(clippy::too_many_arguments)] +pub fn render_text_with_halo_with_measurer( + measurer: &crate::draw::TextMeasurer, + ctx: &cairo::Context, + x: i32, + y: i32, + text: &str, + color: Color, + size: f64, + font_descriptor: &FontDescriptor, + background_enabled: bool, + wrap_width: Option, + halo_enabled: bool, +) { + render_text_over_with_halo_with_measurer( + measurer, ctx, x, y, @@ -100,7 +162,39 @@ pub fn render_text_over( wrap_width: Option, known_background_luminance: Option, ) { - render_text_over_with_halo( + crate::draw::with_legacy_measurer(|measurer| { + render_text_over_with_measurer( + measurer, + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + known_background_luminance, + ) + }) +} + +#[allow(clippy::too_many_arguments)] +pub fn render_text_over_with_measurer( + measurer: &crate::draw::TextMeasurer, + ctx: &cairo::Context, + x: i32, + y: i32, + text: &str, + color: Color, + size: f64, + font_descriptor: &FontDescriptor, + background_enabled: bool, + wrap_width: Option, + known_background_luminance: Option, +) { + render_text_over_with_halo_with_measurer( + measurer, ctx, x, y, @@ -129,6 +223,39 @@ pub fn render_text_over_with_halo( wrap_width: Option, known_background_luminance: Option, halo_enabled: bool, +) { + crate::draw::with_legacy_measurer(|measurer| { + render_text_over_with_halo_with_measurer( + measurer, + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + known_background_luminance, + halo_enabled, + ) + }) +} + +#[allow(clippy::too_many_arguments)] +pub fn render_text_over_with_halo_with_measurer( + measurer: &crate::draw::TextMeasurer, + ctx: &cairo::Context, + x: i32, + y: i32, + text: &str, + color: Color, + size: f64, + font_descriptor: &FontDescriptor, + background_enabled: bool, + wrap_width: Option, + known_background_luminance: Option, + halo_enabled: bool, ) { // Save context state to prevent settings from leaking to other drawing operations ctx.save().ok(); @@ -155,7 +282,8 @@ pub fn render_text_over_with_halo( } // Use cached measurements for ink rect (avoids repeated Pango measurement) - let measurement = measure_text_with_context(ctx, text, &font_desc_str, size, wrap_width) + let measurement = measurer + .measure(text, &font_desc_str, size, wrap_width) .unwrap_or_else(|| { // Fallback: measure directly. let (ink_rect, logical_rect) = layout.extents(); @@ -284,11 +412,39 @@ pub fn render_sticky_note( size: f64, font_descriptor: &FontDescriptor, wrap_width: Option, +) { + crate::draw::with_legacy_measurer(|measurer| { + render_sticky_note_with_measurer( + measurer, + ctx, + x, + y, + text, + background, + size, + font_descriptor, + wrap_width, + ) + }) +} + +#[allow(clippy::too_many_arguments)] +pub fn render_sticky_note_with_measurer( + measurer: &crate::draw::TextMeasurer, + ctx: &cairo::Context, + x: i32, + y: i32, + text: &str, + background: Color, + size: f64, + font_descriptor: &FontDescriptor, + wrap_width: Option, ) { if text.is_empty() { return; } render_sticky_note_layout( + measurer, ctx, x, y, @@ -313,8 +469,36 @@ pub(crate) fn render_sticky_note_preview( size: f64, font_descriptor: &FontDescriptor, wrap_width: Option, +) { + crate::draw::with_legacy_measurer(|measurer| { + render_sticky_note_preview_with_measurer( + measurer, + ctx, + x, + y, + text, + background, + size, + font_descriptor, + wrap_width, + ) + }) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_sticky_note_preview_with_measurer( + measurer: &crate::draw::TextMeasurer, + ctx: &cairo::Context, + x: i32, + y: i32, + text: &str, + background: Color, + size: f64, + font_descriptor: &FontDescriptor, + wrap_width: Option, ) { render_sticky_note_layout( + measurer, ctx, x, y, @@ -329,6 +513,7 @@ pub(crate) fn render_sticky_note_preview( #[allow(clippy::too_many_arguments)] fn render_sticky_note_layout( + measurer: &crate::draw::TextMeasurer, ctx: &cairo::Context, x: i32, y: i32, @@ -342,7 +527,14 @@ fn render_sticky_note_layout( ctx.save().ok(); ctx.set_antialias(cairo::Antialias::Best); - let text_layout = sticky_note_text_layout(ctx, layout_text, size, font_descriptor, wrap_width); + let text_layout = sticky_note_text_layout_with_measurer( + measurer, + ctx, + layout_text, + size, + font_descriptor, + wrap_width, + ); let base_x = x as f64; let base_y = y as f64 - text_layout.baseline; let note_layout = sticky_note_layout( diff --git a/src/draw/shape/arrow_label.rs b/src/draw/shape/arrow_label.rs index bbf0b444c..147b4f4aa 100644 --- a/src/draw/shape/arrow_label.rs +++ b/src/draw/shape/arrow_label.rs @@ -2,7 +2,7 @@ use crate::draw::{ArrowStyle, FontDescriptor}; use crate::util::Rect; use super::text::{text_bounds_from_metrics, text_layout_metrics}; -use super::text_cache::{TextMeasurer, with_legacy_measurer}; +use super::text_cache::TextMeasurer; pub(crate) const ARROW_LABEL_BACKGROUND: bool = true; @@ -53,34 +53,6 @@ pub(crate) struct ArrowLabelLayout { /// so a label anchored to the chord's midpoint would float in the gap the arrow /// was drawn to route around; the anchor follows the arc instead, and sits on /// the outside of the curve where there is room for it. -#[allow(clippy::too_many_arguments)] -pub(crate) fn arrow_label_layout( - tip_x: i32, - tip_y: i32, - tail_x: i32, - tail_y: i32, - thick: f64, - bend: f64, - label_text: &str, - label_size: f64, - font_descriptor: &FontDescriptor, -) -> Option { - with_legacy_measurer(|measurer| { - arrow_label_layout_with( - measurer, - tip_x, - tip_y, - tail_x, - tail_y, - thick, - bend, - label_text, - label_size, - font_descriptor, - ) - }) -} - #[allow(clippy::too_many_arguments)] pub(crate) fn arrow_label_layout_with( measurer: &TextMeasurer, diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs index b1c545b8e..9d4b0921c 100644 --- a/src/draw/shape/mod.rs +++ b/src/draw/shape/mod.rs @@ -22,21 +22,17 @@ pub use types::{ StepMarkerLabel, }; -pub(crate) use arrow_label::{ - ARROW_LABEL_BACKGROUND, arrow_label_ends, arrow_label_layout, arrow_label_layout_with, -}; +pub(crate) use arrow_label::{ARROW_LABEL_BACKGROUND, arrow_label_ends, arrow_label_layout_with}; pub(crate) use bounds::{bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points}; pub(crate) use polygon::{PolygonTemplate, generated_points, has_minimum_distinct_points}; -pub(crate) use step_marker::{ - step_marker_outline_thickness, step_marker_radius, step_marker_radius_with, -}; +pub(crate) use step_marker::{step_marker_outline_thickness, step_marker_radius_with}; pub(crate) use text::{ bounding_box_for_sticky_note_preview_with, bounding_box_for_text_with, sticky_note_layout, - sticky_note_layout_text, sticky_note_text_layout, + sticky_note_layout_text, sticky_note_text_layout_with_measurer, }; pub(crate) use text_cache::{ CaretGeometry, LogicalBounds, TextMeasurement, VisualCaretDirection, VisualLineDirection, - VisualLineEdge, caret_geometry_text, configured_layout, measure_text_with_context, + VisualLineEdge, caret_geometry_text, configured_layout, }; #[cfg(test)] diff --git a/src/draw/shape/step_marker.rs b/src/draw/shape/step_marker.rs index e4699e30d..cc4a5d8d8 100644 --- a/src/draw/shape/step_marker.rs +++ b/src/draw/shape/step_marker.rs @@ -2,16 +2,12 @@ use crate::draw::FontDescriptor; use crate::util::Rect; use super::bounds::ensure_positive_rect_f64; -use super::text_cache::{TextMeasurer, with_legacy_measurer}; +use super::text_cache::TextMeasurer; const STEP_MARKER_PADDING_RATIO: f64 = 0.45; const STEP_MARKER_PADDING_MIN: f64 = 6.0; const STEP_MARKER_MIN_RADIUS: f64 = 10.0; -pub(crate) fn step_marker_radius(value: u32, size: f64, font_descriptor: &FontDescriptor) -> f64 { - with_legacy_measurer(|measurer| step_marker_radius_with(measurer, value, size, font_descriptor)) -} - pub(crate) fn step_marker_radius_with( measurer: &TextMeasurer, value: u32, @@ -63,14 +59,22 @@ mod tests { #[test] fn step_marker_radius_grows_with_font_size() { + let measurer = crate::draw::TextMeasurer::default(); let font = FontDescriptor::default(); - assert!(step_marker_radius(1, 32.0, &font) > step_marker_radius(1, 12.0, &font)); + assert!( + step_marker_radius_with(&measurer, 1, 32.0, &font) + > step_marker_radius_with(&measurer, 1, 12.0, &font) + ); } #[test] fn step_marker_radius_grows_for_multi_digit_labels() { + let measurer = crate::draw::TextMeasurer::default(); let font = FontDescriptor::default(); - assert!(step_marker_radius(88, 18.0, &font) >= step_marker_radius(8, 18.0, &font)); + assert!( + step_marker_radius_with(&measurer, 88, 18.0, &font) + >= step_marker_radius_with(&measurer, 8, 18.0, &font) + ); } #[test] diff --git a/src/draw/shape/tests.rs b/src/draw/shape/tests.rs index 78316812f..68ea653d3 100644 --- a/src/draw/shape/tests.rs +++ b/src/draw/shape/tests.rs @@ -212,17 +212,20 @@ fn double_arrow_bounds_contain_the_second_head() { #[test] fn arrow_label_layout_offsets_from_line() { + let measurer = crate::draw::TextMeasurer::default(); let font = FontDescriptor::default(); - let layout = super::arrow_label_layout(100, 0, 0, 0, 2.0, 0.0, "1", 12.0, &font) - .expect("label layout should exist"); + let layout = + super::arrow_label_layout_with(&measurer, 100, 0, 0, 0, 2.0, 0.0, "1", 12.0, &font) + .expect("label layout should exist"); let center_x = layout.bounds.x + layout.bounds.width / 2; let center_y = layout.bounds.y + layout.bounds.height / 2; assert!(center_y > 0); assert!((center_x - 50).abs() <= 20); - let layout = super::arrow_label_layout(0, 100, 0, 0, 2.0, 0.0, "1", 12.0, &font) - .expect("label layout should exist"); + let layout = + super::arrow_label_layout_with(&measurer, 0, 100, 0, 0, 2.0, 0.0, "1", 12.0, &font) + .expect("label layout should exist"); let center_x = layout.bounds.x + layout.bounds.width / 2; let center_y = layout.bounds.y + layout.bounds.height / 2; @@ -493,14 +496,17 @@ fn pressure_and_image_bounds_handle_extreme_coordinates() { #[test] fn curved_arrow_label_follows_the_arc_not_the_chord() { + let measurer = crate::draw::TextMeasurer::default(); // Anchored to the chord, the label would sit in the gap the arrow was drawn // to route around - far from the shaft it numbers. let font = FontDescriptor::default(); // Tail at (0, 100), tip at (400, 100), bulging up by 0.5 * 400 / 2 = 100px. - let straight = super::arrow_label_layout(400, 100, 0, 100, 4.0, 0.0, "1", 12.0, &font) - .expect("straight label layout"); - let curved = super::arrow_label_layout(400, 100, 0, 100, 4.0, 0.5, "1", 12.0, &font) - .expect("curved label layout"); + let straight = + super::arrow_label_layout_with(&measurer, 400, 100, 0, 100, 4.0, 0.0, "1", 12.0, &font) + .expect("straight label layout"); + let curved = + super::arrow_label_layout_with(&measurer, 400, 100, 0, 100, 4.0, 0.5, "1", 12.0, &font) + .expect("curved label layout"); // Both sit at the middle of the span horizontally. assert_eq!(straight.bounds.x, curved.bounds.x); @@ -565,8 +571,10 @@ fn flipping_the_head_still_moves_a_single_headed_arrow_label() { #[test] fn arrow_label_layout_handles_full_span_endpoints() { + let measurer = crate::draw::TextMeasurer::default(); let font = FontDescriptor::default(); - let layout = super::arrow_label_layout( + let layout = super::arrow_label_layout_with( + &measurer, i32::MAX, i32::MAX, i32::MIN, diff --git a/src/draw/shape/text.rs b/src/draw/shape/text.rs index a73e2497d..bbf5d42c9 100644 --- a/src/draw/shape/text.rs +++ b/src/draw/shape/text.rs @@ -154,7 +154,8 @@ pub(crate) fn sticky_note_layout( } } -pub(crate) fn sticky_note_text_layout( +pub(crate) fn sticky_note_text_layout_with_measurer( + measurer: &crate::draw::TextMeasurer, ctx: &cairo::Context, text: &str, size: f64, @@ -176,9 +177,7 @@ pub(crate) fn sticky_note_text_layout( } // Use cached measurements if available, otherwise measure and cache - if let Some(measurement) = - super::text_cache::measure_text_with_context(ctx, text, &font_desc_str, size, wrap_width) - { + if let Some(measurement) = measurer.measure(text, &font_desc_str, size, wrap_width) { StickyNoteTextLayout { layout, content: measurement.content_extents(wrap_width), diff --git a/src/draw/shape/text_cache.rs b/src/draw/shape/text_cache.rs index b2e5ab4ed..c024fa8cb 100644 --- a/src/draw/shape/text_cache.rs +++ b/src/draw/shape/text_cache.rs @@ -59,7 +59,7 @@ pub(crate) fn with_legacy_measurer(f: impl FnOnce(&TextMeasurer) -> R) -> R { /// Build a Pango layout configured exactly like the measurement and render /// paths: same font description, same text, same wrap mode and width clamp. /// Every caret, hit-test, and decoration helper goes through this, so their -/// geometry cannot drift from what `measure_text_cached` and the renderer see. +/// geometry cannot drift from what `TextMeasurer::measure` and the renderer see. pub(crate) fn configured_layout( ctx: &cairo::Context, text: &str, @@ -91,32 +91,6 @@ fn snap_char_boundary(text: &str, byte: usize) -> usize { index } -/// Measure text using Pango, with caching. -/// Returns cached measurement if available, otherwise measures and caches. -pub(crate) fn measure_text_cached( - text: &str, - font_desc_str: &str, - size: f64, - wrap_width: Option, -) -> Option { - with_legacy_measurer(|measurer| measurer.measure(text, font_desc_str, size, wrap_width)) -} - -/// Measure text using cached measurements. -/// The `_ctx` parameter is kept for API compatibility but measurements always -/// use a shared context for consistency across different rendering contexts. -/// Geometry stays stable because destination settings are ignored and all -/// measurements use the same canonical context. -pub(crate) fn measure_text_with_context( - _ctx: &cairo::Context, - text: &str, - font_desc_str: &str, - size: f64, - wrap_width: Option, -) -> Option { - measure_text_cached(text, font_desc_str, size, wrap_width) -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum VisualLineDirection { Up, diff --git a/src/draw/shape/text_cache/owner/tests.rs b/src/draw/shape/text_cache/owner/tests.rs index 79623cb6f..d7bafdfec 100644 --- a/src/draw/shape/text_cache/owner/tests.rs +++ b/src/draw/shape/text_cache/owner/tests.rs @@ -102,14 +102,21 @@ fn destination_settings_do_not_change_canonical_measurements() { destination.set_font_options(&options); let owner = TextMeasurer::default(); let fresh = TextMeasurer::default(); - let actual = crate::draw::shape::text_cache::measure_text_with_context( + crate::draw::render_text_with_measurer( + &owner, &destination, + 0, + 5, "Hello 你好\nאבג", - "Sans 16", + crate::draw::RED, 16.0, + &crate::draw::FontDescriptor::default(), + false, Some(70), - ) - .unwrap(); + ); + let actual = owner + .measure("Hello 你好\nאבג", "Sans 16", 16.0, Some(70)) + .unwrap(); let expected = fresh .measure("Hello 你好\nאבג", "Sans 16", 16.0, Some(70)) .unwrap(); diff --git a/src/draw/shape/text_cache/tests.rs b/src/draw/shape/text_cache/tests.rs index 38bd01ed1..b3db9b137 100644 --- a/src/draw/shape/text_cache/tests.rs +++ b/src/draw/shape/text_cache/tests.rs @@ -80,11 +80,12 @@ fn caret_geometry_snaps_off_boundary_indices_down() { #[test] fn test_cache_returns_same_measurement() { + let measurer = crate::draw::TextMeasurer::default(); let text = "Hello World"; let font = "Sans 12"; - let m1 = measure_text_cached(text, font, 12.0, None); - let m2 = measure_text_cached(text, font, 12.0, None); + let m1 = measurer.measure(text, font, 12.0, None); + let m2 = measurer.measure(text, font, 12.0, None); assert!(m1.is_some()); assert!(m2.is_some()); @@ -99,20 +100,21 @@ fn test_cache_returns_same_measurement() { #[test] fn test_different_sizes_use_different_cache_keys() { + let measurer = crate::draw::TextMeasurer::default(); // Verify that measurements for different sizes are cached with different keys // by checking that both requests succeed (cache doesn't confuse them) let text = "Test"; let font = "Sans"; - let m1 = measure_text_cached(text, font, 12.0, None); - let m2 = measure_text_cached(text, font, 24.0, None); + let m1 = measurer.measure(text, font, 12.0, None); + let m2 = measurer.measure(text, font, 24.0, None); assert!(m1.is_some(), "12pt measurement should succeed"); assert!(m2.is_some(), "24pt measurement should succeed"); // Request them again - should hit cache for both - let m1_cached = measure_text_cached(text, font, 12.0, None); - let m2_cached = measure_text_cached(text, font, 24.0, None); + let m1_cached = measurer.measure(text, font, 12.0, None); + let m2_cached = measurer.measure(text, font, 24.0, None); let m1 = m1.unwrap(); let m1_cached = m1_cached.unwrap(); @@ -175,17 +177,19 @@ fn test_insert_existing_key_updates_cached_measurement() { #[test] fn test_empty_text_returns_none() { - let result = measure_text_cached("", "Sans 12", 12.0, None); + let measurer = crate::draw::TextMeasurer::default(); + let result = measurer.measure("", "Sans 12", 12.0, None); assert!(result.is_none()); } #[test] fn test_wrap_width_affects_cache_key() { + let measurer = crate::draw::TextMeasurer::default(); let text = "A very long text that would wrap"; let font = "Sans 12"; - let m1 = measure_text_cached(text, font, 12.0, None); - let m2 = measure_text_cached(text, font, 12.0, Some(50)); + let m1 = measurer.measure(text, font, 12.0, None); + let m2 = measurer.measure(text, font, 12.0, Some(50)); assert!(m1.is_some()); assert!(m2.is_some()); diff --git a/src/input/hit_test/tests.rs b/src/input/hit_test/tests.rs index bfda02689..1a767a4ed 100644 --- a/src/input/hit_test/tests.rs +++ b/src/input/hit_test/tests.rs @@ -330,6 +330,8 @@ fn double_arrow_is_grabbed_by_either_head() { #[test] fn arrow_label_hit_detects_label_bounds() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let font = FontDescriptor::default(); let label = ArrowLabel { value: 12, @@ -357,9 +359,19 @@ fn arrow_label_hit_detects_label_bounds() { ); let label_text = "12"; - let layout = - crate::draw::shape::arrow_label_layout(100, 0, 0, 0, 2.0, 0.0, label_text, 12.0, &font) - .expect("label layout should exist"); + let layout = crate::draw::shape::arrow_label_layout_with( + &test_text_measurer, + 100, + 0, + 0, + 0, + 2.0, + 0.0, + label_text, + 12.0, + &font, + ) + .expect("label layout should exist"); let hit_point = ( layout.bounds.x + layout.bounds.width / 2, layout.bounds.y + layout.bounds.height / 2, @@ -377,6 +389,8 @@ fn arrow_label_hit_detects_label_bounds() { #[test] fn step_marker_hit_detects_center_and_rejects_outside_point() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let font = FontDescriptor::default(); let label = StepMarkerLabel { value: 7, @@ -403,8 +417,12 @@ fn step_marker_hit_detects_center_and_rejects_outside_point() { let Shape::StepMarker { label, .. } = &drawn.shape else { panic!("expected step marker shape"); }; - let radius = - crate::draw::shape::step_marker_radius(label.value, label.size, &label.font_descriptor); + let radius = crate::draw::shape::step_marker_radius_with( + &test_text_measurer, + label.value, + label.size, + &label.font_descriptor, + ); let outline = crate::draw::shape::step_marker_outline_thickness(label.size); let outside_x = 50 + (radius + outline / 2.0).ceil() as i32 + 2; assert!( @@ -537,14 +555,26 @@ fn labelled_arrow_shape(style: ArrowStyle, head_at_end: bool) -> DrawnShape { #[test] fn a_double_arrow_label_is_grabbable_from_the_same_place_either_way() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + // head_at_end has no effect on a Double arrow — the outline is the same // polygon either way, and docs/CONFIG.md says so. The hit area has to // follow, or the number is grabbable where it is not painted on exactly // one of the two readings. let font = FontDescriptor::default(); - let layout = - crate::draw::shape::arrow_label_layout(400, 100, 0, 100, 4.0, 0.0, "7", 12.0, &font) - .expect("label layout should exist"); + let layout = crate::draw::shape::arrow_label_layout_with( + &test_text_measurer, + 400, + 100, + 0, + 100, + 4.0, + 0.0, + "7", + 12.0, + &font, + ) + .expect("label layout should exist"); let center = ( layout.bounds.x + layout.bounds.width / 2, layout.bounds.y + layout.bounds.height / 2, From d7d8bfc7430daba8696f5c15767b52e510c87609 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:34:36 +0200 Subject: [PATCH 32/42] refactor(capture): require complete cut preview inputs --- .../state/region_capture/cut_preview.rs | 1395 +---------------- .../state/region_capture/cut_preview/apply.rs | 131 ++ .../state/region_capture/cut_preview/job.rs | 55 + .../region_capture/cut_preview/scheduler.rs | 176 +++ .../region_capture/cut_preview/snapshot.rs | 199 +++ .../state/region_capture/cut_preview/tests.rs | 987 ++++++++++++ 6 files changed, 1555 insertions(+), 1388 deletions(-) create mode 100644 src/backend/wayland/state/region_capture/cut_preview/apply.rs create mode 100644 src/backend/wayland/state/region_capture/cut_preview/job.rs create mode 100644 src/backend/wayland/state/region_capture/cut_preview/scheduler.rs create mode 100644 src/backend/wayland/state/region_capture/cut_preview/snapshot.rs create mode 100644 src/backend/wayland/state/region_capture/cut_preview/tests.rs diff --git a/src/backend/wayland/state/region_capture/cut_preview.rs b/src/backend/wayland/state/region_capture/cut_preview.rs index 4d0d624da..d6d0ce832 100644 --- a/src/backend/wayland/state/region_capture/cut_preview.rs +++ b/src/backend/wayland/state/region_capture/cut_preview.rs @@ -1,1392 +1,11 @@ -use std::sync::Arc; +//! Region cut-preview jobs, correlated results, and live scheduling. -use crate::backend::wayland::runtime_operation::{ - RuntimeOperationPoll, RuntimeOperationSubmitError, -}; -use crate::backend::wayland::state::WaylandState; -use crate::canvas_export::{CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource}; -use crate::capture::{CaptureError, CutBand}; -use crate::input::state::{Toast, ToastPriority}; -use crate::screen_pixels::{ImagePixelRect, PackedArgb32}; +mod apply; +mod job; +mod scheduler; +mod snapshot; -use super::super::screen_image::{ - displayed_screen_image, screen_source_is, shared_displayed_screen_image, -}; -use super::ActiveScreenRegion; -use super::cut_review::{ - CutPreviewKey, PreviewApply, RegionAnnotatedRenderContext, RegionCutBase, RegionCutPreview, - RegionRenderFingerprint, RegionReviewCorrelation, RegionReviewEdits, native_extent_display, -}; -use super::render::{RegionPixelSource, compose_shared_region_pixels, render_region_base_pixels}; - -const TOAST_SOURCE: &str = "capture"; - -pub(super) struct CutPreviewJob { - pub key: CutPreviewKey, - pub source: Option, - pub base: Option>, -} - -pub(in crate::backend::wayland) enum CutPreviewOutcome { - Success { - key: CutPreviewKey, - base: Arc, - composed: Arc, - }, - Failed { - key: CutPreviewKey, - message: String, - }, -} - -pub(super) struct RegionRenderSnapshot { - pub source: RegionPixelSource, - pub fingerprint: RegionRenderFingerprint, -} - -pub(super) fn run_cut_preview(job: CutPreviewJob) -> CutPreviewOutcome { - let key = job.key; - let base = match job.base { - Some(base) => base, - None => { - let Some(source) = job.source else { - return CutPreviewOutcome::Failed { - key, - message: "Cut preview is missing its source image.".to_string(), - }; - }; - match render_region_base_pixels(source) { - Ok(pixels) => Arc::new(pixels), - Err(error) => { - return CutPreviewOutcome::Failed { - key, - message: error.to_string(), - }; - } - } - } - }; - match compose_shared_region_pixels(&base, &key.cuts) { - Ok(composed) => CutPreviewOutcome::Success { - key, - base, - composed, - }, - Err(error) => CutPreviewOutcome::Failed { - key, - message: error.to_string(), - }, - } -} - -fn preview_matches_review(edits: &RegionReviewEdits, key: &CutPreviewKey) -> bool { - key.fingerprint.correlation() == &edits.correlation - && key.fingerprint.source_rect() == edits.source_rect -} - -pub(super) fn apply_cut_preview_outcome( - edits: &mut Option, - outcome: CutPreviewOutcome, - display_for: impl FnOnce( - &RegionReviewEdits, - &[CutBand], - ) -> Option, -) -> PreviewApply { - let Some(edits) = edits.as_mut() else { - return PreviewApply::Ignored; - }; - match outcome { - CutPreviewOutcome::Failed { key, .. } => { - if !preview_matches_review(edits, &key) { - return PreviewApply::Ignored; - } - if edits.mark_preview_failed(&key) { - PreviewApply::Changed - } else { - PreviewApply::Ignored - } - } - CutPreviewOutcome::Success { - key, - base, - composed, - } => { - if !preview_matches_review(edits, &key) { - return PreviewApply::Ignored; - } - let mut changed = false; - if edits - .desired_preview - .as_ref() - .is_some_and(|desired| desired.fingerprint == key.fingerprint) - { - edits.base_cache = Some(RegionCutBase { - fingerprint: key.fingerprint.clone(), - pixels: Arc::clone(&base), - }); - changed = true; - } - if edits.desired_preview.as_ref() == Some(&key) { - let Some(display) = display_for(edits, &key.cuts) else { - return if changed { - PreviewApply::Changed - } else { - PreviewApply::Ignored - }; - }; - if (composed.width(), composed.height()) - != output_size_from_key(&key).unwrap_or((0, 0)) - { - return if changed { - PreviewApply::Changed - } else { - PreviewApply::Ignored - }; - } - edits.ready_preview = Some(RegionCutPreview { - key, - pixels: composed, - display, - }); - edits.failed_revision = None; - changed = true; - } - if changed { - PreviewApply::Changed - } else { - PreviewApply::Ignored - } - } - } -} - -fn output_size_from_key(key: &CutPreviewKey) -> Option<(u32, u32)> { - crate::capture::output_size( - ( - key.fingerprint.source_rect().width(), - key.fingerprint.source_rect().height(), - ), - &key.cuts, - ) - .ok() -} - -fn desired_preview_to_schedule( - edits: Option<&RegionReviewEdits>, - worker_active: bool, -) -> Option { - let edits = edits?; - let desired = edits.desired_preview.clone()?; - if edits.current_preview_failed() - || worker_active - || edits - .ready_preview - .as_ref() - .is_some_and(|ready| ready.key == desired) - { - return None; - } - Some(desired) -} - -fn capture_ready_correlation( - region: Option, -) -> Result { - match region { - Some(ActiveScreenRegion::Ready { - purpose, - generation, - source, - .. - }) if purpose.is_capture() => Ok(RegionReviewCorrelation { generation, source }), - _ => Err(CaptureError::ImageError( - "Region capture is not active.".to_string(), - )), - } -} - -#[derive(Debug, PartialEq, Eq)] -enum CutPreviewSnapshotClass { - Ready, - Cancelled, - Failed { message: String }, -} - -fn classify_cut_preview_snapshot( - desired: &RegionRenderFingerprint, - live: Result<&RegionRenderFingerprint, &CaptureError>, -) -> CutPreviewSnapshotClass { - match live { - Ok(fingerprint) if fingerprint == desired => CutPreviewSnapshotClass::Ready, - Ok(fingerprint) if fingerprint.correlation().source != desired.correlation().source => { - CutPreviewSnapshotClass::Cancelled - } - Ok(_) => CutPreviewSnapshotClass::Failed { - message: "The capture source no longer matches the cut preview.".to_string(), - }, - Err(CaptureError::Cancelled(_)) => CutPreviewSnapshotClass::Cancelled, - Err(error) => CutPreviewSnapshotClass::Failed { - message: error.to_string(), - }, - } -} - -fn cut_preview_from_poll( - poll: RuntimeOperationPoll, -) -> Option { - match poll { - RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => None, - RuntimeOperationPoll::Ready { outcome, .. } => Some(outcome), - RuntimeOperationPoll::ProducerFailed { - context, reason, .. - } => Some(CutPreviewOutcome::Failed { - key: context, - message: reason, - }), - RuntimeOperationPoll::Disconnected { context, .. } => Some(CutPreviewOutcome::Failed { - key: context, - message: "Cut preview worker disconnected.".to_string(), - }), - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct CutPreviewVisibleEffect { - toast_current_failure: bool, - dirty: bool, -} - -fn visible_effect_for_cut_preview( - edits: &mut Option, - outcome: CutPreviewOutcome, - display_for: impl FnOnce( - &RegionReviewEdits, - &[CutBand], - ) -> Option, -) -> CutPreviewVisibleEffect { - let desired = edits - .as_ref() - .and_then(|edits| edits.desired_preview.clone()); - let toast_current_failure = matches!( - &outcome, - CutPreviewOutcome::Failed { key, .. } if desired.as_ref() == Some(key) - ); - if let CutPreviewOutcome::Failed { message, .. } = &outcome - && !toast_current_failure - { - log::debug!("Ignoring stale cut preview failure: {message}"); - } - let applied = apply_cut_preview_outcome(edits, outcome, display_for); - CutPreviewVisibleEffect { - toast_current_failure, - dirty: applied == PreviewApply::Changed, - } -} - -fn present_cut_preview_effect( - input: &mut crate::input::InputState, - effect: CutPreviewVisibleEffect, -) { - if effect.toast_current_failure { - input.push_toast( - ToastPriority::Info, - TOAST_SOURCE, - Toast::warning("Could not update the cut preview."), - ); - } - if effect.dirty { - input.dirty_tracker.mark_full(); - input.needs_redraw = true; - } -} - -impl WaylandState { - fn annotated_render_context(&self) -> RegionAnnotatedRenderContext { - RegionAnnotatedRenderContext { - board_id: self.input_state.boards.active_board_id().to_string(), - page_index: self.input_state.boards.active_page_index(), - page_generation: self.input_state.boards.active_page_generation(), - canvas_content_generation: self.input_state.canvas_content_generation(), - board_view_offset: self.board_view_offset(), - text_halo_enabled: self.config.drawing.text_halo_enabled, - spotlight: crate::canvas_export::SpotlightPassSnapshot { - dim_opacity: self.input_state.style.spotlight_dim_opacity, - feather: self.input_state.style.spotlight_feather, - }, - } - } - - fn fingerprint_for_live_render( - &self, - correlation: RegionReviewCorrelation, - rect: ImagePixelRect, - include_drawings: bool, - ) -> RegionRenderFingerprint { - if include_drawings { - RegionRenderFingerprint::Annotated { - correlation, - source_rect: rect, - context: self.annotated_render_context(), - } - } else { - RegionRenderFingerprint::Raw { - correlation, - source_rect: rect, - } - } - } - - fn retain_current_capture_image( - &self, - token: &crate::backend::wayland::state::screen_image::ScreenSourceToken, - ) -> Result, CaptureError> { - let Some(source) = displayed_screen_image( - &self.zoom, - &self.frozen, - self.input_state.board_is_transparent(), - ) else { - return Err(CaptureError::Cancelled( - "The captured screen image is no longer available.".to_string(), - )); - }; - if !screen_source_is( - token, - &source, - &self.zoom, - &self.frozen, - (self.surface.width(), self.surface.height()), - ) { - return Err(CaptureError::Cancelled( - "The captured screen image changed.".to_string(), - )); - } - shared_displayed_screen_image(&self.zoom, &self.frozen, source.kind).ok_or_else(|| { - CaptureError::ImageError("Could not retain the captured screen image.".to_string()) - }) - } - - fn region_pixel_source( - &self, - fingerprint: &RegionRenderFingerprint, - shared_image: std::sync::Arc, - ) -> Result { - match fingerprint { - RegionRenderFingerprint::Raw { source_rect, .. } => Ok(RegionPixelSource::Raw { - image: shared_image, - selection: *source_rect, - }), - RegionRenderFingerprint::Annotated { - source_rect, - context, - correlation, - } => { - let logical_bounds = CanvasExportRect::new( - context.board_view_offset.0, - context.board_view_offset.1, - f64::from(correlation.source.surface.0), - f64::from(correlation.source.surface.1), - ) - .ok_or_else(|| { - CaptureError::ImageError("Could not map the selected drawings.".to_string()) - })?; - Ok(RegionPixelSource::Annotated(Box::new( - CanvasRegionExportSnapshot { - source: CanvasRegionSource { - image: shared_image, - logical_bounds, - }, - selection: *source_rect, - frame: self - .input_state - .boards - .active_frame() - .clone_without_history(), - text_halo_enabled: context.text_halo_enabled, - spotlight: context.spotlight, - }, - ))) - } - } - } - - pub(super) fn snapshot_region_render( - &self, - rect: ImagePixelRect, - include_drawings: bool, - ) -> Result { - let correlation = capture_ready_correlation(self.region_capture.active())?; - let shared_image = self.retain_current_capture_image(&correlation.source)?; - let fingerprint = self.fingerprint_for_live_render(correlation, rect, include_drawings); - let source = self.region_pixel_source(&fingerprint, shared_image)?; - Ok(RegionRenderSnapshot { - source, - fingerprint, - }) - } - - pub(super) fn current_region_fingerprint(&self) -> Option { - let rect = self.region_review_rect()?; - let include_drawings = self.region_picker_include_drawings(); - let correlation = capture_ready_correlation(self.region_capture.active()).ok()?; - if self - .retain_current_capture_image(&correlation.source) - .is_err() - { - return None; - } - Some(self.fingerprint_for_live_render(correlation, rect, include_drawings)) - } - - pub(super) fn schedule_region_cut_preview(&mut self) { - let Some(desired) = desired_preview_to_schedule( - self.region_capture.review_edits(), - self.region_capture.cut_preview_active(), - ) else { - return; - }; - let Some(edits) = self.region_capture.review_edits() else { - return; - }; - let cached_base = edits - .base_cache - .as_ref() - .filter(|cache| cache.fingerprint == desired.fingerprint) - .map(|cache| Arc::clone(&cache.pixels)); - let source = if cached_base.is_some() { - None - } else { - match self.snapshot_region_render( - desired.fingerprint.source_rect(), - desired.fingerprint.include_drawings(), - ) { - Ok(snapshot) => match classify_cut_preview_snapshot( - &desired.fingerprint, - Ok(&snapshot.fingerprint), - ) { - CutPreviewSnapshotClass::Ready => Some(snapshot.source), - CutPreviewSnapshotClass::Cancelled => { - self.cancel_region_capture_for_source_change(); - return; - } - CutPreviewSnapshotClass::Failed { message } => { - self.finish_cut_preview_poll(CutPreviewOutcome::Failed { - key: desired, - message, - }); - return; - } - }, - Err(error) => { - match classify_cut_preview_snapshot(&desired.fingerprint, Err(&error)) { - CutPreviewSnapshotClass::Cancelled => { - self.cancel_region_capture_for_source_change(); - } - CutPreviewSnapshotClass::Failed { message } => { - self.finish_cut_preview_poll(CutPreviewOutcome::Failed { - key: desired, - message, - }); - } - CutPreviewSnapshotClass::Ready => { - unreachable!("a snapshot error cannot match the desired fingerprint") - } - } - return; - } - } - }; - let job = CutPreviewJob { - key: desired.clone(), - source, - base: cached_base, - }; - if let Err(failure) = self.region_capture.cut_preview_mut().try_submit( - desired, - "wayscriber-region-cut-preview", - move || run_cut_preview(job), - ) { - let (error, key) = failure.into_parts(); - if !matches!(error, RuntimeOperationSubmitError::Busy { .. }) { - log::debug!("Cut preview worker unavailable: {error}"); - self.finish_cut_preview_poll(CutPreviewOutcome::Failed { - key, - message: error.to_string(), - }); - } - } - } - - pub(in crate::backend::wayland) fn poll_region_cut_preview_completion(&mut self) { - if let Some(outcome) = cut_preview_from_poll(self.region_capture.cut_preview_mut().poll()) { - self.finish_cut_preview_poll(outcome); - self.schedule_region_cut_preview(); - } - } - - fn finish_cut_preview_poll(&mut self, outcome: CutPreviewOutcome) { - let effect = visible_effect_for_cut_preview( - self.region_capture.review_edits_slot_mut(), - outcome, - |edits, cuts| { - native_extent_display( - &edits.correlation.source, - edits.source_rect, - crate::capture::output_size( - (edits.source_rect.width(), edits.source_rect.height()), - cuts, - ) - .ok()?, - ) - }, - ); - present_cut_preview_effect(&mut self.input_state, effect); - } -} +pub(in crate::backend::wayland) use job::CutPreviewOutcome; #[cfg(test)] -mod tests { - use super::*; - use crate::backend::wayland::state::screen_image::ScreenImageKind; - use crate::capture::CutAxis; - use crate::input::state::RegionSelection; - use wayland_client::protocol::wl_output::Transform; - - type ContextMutation = (&'static str, fn(&mut RegionAnnotatedRenderContext)); - - fn token() -> crate::backend::wayland::state::screen_image::ScreenSourceToken { - crate::backend::wayland::state::screen_image::ScreenSourceToken { - output_id: 1, - output_layout_generation: 1, - kind: ScreenImageKind::Frozen, - image_generation: 1, - image_size: (2, 1), - stride: 8, - surface: (2, 1), - output_scale: 1, - output_transform: Transform::Normal, - zoom_transformed: false, - zoom_scale: 1.0, - zoom_view_offset: (0.0, 0.0), - } - } - - fn key(revision: u64, generation: u64) -> CutPreviewKey { - let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); - CutPreviewKey { - fingerprint: RegionRenderFingerprint::Raw { - correlation: RegionReviewCorrelation { - generation, - source: token(), - }, - source_rect: rect, - }, - revision, - cuts: vec![CutBand::new(CutAxis::Columns, 1, 2).unwrap()], - } - } - - fn annotated_key(revision: u64, generation: u64) -> CutPreviewKey { - let mut key = key(revision, generation); - key.fingerprint = RegionRenderFingerprint::Annotated { - correlation: key.fingerprint.correlation().clone(), - source_rect: key.fingerprint.source_rect(), - context: RegionAnnotatedRenderContext { - board_id: "board-a".to_string(), - page_index: 2, - page_generation: 3, - canvas_content_generation: 4, - board_view_offset: (5.0, 6.0), - text_halo_enabled: true, - spotlight: crate::canvas_export::SpotlightPassSnapshot { - dim_opacity: 0.7, - feather: 0.2, - }, - }, - }; - key - } - - fn pixels() -> Arc { - Arc::new( - PackedArgb32::new( - 2, - 1, - 8, - [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88].to_vec(), - ) - .unwrap(), - ) - } - - fn display_selection() -> RegionSelection { - RegionSelection { - start: (0.0, 0.0), - end: (1.0, 1.0), - } - } - - fn display(_: &RegionReviewEdits, _: &[CutBand]) -> Option { - Some(display_selection()) - } - - #[test] - fn matching_completion_installs_base_and_preview() { - let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); - let mut edits = Some(RegionReviewEdits::new( - RegionReviewCorrelation { - generation: 1, - source: token(), - }, - rect, - )); - let desired = key(1, 1); - edits.as_mut().unwrap().cuts = desired.cuts.clone(); - edits.as_mut().unwrap().revision = 1; - edits.as_mut().unwrap().desired_preview = Some(desired.clone()); - let composed = Arc::new(PackedArgb32::new(1, 1, 4, vec![0x55, 0x66, 0x77, 0x88]).unwrap()); - let applied = apply_cut_preview_outcome( - &mut edits, - CutPreviewOutcome::Success { - key: desired, - base: pixels(), - composed: Arc::clone(&composed), - }, - display, - ); - assert_eq!(applied, PreviewApply::Changed); - let edits = edits.unwrap(); - assert!(edits.base_cache.is_some()); - assert_eq!( - edits - .ready_preview - .as_ref() - .map(|preview| preview.pixels.as_ref()), - Some(composed.as_ref()) - ); - assert!(edits.preview_is_current()); - } - - #[test] - fn stale_revision_may_cache_the_base_only() { - let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); - let mut edits = Some(RegionReviewEdits::new( - RegionReviewCorrelation { - generation: 1, - source: token(), - }, - rect, - )); - let stale = key(1, 1); - let desired = key(2, 1); - edits.as_mut().unwrap().cuts = desired.cuts.clone(); - edits.as_mut().unwrap().revision = 2; - edits.as_mut().unwrap().desired_preview = Some(desired); - let applied = apply_cut_preview_outcome( - &mut edits, - CutPreviewOutcome::Success { - key: stale, - base: pixels(), - composed: Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()), - }, - display, - ); - assert_eq!(applied, PreviewApply::Changed); - let edits = edits.unwrap(); - assert!(edits.base_cache.is_some()); - assert!(edits.ready_preview.is_none()); - } - - #[test] - fn different_generation_rejects_all_output() { - let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); - let mut edits = Some(RegionReviewEdits::new( - RegionReviewCorrelation { - generation: 2, - source: token(), - }, - rect, - )); - let applied = apply_cut_preview_outcome( - &mut edits, - CutPreviewOutcome::Success { - key: key(1, 1), - base: pixels(), - composed: pixels(), - }, - display, - ); - assert_eq!(applied, PreviewApply::Ignored); - assert!(edits.unwrap().ready_preview.is_none()); - } - - #[test] - fn every_nonmatching_render_fingerprint_field_rejects_completed_output() { - let desired = annotated_key(1, 1); - let mut mismatches = Vec::new(); - - let mut different_source = desired.clone(); - let RegionRenderFingerprint::Annotated { correlation, .. } = - &mut different_source.fingerprint - else { - unreachable!("annotated fixture stays annotated"); - }; - correlation.source.image_generation = 9; - mismatches.push(("source token", different_source)); - - let mut different_rect = desired.clone(); - let RegionRenderFingerprint::Annotated { source_rect, .. } = - &mut different_rect.fingerprint - else { - unreachable!("annotated fixture stays annotated"); - }; - *source_rect = ImagePixelRect::new(0, 0, 1, 1, (2, 1)).unwrap(); - mismatches.push(("source rectangle", different_rect)); - - let mut without_drawings = desired.clone(); - without_drawings.fingerprint = RegionRenderFingerprint::Raw { - correlation: desired.fingerprint.correlation().clone(), - source_rect: desired.fingerprint.source_rect(), - }; - mismatches.push(("include drawings", without_drawings)); - - let context_mutations: [ContextMutation; 8] = [ - ( - "board identity", - |context: &mut RegionAnnotatedRenderContext| { - context.board_id = "board-b".to_string() - }, - ), - ("page identity", |context| context.page_index += 1), - ("page generation", |context| context.page_generation += 1), - ("canvas content generation", |context| { - context.canvas_content_generation += 1; - }), - ("board view offset", |context| { - context.board_view_offset.0 += 1.0; - }), - ("text halo", |context| { - context.text_halo_enabled = !context.text_halo_enabled; - }), - ("Spotlight opacity", |context| { - context.spotlight.dim_opacity += 0.1; - }), - ("Spotlight feather", |context| { - context.spotlight.feather += 0.1; - }), - ]; - for (name, mutate) in context_mutations { - let mut mismatch = desired.clone(); - let RegionRenderFingerprint::Annotated { context, .. } = &mut mismatch.fingerprint - else { - unreachable!("annotated fixture stays annotated"); - }; - mutate(context); - mismatches.push((name, mismatch)); - } - - for (name, mismatch) in mismatches { - let mut edits = review_edits(desired.clone()); - let applied = apply_cut_preview_outcome( - &mut edits, - CutPreviewOutcome::Success { - key: mismatch, - base: pixels(), - composed: Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()), - }, - display, - ); - let edits = edits.unwrap(); - assert_eq!(applied, PreviewApply::Ignored, "{name}"); - assert!(edits.base_cache.is_none(), "{name} donated a stale base"); - assert!( - edits.ready_preview.is_none(), - "{name} installed a stale preview" - ); - } - } - - #[test] - fn current_failure_records_visible_state() { - let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); - let mut edits = Some(RegionReviewEdits::new( - RegionReviewCorrelation { - generation: 1, - source: token(), - }, - rect, - )); - let desired = key(3, 1); - edits.as_mut().unwrap().cuts = desired.cuts.clone(); - edits.as_mut().unwrap().revision = 3; - edits.as_mut().unwrap().desired_preview = Some(desired.clone()); - let applied = apply_cut_preview_outcome( - &mut edits, - CutPreviewOutcome::Failed { - key: desired, - message: "boom".to_string(), - }, - display, - ); - assert_eq!(applied, PreviewApply::Changed); - assert_eq!(edits.unwrap().failed_revision, Some(3)); - } - - #[test] - fn stale_failure_does_not_touch_the_new_review() { - let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); - let mut edits = Some(RegionReviewEdits::new( - RegionReviewCorrelation { - generation: 2, - source: token(), - }, - rect, - )); - let applied = apply_cut_preview_outcome( - &mut edits, - CutPreviewOutcome::Failed { - key: key(1, 1), - message: "old".to_string(), - }, - display, - ); - assert_eq!(applied, PreviewApply::Ignored); - assert!(edits.unwrap().failed_revision.is_none()); - } - - fn ready(purpose: crate::input::state::RegionPurposeTag) -> ActiveScreenRegion { - ActiveScreenRegion::Ready { - purpose, - generation: 7, - source: token(), - freeze_ownership: super::super::FreezeOwnership::PreExisting, - anchor: None, - raw_edge: None, - logical_anchor: None, - logical_edge: None, - square_modifier: false, - legend_dismissed: false, - include_drawings: false, - review_resize: None, - } - } - - #[test] - fn render_snapshot_accepts_both_capture_purposes() { - use crate::input::state::RegionPurposeTag; - assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureDeliver))).is_ok()); - assert!( - capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureInteractive))).is_ok() - ); - assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::Ocr))).is_err()); - assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::Measure))).is_err()); - assert!(capture_ready_correlation(None).is_err()); - let correlation = - capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureDeliver))).unwrap(); - assert_eq!(correlation.generation, 7); - let err = capture_ready_correlation(Some(ready(RegionPurposeTag::Ocr))).unwrap_err(); - assert!( - !err.to_string().contains("review"), - "direct delivery must not require Review: {err}" - ); - } - - #[test] - fn snapshot_classification_routes_cancel_and_current_failures() { - let desired = key(1, 1); - assert_eq!( - classify_cut_preview_snapshot(&desired.fingerprint, Ok(&desired.fingerprint)), - CutPreviewSnapshotClass::Ready - ); - let other = key(1, 2); - assert!(matches!( - classify_cut_preview_snapshot(&desired.fingerprint, Ok(&other.fingerprint)), - CutPreviewSnapshotClass::Failed { .. } - )); - assert_eq!( - classify_cut_preview_snapshot( - &desired.fingerprint, - Err(&CaptureError::Cancelled("changed".to_string())) - ), - CutPreviewSnapshotClass::Cancelled - ); - assert!(matches!( - classify_cut_preview_snapshot( - &desired.fingerprint, - Err(&CaptureError::ImageError("nope".to_string())) - ), - CutPreviewSnapshotClass::Failed { .. } - )); - let mut other_token = token(); - other_token.image_generation = 99; - let other_source = RegionRenderFingerprint::Raw { - correlation: RegionReviewCorrelation { - generation: 1, - source: other_token, - }, - source_rect: desired.fingerprint.source_rect(), - }; - assert_eq!( - classify_cut_preview_snapshot(&desired.fingerprint, Ok(&other_source)), - CutPreviewSnapshotClass::Cancelled - ); - let drifted = RegionRenderFingerprint::Raw { - correlation: desired.fingerprint.correlation().clone(), - source_rect: ImagePixelRect::new(0, 0, 1, 1, (2, 1)).unwrap(), - }; - assert!(matches!( - classify_cut_preview_snapshot(&desired.fingerprint, Ok(&drifted)), - CutPreviewSnapshotClass::Failed { .. } - )); - } - - #[test] - fn preview_job_composes_from_the_key_cuts() { - let desired = key(1, 1); - match run_cut_preview(CutPreviewJob { - key: desired.clone(), - source: None, - base: Some(pixels()), - }) { - CutPreviewOutcome::Success { key, composed, .. } => { - assert_eq!(key.cuts, desired.cuts); - assert_eq!((composed.width(), composed.height()), (1, 1)); - } - CutPreviewOutcome::Failed { message, .. } => panic!("{message}"), - } - } - - #[test] - fn empty_cuts_reuse_the_cached_base_raster() { - let mut desired = key(1, 1); - desired.cuts.clear(); - let base = pixels(); - match run_cut_preview(CutPreviewJob { - key: desired, - source: None, - base: Some(Arc::clone(&base)), - }) { - CutPreviewOutcome::Success { - base: out_base, - composed, - .. - } => { - assert!(Arc::ptr_eq(&base, &out_base)); - assert!(Arc::ptr_eq(&out_base, &composed)); - assert_eq!((composed.width(), composed.height()), (2, 1)); - } - CutPreviewOutcome::Failed { message, .. } => panic!("{message}"), - } - } - - fn review_edits(desired: CutPreviewKey) -> Option { - let rect = desired.fingerprint.source_rect(); - let mut edits = RegionReviewEdits::new(desired.fingerprint.correlation().clone(), rect); - edits.cuts = desired.cuts.clone(); - edits.revision = desired.revision; - edits.desired_preview = Some(desired); - Some(edits) - } - - fn poll_until_terminal( - controller: &mut crate::backend::wayland::runtime_operation::RuntimeOperationController< - CutPreviewKey, - CutPreviewOutcome, - >, - ) -> RuntimeOperationPoll { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); - loop { - match controller.poll() { - RuntimeOperationPoll::Pending { .. } => { - assert!( - std::time::Instant::now() < deadline, - "cut preview worker did not finish" - ); - std::thread::yield_now(); - } - poll => return poll, - } - } - } - - fn apply_poll( - edits: &mut Option, - input: &mut crate::input::InputState, - poll: RuntimeOperationPoll, - ) { - if let Some(outcome) = cut_preview_from_poll(poll) { - let effect = visible_effect_for_cut_preview(edits, outcome, display); - present_cut_preview_effect(input, effect); - } - } - - fn preview_controller() - -> crate::backend::wayland::runtime_operation::RuntimeOperationController< - CutPreviewKey, - CutPreviewOutcome, - > { - let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); - crate::backend::wayland::runtime_operation::RuntimeOperationController::new( - crate::backend::wayland::runtime_operation::RuntimeOperationIdSource::new(), - wake.handle(), - ) - } - - #[test] - fn worker_error_toasts_once_and_marks_dirty_for_the_current_revision() { - let desired = key(3, 1); - let mut edits = review_edits(desired.clone()); - let mut input = crate::input::state::test_support::make_test_input_state(); - input.needs_redraw = false; - let _ = input.dirty_tracker.take_region_report(2, 1); - - let mut controller = preview_controller(); - let job_key = desired.clone(); - controller - .try_submit(desired.clone(), "test-cut-preview-error", move || { - CutPreviewOutcome::Failed { - key: job_key, - message: "pixels".to_string(), - } - }) - .unwrap(); - apply_poll(&mut edits, &mut input, poll_until_terminal(&mut controller)); - - assert_eq!(edits.as_ref().unwrap().failed_revision, Some(3)); - assert!(edits.as_ref().unwrap().current_preview_failed()); - assert_eq!(input.test_toast_count(), 1); - assert_eq!( - input.test_active_toast_message(), - Some("Could not update the cut preview.") - ); - assert_eq!(input.test_active_toast_key(), Some(TOAST_SOURCE)); - assert!(input.needs_redraw); - assert_eq!( - input.dirty_tracker.take_region_report(2, 1).regions.len(), - 1 - ); - - present_cut_preview_effect( - &mut input, - visible_effect_for_cut_preview( - &mut edits, - CutPreviewOutcome::Failed { - key: desired, - message: "pixels again".to_string(), - }, - display, - ), - ); - assert_eq!( - input.test_toast_count(), - 1, - "same capture key must not stack a second preview-failure toast" - ); - } - - #[test] - fn worker_panic_and_disconnect_fail_the_current_preview() { - let desired = key(4, 1); - let mut edits = review_edits(desired.clone()); - let mut input = crate::input::state::test_support::make_test_input_state(); - let mut controller = preview_controller(); - controller - .try_submit(desired.clone(), "test-cut-preview-panic", || { - panic!("expected cut preview panic") - }) - .unwrap(); - apply_poll(&mut edits, &mut input, poll_until_terminal(&mut controller)); - assert_eq!(edits.as_ref().unwrap().failed_revision, Some(4)); - assert_eq!(input.test_toast_count(), 1); - - let desired = key(5, 1); - let mut edits = review_edits(desired.clone()); - let mut input = crate::input::state::test_support::make_test_input_state(); - let mut controller = preview_controller(); - controller - .try_submit_with_spawner_for_test( - desired.clone(), - || panic!("must not run"), - |job| { - drop(job); - Ok(()) - }, - ) - .unwrap(); - apply_poll(&mut edits, &mut input, controller.poll()); - assert_eq!(edits.as_ref().unwrap().failed_revision, Some(5)); - assert_eq!(input.test_toast_count(), 1); - } - - #[test] - fn submit_failure_is_visible_and_busy_is_not() { - let desired = key(6, 1); - let mut edits = review_edits(desired.clone()); - let mut input = crate::input::state::test_support::make_test_input_state(); - let mut controller = preview_controller(); - let failure = controller - .try_submit_with_spawner_for_test( - desired.clone(), - || panic!("must not run"), - |_job| Err(std::io::Error::other("injected spawn failure")), - ) - .unwrap_err(); - let (error, failed_key) = failure.into_parts(); - assert!(matches!( - error, - crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::SpawnFailed { .. } - )); - present_cut_preview_effect( - &mut input, - visible_effect_for_cut_preview( - &mut edits, - CutPreviewOutcome::Failed { - key: failed_key, - message: error.to_string(), - }, - display, - ), - ); - assert_eq!(edits.as_ref().unwrap().failed_revision, Some(6)); - assert_eq!(input.test_toast_count(), 1); - - let desired = key(7, 1); - let edits = review_edits(desired.clone()); - let input = crate::input::state::test_support::make_test_input_state(); - let mut controller = preview_controller(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let blocked_key = desired.clone(); - controller - .try_submit(desired.clone(), "test-cut-preview-busy", move || { - release_rx.recv().unwrap(); - CutPreviewOutcome::Failed { - key: blocked_key, - message: "late".to_string(), - } - }) - .unwrap(); - let busy = controller - .try_submit(desired.clone(), "test-cut-preview-busy-2", || { - panic!("must not run") - }) - .unwrap_err() - .into_parts() - .0; - assert!(matches!( - busy, - crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::Busy { .. } - )); - assert!(edits.as_ref().unwrap().failed_revision.is_none()); - assert_eq!(input.test_toast_count(), 0); - release_tx.send(()).unwrap(); - let _ = poll_until_terminal(&mut controller); - } - - #[test] - fn identity_mismatch_and_stale_failure_follow_current_or_silent_rules() { - let desired = key(8, 1); - let mut edits = review_edits(desired.clone()); - let mut input = crate::input::state::test_support::make_test_input_state(); - apply_poll( - &mut edits, - &mut input, - RuntimeOperationPoll::ProducerFailed { - id: crate::backend::wayland::runtime_operation::RuntimeOperationId::from_test(3), - context: desired.clone(), - reason: "runtime operation worker reported transport identity 4, expected 3" - .to_string(), - }, - ); - assert_eq!(edits.as_ref().unwrap().failed_revision, Some(8)); - assert_eq!(input.test_toast_count(), 1); - assert!(input.needs_redraw); - - let mut next = review_edits(key(9, 2)); - let mut input = crate::input::state::test_support::make_test_input_state(); - input.needs_redraw = false; - let _ = input.dirty_tracker.take_region_report(2, 1); - apply_poll( - &mut next, - &mut input, - RuntimeOperationPoll::ProducerFailed { - id: crate::backend::wayland::runtime_operation::RuntimeOperationId::from_test(3), - context: desired, - reason: "old picker".to_string(), - }, - ); - assert!(next.as_ref().unwrap().failed_revision.is_none()); - assert_eq!(input.test_toast_count(), 0); - assert!(!input.needs_redraw); - assert!( - input - .dirty_tracker - .take_region_report(2, 1) - .regions - .is_empty() - ); - } - - #[test] - fn busy_controller_runs_only_the_newest_desired_key_after_terminal_poll() { - let first = key(10, 1); - let second = key(11, 1); - let newest = key(12, 1); - let mut edits = review_edits(first.clone()); - let mut controller = preview_controller(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let first_job_key = first.clone(); - controller - .try_submit(first, "test-cut-preview-queued-first", move || { - release_rx.recv().unwrap(); - CutPreviewOutcome::Failed { - key: first_job_key, - message: "stale".to_string(), - } - }) - .unwrap(); - - edits.as_mut().unwrap().desired_preview = Some(second); - assert!( - desired_preview_to_schedule(edits.as_ref(), controller.is_active()).is_none(), - "busy preview work leaves the current desired key queued in Review state" - ); - edits.as_mut().unwrap().desired_preview = Some(newest.clone()); - assert!( - desired_preview_to_schedule(edits.as_ref(), controller.is_active()).is_none(), - "a later edit replaces the queued desired key instead of appending work" - ); - - release_tx.send(()).unwrap(); - assert!(cut_preview_from_poll(poll_until_terminal(&mut controller)).is_some()); - let scheduled = desired_preview_to_schedule(edits.as_ref(), controller.is_active()) - .expect("terminal consumption schedules the newest desired key"); - assert_eq!(scheduled, newest); - - let scheduled_job_key = scheduled.clone(); - controller - .try_submit(scheduled, "test-cut-preview-queued-newest", move || { - CutPreviewOutcome::Failed { - key: scheduled_job_key, - message: "newest".to_string(), - } - }) - .unwrap(); - assert!(matches!( - poll_until_terminal(&mut controller), - RuntimeOperationPoll::Ready { - context, - outcome: CutPreviewOutcome::Failed { key, .. }, - .. - } if context == newest && key == newest - )); - } - - #[test] - fn reset_while_worker_is_active_releases_buffers_and_ignores_completion() { - let active_key = key(1, 1); - let mut edits = review_edits(active_key.clone()); - let cached_base = pixels(); - let cached_base_weak = Arc::downgrade(&cached_base); - let cached_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()); - let cached_preview_weak = Arc::downgrade(&cached_preview); - edits.as_mut().unwrap().base_cache = Some(RegionCutBase { - fingerprint: active_key.fingerprint.clone(), - pixels: cached_base, - }); - edits.as_mut().unwrap().ready_preview = Some(RegionCutPreview { - key: active_key.clone(), - pixels: cached_preview, - display: display_selection(), - }); - - let worker_base = pixels(); - let worker_base_weak = Arc::downgrade(&worker_base); - let worker_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![1; 4]).unwrap()); - let worker_preview_weak = Arc::downgrade(&worker_preview); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let mut controller = preview_controller(); - let worker_key = active_key.clone(); - controller - .try_submit(active_key, "test-cut-preview-reset-active", move || { - release_rx.recv().unwrap(); - CutPreviewOutcome::Success { - key: worker_key, - base: worker_base, - composed: worker_preview, - } - }) - .unwrap(); - - assert!(edits.as_mut().unwrap().reset()); - assert!(cached_base_weak.upgrade().is_none()); - assert!(cached_preview_weak.upgrade().is_none()); - release_tx.send(()).unwrap(); - let outcome = cut_preview_from_poll(poll_until_terminal(&mut controller)).unwrap(); - assert_eq!( - apply_cut_preview_outcome(&mut edits, outcome, display), - PreviewApply::Ignored - ); - let edits = edits.unwrap(); - assert!(edits.base_cache.is_none()); - assert!(edits.ready_preview.is_none()); - assert!(edits.desired_preview.is_none()); - assert!(worker_base_weak.upgrade().is_none()); - assert!(worker_preview_weak.upgrade().is_none()); - } - - #[test] - fn review_exit_and_reopen_releases_buffers_and_rejects_old_completion() { - let old_key = key(1, 1); - let mut edits = review_edits(old_key.clone()); - let cached_base = pixels(); - let cached_base_weak = Arc::downgrade(&cached_base); - let cached_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()); - let cached_preview_weak = Arc::downgrade(&cached_preview); - edits.as_mut().unwrap().base_cache = Some(RegionCutBase { - fingerprint: old_key.fingerprint.clone(), - pixels: cached_base, - }); - edits.as_mut().unwrap().ready_preview = Some(RegionCutPreview { - key: old_key.clone(), - pixels: cached_preview, - display: display_selection(), - }); - - let worker_base = pixels(); - let worker_base_weak = Arc::downgrade(&worker_base); - let worker_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![1; 4]).unwrap()); - let worker_preview_weak = Arc::downgrade(&worker_preview); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - let mut controller = preview_controller(); - let worker_key = old_key.clone(); - controller - .try_submit(old_key, "test-cut-preview-reopen", move || { - release_rx.recv().unwrap(); - CutPreviewOutcome::Success { - key: worker_key, - base: worker_base, - composed: worker_preview, - } - }) - .unwrap(); - - edits = None; - assert!( - edits.is_none(), - "Review exit clears the transient edit state" - ); - assert!(cached_base_weak.upgrade().is_none()); - assert!(cached_preview_weak.upgrade().is_none()); - edits = review_edits(key(1, 2)); - release_tx.send(()).unwrap(); - let outcome = cut_preview_from_poll(poll_until_terminal(&mut controller)).unwrap(); - assert_eq!( - apply_cut_preview_outcome(&mut edits, outcome, display), - PreviewApply::Ignored - ); - let edits = edits.unwrap(); - assert!(edits.base_cache.is_none()); - assert!(edits.ready_preview.is_none()); - assert!(worker_base_weak.upgrade().is_none()); - assert!(worker_preview_weak.upgrade().is_none()); - } -} +mod tests; diff --git a/src/backend/wayland/state/region_capture/cut_preview/apply.rs b/src/backend/wayland/state/region_capture/cut_preview/apply.rs new file mode 100644 index 000000000..9d6e4beed --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_preview/apply.rs @@ -0,0 +1,131 @@ +use super::super::cut_review::{ + CutPreviewKey, PreviewApply, RegionCutBase, RegionCutPreview, RegionReviewEdits, +}; +use super::job::CutPreviewOutcome; +use crate::capture::CutBand; +use std::sync::Arc; + +fn preview_matches_review(edits: &RegionReviewEdits, key: &CutPreviewKey) -> bool { + key.fingerprint.correlation() == &edits.correlation + && key.fingerprint.source_rect() == edits.source_rect +} + +pub(super) fn apply_cut_preview_outcome( + edits: &mut Option, + outcome: CutPreviewOutcome, + display_for: impl FnOnce( + &RegionReviewEdits, + &[CutBand], + ) -> Option, +) -> PreviewApply { + let Some(edits) = edits.as_mut() else { + return PreviewApply::Ignored; + }; + match outcome { + CutPreviewOutcome::Failed { key, .. } => { + if !preview_matches_review(edits, &key) { + return PreviewApply::Ignored; + } + if edits.mark_preview_failed(&key) { + PreviewApply::Changed + } else { + PreviewApply::Ignored + } + } + CutPreviewOutcome::Success { + key, + base, + composed, + } => { + if !preview_matches_review(edits, &key) { + return PreviewApply::Ignored; + } + let mut changed = false; + if edits + .desired_preview + .as_ref() + .is_some_and(|desired| desired.fingerprint == key.fingerprint) + { + edits.base_cache = Some(RegionCutBase { + fingerprint: key.fingerprint.clone(), + pixels: Arc::clone(&base), + }); + changed = true; + } + if edits.desired_preview.as_ref() == Some(&key) { + let Some(display) = display_for(edits, &key.cuts) else { + return if changed { + PreviewApply::Changed + } else { + PreviewApply::Ignored + }; + }; + if (composed.width(), composed.height()) + != output_size_from_key(&key).unwrap_or((0, 0)) + { + return if changed { + PreviewApply::Changed + } else { + PreviewApply::Ignored + }; + } + edits.ready_preview = Some(RegionCutPreview { + key, + pixels: composed, + display, + }); + edits.failed_revision = None; + changed = true; + } + if changed { + PreviewApply::Changed + } else { + PreviewApply::Ignored + } + } + } +} + +fn output_size_from_key(key: &CutPreviewKey) -> Option<(u32, u32)> { + crate::capture::output_size( + ( + key.fingerprint.source_rect().width(), + key.fingerprint.source_rect().height(), + ), + &key.cuts, + ) + .ok() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct CutPreviewVisibleEffect { + pub(super) toast_current_failure: bool, + pub(super) dirty: bool, +} + +pub(super) fn visible_effect_for_cut_preview( + edits: &mut Option, + outcome: CutPreviewOutcome, + display_for: impl FnOnce( + &RegionReviewEdits, + &[CutBand], + ) -> Option, +) -> CutPreviewVisibleEffect { + let desired = edits + .as_ref() + .and_then(|edits| edits.desired_preview.clone()); + let toast_current_failure = matches!( + &outcome, + CutPreviewOutcome::Failed { key, .. } if desired.as_ref() == Some(key) + ); + if let CutPreviewOutcome::Failed { message, .. } = &outcome + && !toast_current_failure + { + log::debug!("Ignoring stale cut preview failure: {message}"); + } + let applied = apply_cut_preview_outcome(edits, outcome, display_for); + CutPreviewVisibleEffect { + toast_current_failure, + dirty: applied == PreviewApply::Changed, + } +} diff --git a/src/backend/wayland/state/region_capture/cut_preview/job.rs b/src/backend/wayland/state/region_capture/cut_preview/job.rs new file mode 100644 index 000000000..3433eabbd --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_preview/job.rs @@ -0,0 +1,55 @@ +use super::super::cut_review::CutPreviewKey; +use super::super::render::{ + RegionPixelSource, compose_shared_region_pixels, render_region_base_pixels, +}; +use crate::screen_pixels::PackedArgb32; +use std::sync::Arc; + +pub(super) enum CutPreviewInput { + CachedBase(Arc), + RenderSource(RegionPixelSource), +} + +pub(super) struct CutPreviewJob { + pub key: CutPreviewKey, + pub input: CutPreviewInput, +} + +pub(in crate::backend::wayland) enum CutPreviewOutcome { + Success { + key: CutPreviewKey, + base: Arc, + composed: Arc, + }, + Failed { + key: CutPreviewKey, + message: String, + }, +} + +pub(super) fn run_cut_preview(job: CutPreviewJob) -> CutPreviewOutcome { + let key = job.key; + let base = match job.input { + CutPreviewInput::CachedBase(base) => base, + CutPreviewInput::RenderSource(source) => match render_region_base_pixels(source) { + Ok(pixels) => Arc::new(pixels), + Err(error) => { + return CutPreviewOutcome::Failed { + key, + message: error.to_string(), + }; + } + }, + }; + match compose_shared_region_pixels(&base, &key.cuts) { + Ok(composed) => CutPreviewOutcome::Success { + key, + base, + composed, + }, + Err(error) => CutPreviewOutcome::Failed { + key, + message: error.to_string(), + }, + } +} diff --git a/src/backend/wayland/state/region_capture/cut_preview/scheduler.rs b/src/backend/wayland/state/region_capture/cut_preview/scheduler.rs new file mode 100644 index 000000000..3a253b7b8 --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_preview/scheduler.rs @@ -0,0 +1,176 @@ +use super::super::cut_review::{CutPreviewKey, RegionReviewEdits, native_extent_display}; +use super::apply::{CutPreviewVisibleEffect, visible_effect_for_cut_preview}; +use super::job::{CutPreviewInput, CutPreviewJob, CutPreviewOutcome, run_cut_preview}; +use super::snapshot::{CutPreviewSnapshotClass, classify_cut_preview_snapshot}; +use crate::backend::wayland::runtime_operation::{ + RuntimeOperationPoll, RuntimeOperationSubmitError, +}; +use crate::backend::wayland::state::WaylandState; +use crate::input::state::{Toast, ToastPriority}; +use std::sync::Arc; + +pub(super) const TOAST_SOURCE: &str = "capture"; + +pub(super) fn desired_preview_to_schedule( + edits: Option<&RegionReviewEdits>, + worker_active: bool, +) -> Option { + let edits = edits?; + let desired = edits.desired_preview.clone()?; + if edits.current_preview_failed() + || worker_active + || edits + .ready_preview + .as_ref() + .is_some_and(|ready| ready.key == desired) + { + return None; + } + Some(desired) +} + +pub(super) fn cut_preview_from_poll( + poll: RuntimeOperationPoll, +) -> Option { + match poll { + RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => None, + RuntimeOperationPoll::Ready { outcome, .. } => Some(outcome), + RuntimeOperationPoll::ProducerFailed { + context, reason, .. + } => Some(CutPreviewOutcome::Failed { + key: context, + message: reason, + }), + RuntimeOperationPoll::Disconnected { context, .. } => Some(CutPreviewOutcome::Failed { + key: context, + message: "Cut preview worker disconnected.".to_string(), + }), + } +} + +pub(super) fn present_cut_preview_effect( + input: &mut crate::input::InputState, + effect: CutPreviewVisibleEffect, +) { + if effect.toast_current_failure { + input.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("Could not update the cut preview."), + ); + } + if effect.dirty { + input.dirty_tracker.mark_full(); + input.needs_redraw = true; + } +} + +impl WaylandState { + pub(in crate::backend::wayland::state::region_capture) fn schedule_region_cut_preview( + &mut self, + ) { + let Some(desired) = desired_preview_to_schedule( + self.region_capture.review_edits(), + self.region_capture.cut_preview_active(), + ) else { + return; + }; + let Some(edits) = self.region_capture.review_edits() else { + return; + }; + let cached_base = edits + .base_cache + .as_ref() + .filter(|cache| cache.fingerprint == desired.fingerprint) + .map(|cache| Arc::clone(&cache.pixels)); + let input = if let Some(base) = cached_base { + CutPreviewInput::CachedBase(base) + } else { + match self.snapshot_region_render( + desired.fingerprint.source_rect(), + desired.fingerprint.include_drawings(), + ) { + Ok(snapshot) => match classify_cut_preview_snapshot( + &desired.fingerprint, + Ok(&snapshot.fingerprint), + ) { + CutPreviewSnapshotClass::Ready => { + CutPreviewInput::RenderSource(snapshot.source) + } + CutPreviewSnapshotClass::Cancelled => { + self.cancel_region_capture_for_source_change(); + return; + } + CutPreviewSnapshotClass::Failed { message } => { + self.finish_cut_preview_poll(CutPreviewOutcome::Failed { + key: desired, + message, + }); + return; + } + }, + Err(error) => { + match classify_cut_preview_snapshot(&desired.fingerprint, Err(&error)) { + CutPreviewSnapshotClass::Cancelled => { + self.cancel_region_capture_for_source_change(); + } + CutPreviewSnapshotClass::Failed { message } => { + self.finish_cut_preview_poll(CutPreviewOutcome::Failed { + key: desired, + message, + }); + } + CutPreviewSnapshotClass::Ready => { + unreachable!("a snapshot error cannot match the desired fingerprint") + } + } + return; + } + } + }; + let job = CutPreviewJob { + key: desired.clone(), + input, + }; + if let Err(failure) = self.region_capture.cut_preview_mut().try_submit( + desired, + "wayscriber-region-cut-preview", + move || run_cut_preview(job), + ) { + let (error, key) = failure.into_parts(); + if !matches!(error, RuntimeOperationSubmitError::Busy { .. }) { + log::debug!("Cut preview worker unavailable: {error}"); + self.finish_cut_preview_poll(CutPreviewOutcome::Failed { + key, + message: error.to_string(), + }); + } + } + } + + pub(in crate::backend::wayland) fn poll_region_cut_preview_completion(&mut self) { + if let Some(outcome) = cut_preview_from_poll(self.region_capture.cut_preview_mut().poll()) { + self.finish_cut_preview_poll(outcome); + self.schedule_region_cut_preview(); + } + } + + fn finish_cut_preview_poll(&mut self, outcome: CutPreviewOutcome) { + let effect = visible_effect_for_cut_preview( + self.region_capture.review_edits_slot_mut(), + outcome, + |edits, cuts| { + native_extent_display( + &edits.correlation.source, + edits.source_rect, + crate::capture::output_size( + (edits.source_rect.width(), edits.source_rect.height()), + cuts, + ) + .ok()?, + ) + }, + ); + present_cut_preview_effect(&mut self.input_state, effect); + } +} diff --git a/src/backend/wayland/state/region_capture/cut_preview/snapshot.rs b/src/backend/wayland/state/region_capture/cut_preview/snapshot.rs new file mode 100644 index 000000000..85bf92c1a --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_preview/snapshot.rs @@ -0,0 +1,199 @@ +use super::super::ActiveScreenRegion; +use super::super::cut_review::{ + RegionAnnotatedRenderContext, RegionRenderFingerprint, RegionReviewCorrelation, +}; +use super::super::render::RegionPixelSource; +use crate::backend::wayland::state::WaylandState; +use crate::backend::wayland::state::screen_image::{ + displayed_screen_image, screen_source_is, shared_displayed_screen_image, +}; +use crate::canvas_export::{CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource}; +use crate::capture::CaptureError; +use crate::screen_pixels::ImagePixelRect; + +pub(in crate::backend::wayland::state::region_capture) struct RegionRenderSnapshot { + pub source: RegionPixelSource, + pub fingerprint: RegionRenderFingerprint, +} + +pub(super) fn capture_ready_correlation( + region: Option, +) -> Result { + match region { + Some(ActiveScreenRegion::Ready { + purpose, + generation, + source, + .. + }) if purpose.is_capture() => Ok(RegionReviewCorrelation { generation, source }), + _ => Err(CaptureError::ImageError( + "Region capture is not active.".to_string(), + )), + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum CutPreviewSnapshotClass { + Ready, + Cancelled, + Failed { message: String }, +} + +pub(super) fn classify_cut_preview_snapshot( + desired: &RegionRenderFingerprint, + live: Result<&RegionRenderFingerprint, &CaptureError>, +) -> CutPreviewSnapshotClass { + match live { + Ok(fingerprint) if fingerprint == desired => CutPreviewSnapshotClass::Ready, + Ok(fingerprint) if fingerprint.correlation().source != desired.correlation().source => { + CutPreviewSnapshotClass::Cancelled + } + Ok(_) => CutPreviewSnapshotClass::Failed { + message: "The capture source no longer matches the cut preview.".to_string(), + }, + Err(CaptureError::Cancelled(_)) => CutPreviewSnapshotClass::Cancelled, + Err(error) => CutPreviewSnapshotClass::Failed { + message: error.to_string(), + }, + } +} + +impl WaylandState { + fn annotated_render_context(&self) -> RegionAnnotatedRenderContext { + RegionAnnotatedRenderContext { + board_id: self.input_state.boards.active_board_id().to_string(), + page_index: self.input_state.boards.active_page_index(), + page_generation: self.input_state.boards.active_page_generation(), + canvas_content_generation: self.input_state.canvas_content_generation(), + board_view_offset: self.board_view_offset(), + text_halo_enabled: self.config.drawing.text_halo_enabled, + spotlight: crate::canvas_export::SpotlightPassSnapshot { + dim_opacity: self.input_state.style.spotlight_dim_opacity, + feather: self.input_state.style.spotlight_feather, + }, + } + } + + fn fingerprint_for_live_render( + &self, + correlation: RegionReviewCorrelation, + rect: ImagePixelRect, + include_drawings: bool, + ) -> RegionRenderFingerprint { + if include_drawings { + RegionRenderFingerprint::Annotated { + correlation, + source_rect: rect, + context: self.annotated_render_context(), + } + } else { + RegionRenderFingerprint::Raw { + correlation, + source_rect: rect, + } + } + } + + fn retain_current_capture_image( + &self, + token: &crate::backend::wayland::state::screen_image::ScreenSourceToken, + ) -> Result, CaptureError> { + let Some(source) = displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) else { + return Err(CaptureError::Cancelled( + "The captured screen image is no longer available.".to_string(), + )); + }; + if !screen_source_is( + token, + &source, + &self.zoom, + &self.frozen, + (self.surface.width(), self.surface.height()), + ) { + return Err(CaptureError::Cancelled( + "The captured screen image changed.".to_string(), + )); + } + shared_displayed_screen_image(&self.zoom, &self.frozen, source.kind).ok_or_else(|| { + CaptureError::ImageError("Could not retain the captured screen image.".to_string()) + }) + } + + fn region_pixel_source( + &self, + fingerprint: &RegionRenderFingerprint, + shared_image: std::sync::Arc, + ) -> Result { + match fingerprint { + RegionRenderFingerprint::Raw { source_rect, .. } => Ok(RegionPixelSource::Raw { + image: shared_image, + selection: *source_rect, + }), + RegionRenderFingerprint::Annotated { + source_rect, + context, + correlation, + } => { + let logical_bounds = CanvasExportRect::new( + context.board_view_offset.0, + context.board_view_offset.1, + f64::from(correlation.source.surface.0), + f64::from(correlation.source.surface.1), + ) + .ok_or_else(|| { + CaptureError::ImageError("Could not map the selected drawings.".to_string()) + })?; + Ok(RegionPixelSource::Annotated(Box::new( + CanvasRegionExportSnapshot { + source: CanvasRegionSource { + image: shared_image, + logical_bounds, + }, + selection: *source_rect, + frame: self + .input_state + .boards + .active_frame() + .clone_without_history(), + text_halo_enabled: context.text_halo_enabled, + spotlight: context.spotlight, + }, + ))) + } + } + } + + pub(in crate::backend::wayland::state::region_capture) fn snapshot_region_render( + &self, + rect: ImagePixelRect, + include_drawings: bool, + ) -> Result { + let correlation = capture_ready_correlation(self.region_capture.active())?; + let shared_image = self.retain_current_capture_image(&correlation.source)?; + let fingerprint = self.fingerprint_for_live_render(correlation, rect, include_drawings); + let source = self.region_pixel_source(&fingerprint, shared_image)?; + Ok(RegionRenderSnapshot { + source, + fingerprint, + }) + } + + pub(in crate::backend::wayland::state::region_capture) fn current_region_fingerprint( + &self, + ) -> Option { + let rect = self.region_review_rect()?; + let include_drawings = self.region_picker_include_drawings(); + let correlation = capture_ready_correlation(self.region_capture.active()).ok()?; + if self + .retain_current_capture_image(&correlation.source) + .is_err() + { + return None; + } + Some(self.fingerprint_for_live_render(correlation, rect, include_drawings)) + } +} diff --git a/src/backend/wayland/state/region_capture/cut_preview/tests.rs b/src/backend/wayland/state/region_capture/cut_preview/tests.rs new file mode 100644 index 000000000..43cca13eb --- /dev/null +++ b/src/backend/wayland/state/region_capture/cut_preview/tests.rs @@ -0,0 +1,987 @@ +use super::super::ActiveScreenRegion; +use super::super::cut_review::{ + CutPreviewKey, PreviewApply, RegionAnnotatedRenderContext, RegionCutBase, RegionCutPreview, + RegionRenderFingerprint, RegionReviewCorrelation, RegionReviewEdits, +}; +use super::apply::{apply_cut_preview_outcome, visible_effect_for_cut_preview}; +use super::job::{CutPreviewInput, CutPreviewJob, CutPreviewOutcome, run_cut_preview}; +use super::scheduler::{ + TOAST_SOURCE, cut_preview_from_poll, desired_preview_to_schedule, present_cut_preview_effect, +}; +use super::snapshot::{ + CutPreviewSnapshotClass, capture_ready_correlation, classify_cut_preview_snapshot, +}; +use crate::backend::wayland::runtime_operation::RuntimeOperationPoll; +use crate::backend::wayland::state::screen_image::ScreenImageKind; +use crate::capture::CutAxis; +use crate::capture::{CaptureError, CutBand}; +use crate::input::state::RegionSelection; +use crate::screen_pixels::{ImagePixelRect, PackedArgb32}; +use std::sync::Arc; +use wayland_client::protocol::wl_output::Transform; + +type ContextMutation = (&'static str, fn(&mut RegionAnnotatedRenderContext)); + +fn token() -> crate::backend::wayland::state::screen_image::ScreenSourceToken { + crate::backend::wayland::state::screen_image::ScreenSourceToken { + output_id: 1, + output_layout_generation: 1, + kind: ScreenImageKind::Frozen, + image_generation: 1, + image_size: (2, 1), + stride: 8, + surface: (2, 1), + output_scale: 1, + output_transform: Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + } +} + +fn key(revision: u64, generation: u64) -> CutPreviewKey { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + CutPreviewKey { + fingerprint: RegionRenderFingerprint::Raw { + correlation: RegionReviewCorrelation { + generation, + source: token(), + }, + source_rect: rect, + }, + revision, + cuts: vec![CutBand::new(CutAxis::Columns, 1, 2).unwrap()], + } +} + +fn annotated_key(revision: u64, generation: u64) -> CutPreviewKey { + let mut key = key(revision, generation); + key.fingerprint = RegionRenderFingerprint::Annotated { + correlation: key.fingerprint.correlation().clone(), + source_rect: key.fingerprint.source_rect(), + context: RegionAnnotatedRenderContext { + board_id: "board-a".to_string(), + page_index: 2, + page_generation: 3, + canvas_content_generation: 4, + board_view_offset: (5.0, 6.0), + text_halo_enabled: true, + spotlight: crate::canvas_export::SpotlightPassSnapshot { + dim_opacity: 0.7, + feather: 0.2, + }, + }, + }; + key +} + +fn pixels() -> Arc { + Arc::new( + PackedArgb32::new( + 2, + 1, + 8, + [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88].to_vec(), + ) + .unwrap(), + ) +} + +fn display_selection() -> RegionSelection { + RegionSelection { + start: (0.0, 0.0), + end: (1.0, 1.0), + } +} + +fn display(_: &RegionReviewEdits, _: &[CutBand]) -> Option { + Some(display_selection()) +} + +#[test] +fn matching_completion_installs_base_and_preview() { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + let mut edits = Some(RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 1, + source: token(), + }, + rect, + )); + let desired = key(1, 1); + edits.as_mut().unwrap().cuts = desired.cuts.clone(); + edits.as_mut().unwrap().revision = 1; + edits.as_mut().unwrap().desired_preview = Some(desired.clone()); + let composed = Arc::new(PackedArgb32::new(1, 1, 4, vec![0x55, 0x66, 0x77, 0x88]).unwrap()); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Success { + key: desired, + base: pixels(), + composed: Arc::clone(&composed), + }, + display, + ); + assert_eq!(applied, PreviewApply::Changed); + let edits = edits.unwrap(); + assert!(edits.base_cache.is_some()); + assert_eq!( + edits + .ready_preview + .as_ref() + .map(|preview| preview.pixels.as_ref()), + Some(composed.as_ref()) + ); + assert!(edits.preview_is_current()); +} + +#[test] +fn stale_revision_may_cache_the_base_only() { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + let mut edits = Some(RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 1, + source: token(), + }, + rect, + )); + let stale = key(1, 1); + let desired = key(2, 1); + edits.as_mut().unwrap().cuts = desired.cuts.clone(); + edits.as_mut().unwrap().revision = 2; + edits.as_mut().unwrap().desired_preview = Some(desired); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Success { + key: stale, + base: pixels(), + composed: Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()), + }, + display, + ); + assert_eq!(applied, PreviewApply::Changed); + let edits = edits.unwrap(); + assert!(edits.base_cache.is_some()); + assert!(edits.ready_preview.is_none()); +} + +#[test] +fn different_generation_rejects_all_output() { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + let mut edits = Some(RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 2, + source: token(), + }, + rect, + )); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Success { + key: key(1, 1), + base: pixels(), + composed: pixels(), + }, + display, + ); + assert_eq!(applied, PreviewApply::Ignored); + assert!(edits.unwrap().ready_preview.is_none()); +} + +#[test] +fn every_nonmatching_render_fingerprint_field_rejects_completed_output() { + let desired = annotated_key(1, 1); + let mut mismatches = Vec::new(); + + let mut different_source = desired.clone(); + let RegionRenderFingerprint::Annotated { correlation, .. } = &mut different_source.fingerprint + else { + unreachable!("annotated fixture stays annotated"); + }; + correlation.source.image_generation = 9; + mismatches.push(("source token", different_source)); + + let mut different_rect = desired.clone(); + let RegionRenderFingerprint::Annotated { source_rect, .. } = &mut different_rect.fingerprint + else { + unreachable!("annotated fixture stays annotated"); + }; + *source_rect = ImagePixelRect::new(0, 0, 1, 1, (2, 1)).unwrap(); + mismatches.push(("source rectangle", different_rect)); + + let mut without_drawings = desired.clone(); + without_drawings.fingerprint = RegionRenderFingerprint::Raw { + correlation: desired.fingerprint.correlation().clone(), + source_rect: desired.fingerprint.source_rect(), + }; + mismatches.push(("include drawings", without_drawings)); + + let context_mutations: [ContextMutation; 8] = [ + ( + "board identity", + |context: &mut RegionAnnotatedRenderContext| context.board_id = "board-b".to_string(), + ), + ("page identity", |context| context.page_index += 1), + ("page generation", |context| context.page_generation += 1), + ("canvas content generation", |context| { + context.canvas_content_generation += 1; + }), + ("board view offset", |context| { + context.board_view_offset.0 += 1.0; + }), + ("text halo", |context| { + context.text_halo_enabled = !context.text_halo_enabled; + }), + ("Spotlight opacity", |context| { + context.spotlight.dim_opacity += 0.1; + }), + ("Spotlight feather", |context| { + context.spotlight.feather += 0.1; + }), + ]; + for (name, mutate) in context_mutations { + let mut mismatch = desired.clone(); + let RegionRenderFingerprint::Annotated { context, .. } = &mut mismatch.fingerprint else { + unreachable!("annotated fixture stays annotated"); + }; + mutate(context); + mismatches.push((name, mismatch)); + } + + for (name, mismatch) in mismatches { + let mut edits = review_edits(desired.clone()); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Success { + key: mismatch, + base: pixels(), + composed: Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()), + }, + display, + ); + let edits = edits.unwrap(); + assert_eq!(applied, PreviewApply::Ignored, "{name}"); + assert!(edits.base_cache.is_none(), "{name} donated a stale base"); + assert!( + edits.ready_preview.is_none(), + "{name} installed a stale preview" + ); + } +} + +#[test] +fn current_failure_records_visible_state() { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + let mut edits = Some(RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 1, + source: token(), + }, + rect, + )); + let desired = key(3, 1); + edits.as_mut().unwrap().cuts = desired.cuts.clone(); + edits.as_mut().unwrap().revision = 3; + edits.as_mut().unwrap().desired_preview = Some(desired.clone()); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Failed { + key: desired, + message: "boom".to_string(), + }, + display, + ); + assert_eq!(applied, PreviewApply::Changed); + assert_eq!(edits.unwrap().failed_revision, Some(3)); +} + +#[test] +fn stale_failure_does_not_touch_the_new_review() { + let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap(); + let mut edits = Some(RegionReviewEdits::new( + RegionReviewCorrelation { + generation: 2, + source: token(), + }, + rect, + )); + let applied = apply_cut_preview_outcome( + &mut edits, + CutPreviewOutcome::Failed { + key: key(1, 1), + message: "old".to_string(), + }, + display, + ); + assert_eq!(applied, PreviewApply::Ignored); + assert!(edits.unwrap().failed_revision.is_none()); +} + +fn ready(purpose: crate::input::state::RegionPurposeTag) -> ActiveScreenRegion { + ActiveScreenRegion::Ready { + purpose, + generation: 7, + source: token(), + freeze_ownership: super::super::FreezeOwnership::PreExisting, + anchor: None, + raw_edge: None, + logical_anchor: None, + logical_edge: None, + square_modifier: false, + legend_dismissed: false, + include_drawings: false, + review_resize: None, + } +} + +#[test] +fn render_snapshot_accepts_both_capture_purposes() { + use crate::input::state::RegionPurposeTag; + assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureDeliver))).is_ok()); + assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureInteractive))).is_ok()); + assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::Ocr))).is_err()); + assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::Measure))).is_err()); + assert!(capture_ready_correlation(None).is_err()); + let correlation = + capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureDeliver))).unwrap(); + assert_eq!(correlation.generation, 7); + let err = capture_ready_correlation(Some(ready(RegionPurposeTag::Ocr))).unwrap_err(); + assert!( + !err.to_string().contains("review"), + "direct delivery must not require Review: {err}" + ); +} + +#[test] +fn snapshot_classification_routes_cancel_and_current_failures() { + let desired = key(1, 1); + assert_eq!( + classify_cut_preview_snapshot(&desired.fingerprint, Ok(&desired.fingerprint)), + CutPreviewSnapshotClass::Ready + ); + let other = key(1, 2); + assert!(matches!( + classify_cut_preview_snapshot(&desired.fingerprint, Ok(&other.fingerprint)), + CutPreviewSnapshotClass::Failed { .. } + )); + assert_eq!( + classify_cut_preview_snapshot( + &desired.fingerprint, + Err(&CaptureError::Cancelled("changed".to_string())) + ), + CutPreviewSnapshotClass::Cancelled + ); + assert!(matches!( + classify_cut_preview_snapshot( + &desired.fingerprint, + Err(&CaptureError::ImageError("nope".to_string())) + ), + CutPreviewSnapshotClass::Failed { .. } + )); + let mut other_token = token(); + other_token.image_generation = 99; + let other_source = RegionRenderFingerprint::Raw { + correlation: RegionReviewCorrelation { + generation: 1, + source: other_token, + }, + source_rect: desired.fingerprint.source_rect(), + }; + assert_eq!( + classify_cut_preview_snapshot(&desired.fingerprint, Ok(&other_source)), + CutPreviewSnapshotClass::Cancelled + ); + let drifted = RegionRenderFingerprint::Raw { + correlation: desired.fingerprint.correlation().clone(), + source_rect: ImagePixelRect::new(0, 0, 1, 1, (2, 1)).unwrap(), + }; + assert!(matches!( + classify_cut_preview_snapshot(&desired.fingerprint, Ok(&drifted)), + CutPreviewSnapshotClass::Failed { .. } + )); +} + +#[test] +fn preview_job_composes_from_the_key_cuts() { + let desired = key(1, 1); + match run_cut_preview(CutPreviewJob { + key: desired.clone(), + input: CutPreviewInput::CachedBase(pixels()), + }) { + CutPreviewOutcome::Success { key, composed, .. } => { + assert_eq!(key.cuts, desired.cuts); + assert_eq!((composed.width(), composed.height()), (1, 1)); + } + CutPreviewOutcome::Failed { message, .. } => panic!("{message}"), + } +} + +#[test] +fn empty_cuts_reuse_the_cached_base_raster() { + let mut desired = key(1, 1); + desired.cuts.clear(); + let base = pixels(); + match run_cut_preview(CutPreviewJob { + key: desired, + input: CutPreviewInput::CachedBase(Arc::clone(&base)), + }) { + CutPreviewOutcome::Success { + base: out_base, + composed, + .. + } => { + assert!(Arc::ptr_eq(&base, &out_base)); + assert!(Arc::ptr_eq(&out_base, &composed)); + assert_eq!((composed.width(), composed.height()), (2, 1)); + } + CutPreviewOutcome::Failed { message, .. } => panic!("{message}"), + } +} + +fn review_edits(desired: CutPreviewKey) -> Option { + let rect = desired.fingerprint.source_rect(); + let mut edits = RegionReviewEdits::new(desired.fingerprint.correlation().clone(), rect); + edits.cuts = desired.cuts.clone(); + edits.revision = desired.revision; + edits.desired_preview = Some(desired); + Some(edits) +} + +fn poll_until_terminal( + controller: &mut crate::backend::wayland::runtime_operation::RuntimeOperationController< + CutPreviewKey, + CutPreviewOutcome, + >, +) -> RuntimeOperationPoll { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + match controller.poll() { + RuntimeOperationPoll::Pending { .. } => { + assert!( + std::time::Instant::now() < deadline, + "cut preview worker did not finish" + ); + std::thread::yield_now(); + } + poll => return poll, + } + } +} + +fn apply_poll( + edits: &mut Option, + input: &mut crate::input::InputState, + poll: RuntimeOperationPoll, +) { + if let Some(outcome) = cut_preview_from_poll(poll) { + let effect = visible_effect_for_cut_preview(edits, outcome, display); + present_cut_preview_effect(input, effect); + } +} + +fn preview_controller() -> crate::backend::wayland::runtime_operation::RuntimeOperationController< + CutPreviewKey, + CutPreviewOutcome, +> { + let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap(); + crate::backend::wayland::runtime_operation::RuntimeOperationController::new( + crate::backend::wayland::runtime_operation::RuntimeOperationIdSource::new(), + wake.handle(), + ) +} + +#[test] +fn worker_error_toasts_once_and_marks_dirty_for_the_current_revision() { + let desired = key(3, 1); + let mut edits = review_edits(desired.clone()); + let mut input = crate::input::state::test_support::make_test_input_state(); + input.needs_redraw = false; + let _ = input.dirty_tracker.take_region_report(2, 1); + + let mut controller = preview_controller(); + let job_key = desired.clone(); + controller + .try_submit(desired.clone(), "test-cut-preview-error", move || { + CutPreviewOutcome::Failed { + key: job_key, + message: "pixels".to_string(), + } + }) + .unwrap(); + apply_poll(&mut edits, &mut input, poll_until_terminal(&mut controller)); + + assert_eq!(edits.as_ref().unwrap().failed_revision, Some(3)); + assert!(edits.as_ref().unwrap().current_preview_failed()); + assert_eq!(input.test_toast_count(), 1); + assert_eq!( + input.test_active_toast_message(), + Some("Could not update the cut preview.") + ); + assert_eq!(input.test_active_toast_key(), Some(TOAST_SOURCE)); + assert!(input.needs_redraw); + assert_eq!( + input.dirty_tracker.take_region_report(2, 1).regions.len(), + 1 + ); + + present_cut_preview_effect( + &mut input, + visible_effect_for_cut_preview( + &mut edits, + CutPreviewOutcome::Failed { + key: desired, + message: "pixels again".to_string(), + }, + display, + ), + ); + assert_eq!( + input.test_toast_count(), + 1, + "same capture key must not stack a second preview-failure toast" + ); +} + +#[test] +fn worker_panic_and_disconnect_fail_the_current_preview() { + let desired = key(4, 1); + let mut edits = review_edits(desired.clone()); + let mut input = crate::input::state::test_support::make_test_input_state(); + let mut controller = preview_controller(); + controller + .try_submit(desired.clone(), "test-cut-preview-panic", || { + panic!("expected cut preview panic") + }) + .unwrap(); + apply_poll(&mut edits, &mut input, poll_until_terminal(&mut controller)); + assert_eq!(edits.as_ref().unwrap().failed_revision, Some(4)); + assert_eq!(input.test_toast_count(), 1); + + let desired = key(5, 1); + let mut edits = review_edits(desired.clone()); + let mut input = crate::input::state::test_support::make_test_input_state(); + let mut controller = preview_controller(); + controller + .try_submit_with_spawner_for_test( + desired.clone(), + || panic!("must not run"), + |job| { + drop(job); + Ok(()) + }, + ) + .unwrap(); + apply_poll(&mut edits, &mut input, controller.poll()); + assert_eq!(edits.as_ref().unwrap().failed_revision, Some(5)); + assert_eq!(input.test_toast_count(), 1); +} + +#[test] +fn submit_failure_is_visible_and_busy_is_not() { + let desired = key(6, 1); + let mut edits = review_edits(desired.clone()); + let mut input = crate::input::state::test_support::make_test_input_state(); + let mut controller = preview_controller(); + let failure = controller + .try_submit_with_spawner_for_test( + desired.clone(), + || panic!("must not run"), + |_job| Err(std::io::Error::other("injected spawn failure")), + ) + .unwrap_err(); + let (error, failed_key) = failure.into_parts(); + assert!(matches!( + error, + crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::SpawnFailed { .. } + )); + present_cut_preview_effect( + &mut input, + visible_effect_for_cut_preview( + &mut edits, + CutPreviewOutcome::Failed { + key: failed_key, + message: error.to_string(), + }, + display, + ), + ); + assert_eq!(edits.as_ref().unwrap().failed_revision, Some(6)); + assert_eq!(input.test_toast_count(), 1); + + let desired = key(7, 1); + let edits = review_edits(desired.clone()); + let input = crate::input::state::test_support::make_test_input_state(); + let mut controller = preview_controller(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let blocked_key = desired.clone(); + controller + .try_submit(desired.clone(), "test-cut-preview-busy", move || { + release_rx.recv().unwrap(); + CutPreviewOutcome::Failed { + key: blocked_key, + message: "late".to_string(), + } + }) + .unwrap(); + let busy = controller + .try_submit(desired.clone(), "test-cut-preview-busy-2", || { + panic!("must not run") + }) + .unwrap_err() + .into_parts() + .0; + assert!(matches!( + busy, + crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::Busy { .. } + )); + assert!(edits.as_ref().unwrap().failed_revision.is_none()); + assert_eq!(input.test_toast_count(), 0); + release_tx.send(()).unwrap(); + let _ = poll_until_terminal(&mut controller); +} + +#[test] +fn identity_mismatch_and_stale_failure_follow_current_or_silent_rules() { + let desired = key(8, 1); + let mut edits = review_edits(desired.clone()); + let mut input = crate::input::state::test_support::make_test_input_state(); + apply_poll( + &mut edits, + &mut input, + RuntimeOperationPoll::ProducerFailed { + id: crate::backend::wayland::runtime_operation::RuntimeOperationId::from_test(3), + context: desired.clone(), + reason: "runtime operation worker reported transport identity 4, expected 3" + .to_string(), + }, + ); + assert_eq!(edits.as_ref().unwrap().failed_revision, Some(8)); + assert_eq!(input.test_toast_count(), 1); + assert!(input.needs_redraw); + + let mut next = review_edits(key(9, 2)); + let mut input = crate::input::state::test_support::make_test_input_state(); + input.needs_redraw = false; + let _ = input.dirty_tracker.take_region_report(2, 1); + apply_poll( + &mut next, + &mut input, + RuntimeOperationPoll::ProducerFailed { + id: crate::backend::wayland::runtime_operation::RuntimeOperationId::from_test(3), + context: desired, + reason: "old picker".to_string(), + }, + ); + assert!(next.as_ref().unwrap().failed_revision.is_none()); + assert_eq!(input.test_toast_count(), 0); + assert!(!input.needs_redraw); + assert!( + input + .dirty_tracker + .take_region_report(2, 1) + .regions + .is_empty() + ); +} + +#[test] +fn busy_controller_runs_only_the_newest_desired_key_after_terminal_poll() { + let first = key(10, 1); + let second = key(11, 1); + let newest = key(12, 1); + let mut edits = review_edits(first.clone()); + let mut controller = preview_controller(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let first_job_key = first.clone(); + controller + .try_submit(first, "test-cut-preview-queued-first", move || { + release_rx.recv().unwrap(); + CutPreviewOutcome::Failed { + key: first_job_key, + message: "stale".to_string(), + } + }) + .unwrap(); + + edits.as_mut().unwrap().desired_preview = Some(second); + assert!( + desired_preview_to_schedule(edits.as_ref(), controller.is_active()).is_none(), + "busy preview work leaves the current desired key queued in Review state" + ); + edits.as_mut().unwrap().desired_preview = Some(newest.clone()); + assert!( + desired_preview_to_schedule(edits.as_ref(), controller.is_active()).is_none(), + "a later edit replaces the queued desired key instead of appending work" + ); + + release_tx.send(()).unwrap(); + assert!(cut_preview_from_poll(poll_until_terminal(&mut controller)).is_some()); + let scheduled = desired_preview_to_schedule(edits.as_ref(), controller.is_active()) + .expect("terminal consumption schedules the newest desired key"); + assert_eq!(scheduled, newest); + + let scheduled_job_key = scheduled.clone(); + controller + .try_submit(scheduled, "test-cut-preview-queued-newest", move || { + CutPreviewOutcome::Failed { + key: scheduled_job_key, + message: "newest".to_string(), + } + }) + .unwrap(); + assert!(matches!( + poll_until_terminal(&mut controller), + RuntimeOperationPoll::Ready { + context, + outcome: CutPreviewOutcome::Failed { key, .. }, + .. + } if context == newest && key == newest + )); +} + +#[test] +fn reset_while_worker_is_active_releases_buffers_and_ignores_completion() { + let active_key = key(1, 1); + let mut edits = review_edits(active_key.clone()); + let cached_base = pixels(); + let cached_base_weak = Arc::downgrade(&cached_base); + let cached_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()); + let cached_preview_weak = Arc::downgrade(&cached_preview); + edits.as_mut().unwrap().base_cache = Some(RegionCutBase { + fingerprint: active_key.fingerprint.clone(), + pixels: cached_base, + }); + edits.as_mut().unwrap().ready_preview = Some(RegionCutPreview { + key: active_key.clone(), + pixels: cached_preview, + display: display_selection(), + }); + + let worker_base = pixels(); + let worker_base_weak = Arc::downgrade(&worker_base); + let worker_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![1; 4]).unwrap()); + let worker_preview_weak = Arc::downgrade(&worker_preview); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let mut controller = preview_controller(); + let worker_key = active_key.clone(); + controller + .try_submit(active_key, "test-cut-preview-reset-active", move || { + release_rx.recv().unwrap(); + CutPreviewOutcome::Success { + key: worker_key, + base: worker_base, + composed: worker_preview, + } + }) + .unwrap(); + + assert!(edits.as_mut().unwrap().reset()); + assert!(cached_base_weak.upgrade().is_none()); + assert!(cached_preview_weak.upgrade().is_none()); + release_tx.send(()).unwrap(); + let outcome = cut_preview_from_poll(poll_until_terminal(&mut controller)).unwrap(); + assert_eq!( + apply_cut_preview_outcome(&mut edits, outcome, display), + PreviewApply::Ignored + ); + let edits = edits.unwrap(); + assert!(edits.base_cache.is_none()); + assert!(edits.ready_preview.is_none()); + assert!(edits.desired_preview.is_none()); + assert!(worker_base_weak.upgrade().is_none()); + assert!(worker_preview_weak.upgrade().is_none()); +} + +#[test] +fn review_exit_and_reopen_releases_buffers_and_rejects_old_completion() { + let old_key = key(1, 1); + let mut edits = review_edits(old_key.clone()); + let cached_base = pixels(); + let cached_base_weak = Arc::downgrade(&cached_base); + let cached_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()); + let cached_preview_weak = Arc::downgrade(&cached_preview); + edits.as_mut().unwrap().base_cache = Some(RegionCutBase { + fingerprint: old_key.fingerprint.clone(), + pixels: cached_base, + }); + edits.as_mut().unwrap().ready_preview = Some(RegionCutPreview { + key: old_key.clone(), + pixels: cached_preview, + display: display_selection(), + }); + + let worker_base = pixels(); + let worker_base_weak = Arc::downgrade(&worker_base); + let worker_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![1; 4]).unwrap()); + let worker_preview_weak = Arc::downgrade(&worker_preview); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let mut controller = preview_controller(); + let worker_key = old_key.clone(); + controller + .try_submit(old_key, "test-cut-preview-reopen", move || { + release_rx.recv().unwrap(); + CutPreviewOutcome::Success { + key: worker_key, + base: worker_base, + composed: worker_preview, + } + }) + .unwrap(); + + edits = None; + assert!( + edits.is_none(), + "Review exit clears the transient edit state" + ); + assert!(cached_base_weak.upgrade().is_none()); + assert!(cached_preview_weak.upgrade().is_none()); + edits = review_edits(key(1, 2)); + release_tx.send(()).unwrap(); + let outcome = cut_preview_from_poll(poll_until_terminal(&mut controller)).unwrap(); + assert_eq!( + apply_cut_preview_outcome(&mut edits, outcome, display), + PreviewApply::Ignored + ); + let edits = edits.unwrap(); + assert!(edits.base_cache.is_none()); + assert!(edits.ready_preview.is_none()); + assert!(worker_base_weak.upgrade().is_none()); + assert!(worker_preview_weak.upgrade().is_none()); +} + +#[test] +fn render_source_jobs_paint_annotations_before_applying_key_cuts_on_the_worker() { + use super::super::render::RegionPixelSource; + use crate::canvas_export::{CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource}; + use crate::draw::{Frame, RED, Shape}; + use crate::screen_pixels::ScreenImage; + + let image = Arc::new(ScreenImage { + data: 0xff00_00ff_u32.to_ne_bytes().repeat(64), + width: 8, + height: 8, + stride: 32, + }); + let selection = ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(); + for annotated in [false, true] { + let mut desired = key(3, 7); + let mut source_token = token(); + source_token.image_size = (8, 8); + source_token.surface = (8, 8); + source_token.stride = 32; + let correlation = RegionReviewCorrelation { + generation: 7, + source: source_token, + }; + let spotlight = crate::canvas_export::SpotlightPassSnapshot { + dim_opacity: 0.0, + feather: 0.0, + }; + desired.fingerprint = if annotated { + RegionRenderFingerprint::Annotated { + correlation, + source_rect: selection, + context: RegionAnnotatedRenderContext { + board_id: "board-a".into(), + page_index: 0, + page_generation: 1, + canvas_content_generation: 1, + board_view_offset: (0.0, 0.0), + text_halo_enabled: true, + spotlight, + }, + } + } else { + RegionRenderFingerprint::Raw { + correlation, + source_rect: selection, + } + }; + desired.cuts = vec![CutBand::new(CutAxis::Columns, 4, 8).unwrap()]; + let source = if annotated { + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 0, + y: 0, + w: 8, + h: 8, + fill: true, + color: RED, + thick: 1.0, + }); + RegionPixelSource::Annotated(Box::new(CanvasRegionExportSnapshot { + source: CanvasRegionSource { + image: Arc::clone(&image), + logical_bounds: CanvasExportRect::new(0.0, 0.0, 8.0, 8.0).unwrap(), + }, + selection, + frame, + text_halo_enabled: true, + spotlight, + })) + } else { + RegionPixelSource::Raw { + image: Arc::clone(&image), + selection, + } + }; + let job = CutPreviewJob { + key: desired.clone(), + input: CutPreviewInput::RenderSource(source), + }; + let outcome = std::thread::spawn(move || run_cut_preview(job)) + .join() + .unwrap(); + let CutPreviewOutcome::Success { + key, + base, + composed, + } = outcome + else { + panic!("valid source job failed"); + }; + assert_eq!(key, desired); + assert_eq!((base.width(), base.height()), (8, 8)); + assert_eq!((composed.width(), composed.height()), (4, 8)); + let expected = if annotated { + 0xffff_0000_u32 + } else { + 0xff00_00ff_u32 + }; + for pixels in [&base, &composed] { + let offset = 4 * pixels.stride() as usize + 2 * 4; + assert_eq!( + u32::from_ne_bytes(pixels.data()[offset..offset + 4].try_into().unwrap()), + expected + ); + } + } +} + +#[test] +fn render_source_failure_retains_the_job_correlation() { + use super::super::render::RegionPixelSource; + use crate::screen_pixels::ScreenImage; + + let desired = key(4, 9); + let job = CutPreviewJob { + key: desired.clone(), + input: CutPreviewInput::RenderSource(RegionPixelSource::Raw { + image: Arc::new(ScreenImage { + data: 0xff00_00ff_u32.to_ne_bytes().to_vec(), + width: 1, + height: 1, + stride: 4, + }), + selection: desired.fingerprint.source_rect(), + }), + }; + let CutPreviewOutcome::Failed { key, message } = run_cut_preview(job) else { + panic!("out-of-image source should fail"); + }; + assert_eq!(key, desired); + assert_eq!( + message, + "Image processing error: Could not copy the selected screen pixels." + ); +} From ba4b7ea291e87ee89dc31f9c51df6af4c3cd8ffb Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:01:42 +0200 Subject: [PATCH 33/42] refactor(capture): validate region review finalization --- src/backend/wayland/state/ocr.rs | 12 +- src/backend/wayland/state/region_capture.rs | 2 - .../region_capture/cut_review/actions.rs | 7 -- .../wayland/state/region_capture/events.rs | 108 +++++++++------- .../wayland/state/region_capture/picker.rs | 16 +-- .../state/region_capture/review_state.rs | 49 +++++++- .../wayland/state/region_capture/runtime.rs | 91 ++++++++------ .../tests/event_characterization.rs | 6 +- .../tests/lifecycle_and_measure.rs | 3 +- .../wayland/state/region_capture/tests/ocr.rs | 9 +- .../state/region_capture/tests/review.rs | 115 +++++++++++++++--- 11 files changed, 282 insertions(+), 136 deletions(-) diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs index 1afe8fe5d..0cfa6d278 100644 --- a/src/backend/wayland/state/ocr.rs +++ b/src/backend/wayland/state/ocr.rs @@ -110,23 +110,17 @@ impl WaylandState { } ScreenSourceEntry::WaitForZoom => { if self.wait_for_current_zoom_capture(ZoomWaiterOwner::Ocr) { - self.set_pending_screen_region( - RegionPurposeTag::Ocr, - generation, - ScreenCaptureSource::Zoom, - None, - ); + self.set_pending_zoom_screen_region(RegionPurposeTag::Ocr, generation); } else { self.report_ocr_zoom_image_unavailable(); } } ScreenSourceEntry::AutoFreeze => { match self.acquisition.request(ScreenAcquisitionOwner::Ocr) { - Ok(acquisition) => self.set_pending_screen_region( + Ok(acquisition) => self.set_pending_frozen_screen_region( RegionPurposeTag::Ocr, generation, - ScreenCaptureSource::Frozen, - Some(acquisition), + acquisition, ), Err(_) => self.report_terminal( ScreenAcquisitionOwner::Ocr, diff --git a/src/backend/wayland/state/region_capture.rs b/src/backend/wayland/state/region_capture.rs index 966c75ca0..73e888cb9 100644 --- a/src/backend/wayland/state/region_capture.rs +++ b/src/backend/wayland/state/region_capture.rs @@ -30,8 +30,6 @@ pub(in crate::backend::wayland) use board::{ pub(in crate::backend::wayland) use cut_preview::CutPreviewOutcome; pub(in crate::backend::wayland) use cut_review::RegionReviewPress; pub(in crate::backend::wayland) use cut_review::{CutPreviewKey, RegionReviewEdits}; -#[cfg(test)] -pub(super) use events::finalize_region_selection_event; pub(super) use events::finalize_region_selection_with_review_edits; use events::*; pub(in crate::backend::wayland) use geometry::{RegionPickerMeasurement, RegionSelectionGeometry}; diff --git a/src/backend/wayland/state/region_capture/cut_review/actions.rs b/src/backend/wayland/state/region_capture/cut_review/actions.rs index c05f79912..a7f307aae 100644 --- a/src/backend/wayland/state/region_capture/cut_review/actions.rs +++ b/src/backend/wayland/state/region_capture/cut_review/actions.rs @@ -94,13 +94,6 @@ impl WaylandState { .is_some_and(|edits| edits.mode == CutMode::Armed) } - pub(in crate::backend::wayland::state::region_capture) fn create_region_review_edits( - &mut self, - rect: ImagePixelRect, - ) { - self.region_capture.set_review_edits_for(rect); - } - pub(in crate::backend::wayland::state::region_capture) fn mark_region_cut_ui_dirty(&mut self) { self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; diff --git a/src/backend/wayland/state/region_capture/events.rs b/src/backend/wayland/state/region_capture/events.rs index 698c37fc0..fee63910c 100644 --- a/src/backend/wayland/state/region_capture/events.rs +++ b/src/backend/wayland/state/region_capture/events.rs @@ -186,65 +186,77 @@ pub(super) fn region_owner_lost_event( RegionOwnerLoss::Rearmed } -pub(in crate::backend::wayland::state) fn finalize_region_selection_event( +enum SelectionFinalization { + Complete(RegionSelectionFinalize), + Review(super::review_state::InteractiveReviewSeed), +} + +fn finalize_region_selection( backend: &mut Option, input_state: &mut crate::input::InputState, owner: RegionInputSource, logical: (f64, f64), -) -> RegionSelectionFinalize { +) -> SelectionFinalization { if !input_state.region_selection_is_owned_by(owner) { - return RegionSelectionFinalize::NotOwned; + return SelectionFinalization::Complete(RegionSelectionFinalize::NotOwned); + } + if !backend.as_ref().is_some_and(|region| { + input_state.region_state().purpose() == Some(region.purpose()) + && input_state.region_state().generation() == Some(region.generation()) + }) { + return SelectionFinalization::Complete(RegionSelectionFinalize::NotOwned); } if input_state.region_state().purpose() == Some(RegionPurposeTag::Measure) { update_region_selection_event(backend, input_state, owner, logical); - return if backend - .as_ref() - .and_then(|region| region.measure_selection()) - .is_some() - && input_state.complete_measurement(owner) - { - RegionSelectionFinalize::Measured - } else { - RegionSelectionFinalize::NotOwned - }; + return SelectionFinalization::Complete( + if backend + .as_ref() + .and_then(|region| region.measure_selection()) + .is_some() + && input_state.complete_measurement(owner) + { + RegionSelectionFinalize::Measured + } else { + RegionSelectionFinalize::NotOwned + }, + ); } if input_state.region_state().is_review() { update_region_selection_event(backend, input_state, owner, logical); let finished_backend = backend.as_mut().is_some_and(finish_review_drag); let finished_ui = input_state.finish_region_review_move(owner); debug_assert_eq!(finished_backend, finished_ui); - return if finished_backend { + return SelectionFinalization::Complete(if finished_backend { RegionSelectionFinalize::Reviewed } else { RegionSelectionFinalize::NotOwned - }; + }); } - update_region_selection_event(backend, input_state, owner, logical); - let Some(rect) = backend - .as_ref() - .copied() - .and_then(ActiveScreenRegion::selection_rect) - else { + let Some(region @ ActiveScreenRegion::Ready { .. }) = backend.as_mut() else { + return SelectionFinalization::Complete(RegionSelectionFinalize::NotOwned); + }; + if region.purpose().is_capture() && input_state.region_is_active() { + input_state.dirty_tracker.mark_full(); + input_state.needs_redraw = true; + } + if region.update_endpoint(logical) + && let Some(preview) = region.display_selection() + { + input_state.update_region_selection(owner, preview.end); + } + let Some(rect) = region.selection_rect() else { rearm_region_selection_event(backend, input_state); - return RegionSelectionFinalize::Rearmed; + return SelectionFinalization::Complete(RegionSelectionFinalize::Rearmed); }; - let purpose = backend - .as_ref() - .expect("a selected region still has backend state") - .purpose(); + let purpose = region.purpose(); if purpose == RegionPurposeTag::CaptureInteractive { - let generation = backend - .as_ref() - .expect("a reviewed region still has backend state") - .generation(); - let display = backend - .as_mut() - .and_then(|region| region.enter_review(rect)) - .expect("an interactive rectangle enters review"); - input_state.activate_region_review(purpose, generation, display); - return RegionSelectionFinalize::Reviewed; + let Some(seed) = region.enter_review_seed(rect) else { + return SelectionFinalization::Complete(RegionSelectionFinalize::NotOwned); + }; + input_state.activate_region_review(purpose, seed.generation, seed.display); + return SelectionFinalization::Review(seed); } - RegionSelectionFinalize::Selected { purpose, rect } + SelectionFinalization::Complete(RegionSelectionFinalize::Selected { purpose, rect }) } pub(in crate::backend::wayland::state) fn finalize_region_selection_with_review_edits( @@ -254,13 +266,19 @@ pub(in crate::backend::wayland::state) fn finalize_region_selection_with_review_ owner: RegionInputSource, logical: (f64, f64), ) -> RegionSelectionFinalize { - let was_review = input_state.region_state().is_review(); - let result = finalize_region_selection_event(backend, input_state, owner, logical); - if result == RegionSelectionFinalize::Reviewed - && (!was_review || review_edits.is_none()) - && let Some(rect) = backend.and_then(ActiveScreenRegion::selection_rect) - { - *review_edits = super::cut_review::review_edits_for_active_region(*backend, rect); + match finalize_region_selection(backend, input_state, owner, logical) { + SelectionFinalization::Review(seed) => { + *review_edits = Some(seed.into_edits()); + RegionSelectionFinalize::Reviewed + } + SelectionFinalization::Complete(result) => { + if result == RegionSelectionFinalize::Reviewed + && review_edits.is_none() + && let Some(rect) = backend.as_ref().and_then(|region| region.selection_rect()) + { + *review_edits = super::cut_review::review_edits_for_active_region(*backend, rect); + } + result + } } - result } diff --git a/src/backend/wayland/state/region_capture/picker.rs b/src/backend/wayland/state/region_capture/picker.rs index 160d1e00b..014f68316 100644 --- a/src/backend/wayland/state/region_capture/picker.rs +++ b/src/backend/wayland/state/region_capture/picker.rs @@ -196,12 +196,7 @@ impl WaylandState { } RegionPickerEntry::WaitForZoom => { if self.wait_for_current_zoom_capture(ZoomWaiterOwner::RegionCapture) { - self.set_pending_screen_region( - purpose, - generation, - ScreenCaptureSource::Zoom, - None, - ); + self.set_pending_zoom_screen_region(purpose, generation); } else { self.cancel_region_capture_ui_and_lifecycle(); self.report_region_zoom_unavailable(); @@ -212,12 +207,9 @@ impl WaylandState { .acquisition .request(ScreenAcquisitionOwner::RegionCapture) { - Ok(acquisition) => self.set_pending_screen_region( - purpose, - generation, - ScreenCaptureSource::Frozen, - Some(acquisition), - ), + Ok(acquisition) => { + self.set_pending_frozen_screen_region(purpose, generation, acquisition) + } Err(_) => { self.cancel_region_capture_ui_and_lifecycle(); self.input_state.push_toast( diff --git a/src/backend/wayland/state/region_capture/review_state.rs b/src/backend/wayland/state/region_capture/review_state.rs index 3413d05da..412fa5368 100644 --- a/src/backend/wayland/state/region_capture/review_state.rs +++ b/src/backend/wayland/state/region_capture/review_state.rs @@ -1,9 +1,33 @@ use super::*; +pub(super) struct InteractiveReviewSeed { + pub(super) generation: u64, + pub(super) source: ScreenSourceToken, + pub(super) rect: ImagePixelRect, + pub(super) display: RegionSelection, +} + +impl InteractiveReviewSeed { + pub(super) fn into_edits(self) -> RegionReviewEdits { + RegionReviewEdits::new( + super::cut_review::RegionReviewCorrelation { + generation: self.generation, + source: self.source, + }, + self.rect, + ) + } +} + impl ActiveScreenRegion { - pub(super) fn enter_review(&mut self, rect: ImagePixelRect) -> Option { + pub(super) fn enter_review_seed( + &mut self, + rect: ImagePixelRect, + ) -> Option { let Self::Ready { purpose, + generation, + source, anchor, raw_edge, logical_anchor, @@ -17,6 +41,26 @@ impl ActiveScreenRegion { if *purpose != RegionPurposeTag::CaptureInteractive { return None; } + ImagePixelRect::new( + rect.x(), + rect.y(), + rect.width(), + rect.height(), + source.image_size, + )?; + let display = super::super::screen_image::screen_rect_for_image_rect(source, rect); + let seed = InteractiveReviewSeed { + generation: *generation, + source: *source, + rect, + display: RegionSelection { + start: (f64::from(display.x), f64::from(display.y)), + end: ( + f64::from(display.x.saturating_add(display.width)), + f64::from(display.y.saturating_add(display.height)), + ), + }, + }; // Re-entering Review replaces the rectangle wholesale — `Ctrl+A` can // do that while a grip is still held — so the old grip must not // survive to block the next move, resize or nudge. @@ -30,8 +74,7 @@ impl ActiveScreenRegion { f64::from(rect.x() + rect.width()), f64::from(rect.y() + rect.height()), )); - self.review_geometry() - .map(|geometry| geometry.display_selection()) + Some(seed) } pub(super) fn begin_review_move(&mut self, logical: (f64, f64)) -> bool { diff --git a/src/backend/wayland/state/region_capture/runtime.rs b/src/backend/wayland/state/region_capture/runtime.rs index 567ac115e..3581d6e35 100644 --- a/src/backend/wayland/state/region_capture/runtime.rs +++ b/src/backend/wayland/state/region_capture/runtime.rs @@ -1,6 +1,5 @@ use crate::backend::wayland::{RuntimeOperationController, RuntimeOperationIdSource}; -use super::cut_review::review_edits_for_active_region; use super::*; pub(in crate::backend::wayland) struct RegionCaptureRuntime { @@ -68,23 +67,27 @@ impl RegionCaptureRuntime { generation } - pub(in crate::backend::wayland::state) fn set_pending( + pub(in crate::backend::wayland::state) fn set_pending_frozen( &mut self, purpose: RegionPurposeTag, generation: u64, - source: ScreenCaptureSource, - acquisition: Option, + acquisition: ScreenAcquisitionId, ) { - self.active = Some(match source { - ScreenCaptureSource::Frozen => ActiveScreenRegion::PendingFrozen { - purpose, - generation, - acquisition: acquisition.expect("frozen region wait has an acquisition id"), - }, - ScreenCaptureSource::Zoom => ActiveScreenRegion::PendingZoom { - purpose, - generation, - }, + self.active = Some(ActiveScreenRegion::PendingFrozen { + purpose, + generation, + acquisition, + }); + } + + pub(in crate::backend::wayland::state) fn set_pending_zoom( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + ) { + self.active = Some(ActiveScreenRegion::PendingZoom { + purpose, + generation, }); } @@ -144,10 +147,6 @@ impl RegionCaptureRuntime { (&mut self.active, &mut self.review_edits) } - pub(in crate::backend::wayland) fn set_review_edits_for(&mut self, rect: ImagePixelRect) { - self.review_edits = review_edits_for_active_region(self.active, rect); - } - pub(in crate::backend::wayland::state) fn window_snap(&self) -> Option<&WindowSnapSession> { self.window_snap.as_ref() } @@ -288,17 +287,30 @@ impl WaylandState { true } - pub(in crate::backend::wayland::state) fn set_pending_screen_region( + pub(in crate::backend::wayland::state) fn set_pending_frozen_screen_region( &mut self, purpose: RegionPurposeTag, generation: u64, - source: ScreenCaptureSource, - acquisition: Option, + acquisition: ScreenAcquisitionId, ) { self.region_capture - .set_pending(purpose, generation, source, acquisition); + .set_pending_frozen(purpose, generation, acquisition); + self.input_state.set_region_pending_capture( + purpose, + generation, + ScreenCaptureSource::Frozen, + ); + self.debug_assert_screen_region_invariant(); + } + + pub(in crate::backend::wayland::state) fn set_pending_zoom_screen_region( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + ) { + self.region_capture.set_pending_zoom(purpose, generation); self.input_state - .set_region_pending_capture(purpose, generation, source); + .set_region_pending_capture(purpose, generation, ScreenCaptureSource::Zoom); self.debug_assert_screen_region_invariant(); } @@ -462,14 +474,15 @@ impl WaylandState { let Some(region) = self.region_capture.active_mut() else { return false; }; - let purpose = region.purpose(); - let generation = region.generation(); - let Some(display) = region.enter_review(rect) else { + let Some(seed) = region.enter_review_seed(rect) else { return false; }; - self.input_state - .activate_region_review(purpose, generation, display); - self.create_region_review_edits(rect); + self.input_state.activate_region_review( + RegionPurposeTag::CaptureInteractive, + seed.generation, + seed.display, + ); + *self.region_capture.review_edits_slot_mut() = Some(seed.into_edits()); self.debug_assert_screen_region_invariant(); true } @@ -701,9 +714,14 @@ mod owner_tests { false, false, ); - runtime.set_review_edits_for( - ImagePixelRect::new(10, 10, 20, 20, source.image_size).expect("review rectangle"), - ); + let seed = runtime + .active_mut() + .unwrap() + .enter_review_seed( + ImagePixelRect::new(10, 10, 20, 20, source.image_size).expect("review rectangle"), + ) + .unwrap(); + runtime.review_edits = Some(seed.into_edits()); runtime.set_window_snap(WindowSnapSession::queued( WindowSnapCorrelation::new(generation, source), WindowQueryContext { @@ -727,9 +745,12 @@ mod owner_tests { let mut runtime = runtime(); assert!(runtime.active().is_none()); - runtime.set_review_edits_for( - ImagePixelRect::new(0, 0, 20, 20, (100, 80)).expect("review rectangle"), - ); + let seed = runtime.active_mut().and_then(|region| { + region.enter_review_seed( + ImagePixelRect::new(0, 0, 20, 20, (100, 80)).expect("review rectangle"), + ) + }); + assert!(seed.is_none()); assert!(runtime.review_edits().is_none()); } diff --git a/src/backend/wayland/state/region_capture/tests/event_characterization.rs b/src/backend/wayland/state/region_capture/tests/event_characterization.rs index 3be3058e0..ee108b4fc 100644 --- a/src/backend/wayland/state/region_capture/tests/event_characterization.rs +++ b/src/backend/wayland/state/region_capture/tests/event_characterization.rs @@ -90,9 +90,10 @@ fn each_purpose_keeps_its_event_geometry_and_terminal_ownership_contract() { (80.0, 75.0), ); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Touch, (90.0, 79.0) ), @@ -101,9 +102,10 @@ fn each_purpose_keeps_its_event_geometry_and_terminal_ownership_contract() { assert_eq!(input.region_state(), moving); assert_eq!(backend, active); - let result = finalize_region_selection_event( + let result = finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Pointer, (30.0, 40.0), ); diff --git a/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs b/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs index 9c7b00e06..06aa14010 100644 --- a/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs +++ b/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs @@ -68,9 +68,10 @@ fn measure_mode_owns_logical_geometry_without_a_screen_image() { (35.2, 60.1), ); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Pointer, (35.2, 60.1), ), diff --git a/src/backend/wayland/state/region_capture/tests/ocr.rs b/src/backend/wayland/state/region_capture/tests/ocr.rs index d1dedf6d7..aa2344e9b 100644 --- a/src/backend/wayland/state/region_capture/tests/ocr.rs +++ b/src/backend/wayland/state/region_capture/tests/ocr.rs @@ -134,9 +134,10 @@ fn production_ocr_event_adapter_uses_release_endpoint_at_every_scale() { let RegionSelectionFinalize::Selected { purpose: RegionPurposeTag::Ocr, rect, - } = finalize_region_selection_event( + } = finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Pointer, release, ) @@ -180,9 +181,10 @@ fn production_ocr_event_adapter_rearms_small_drag_and_ignores_shift_square_polic let RegionSelectionFinalize::Selected { purpose: RegionPurposeTag::Ocr, rect, - } = finalize_region_selection_event( + } = finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Pointer, (14.0, 28.0), ) @@ -201,9 +203,10 @@ fn production_ocr_event_adapter_rearms_small_drag_and_ignores_shift_square_polic (10.0, 20.0), )); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Pointer, (13.0, 23.0), ), diff --git a/src/backend/wayland/state/region_capture/tests/review.rs b/src/backend/wayland/state/region_capture/tests/review.rs index 7fa884eb2..3f5244ed0 100644 --- a/src/backend/wayland/state/region_capture/tests/review.rs +++ b/src/backend/wayland/state/region_capture/tests/review.rs @@ -28,7 +28,10 @@ fn capture_hover_motion_requests_a_repaint_while_armed_and_in_review() { let mut review_region = interactive_region(); let rect = ImagePixelRect::new(20, 20, 30, 25, (100, 80)).unwrap(); - let display = review_region.enter_review(rect).unwrap(); + let display = review_region + .enter_review_seed(rect) + .map(|seed| seed.display) + .unwrap(); let mut review_backend = Some(review_region); let mut review_input = make_test_input_state(); review_input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); @@ -59,7 +62,10 @@ fn capture_hover_motion_requests_a_repaint_while_armed_and_in_review() { fn review_nudge_and_move_clamp_without_resizing_and_owner_loss_keeps_review() { let mut region = interactive_region(); let rect = ImagePixelRect::new(70, 60, 20, 15, (100, 80)).unwrap(); - let display = region.enter_review(rect).unwrap(); + let display = region + .enter_review_seed(rect) + .map(|seed| seed.display) + .unwrap(); let mut backend = Some(region); let mut input = make_test_input_state(); input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); @@ -106,7 +112,10 @@ fn review_nudge_and_move_clamp_without_resizing_and_owner_loss_keeps_review() { fn review_move_preserves_subpixel_motion_until_it_reaches_a_pixel() { let mut region = interactive_region(); let rect = ImagePixelRect::new(20, 20, 30, 25, (100, 80)).unwrap(); - region.enter_review(rect).unwrap(); + region + .enter_review_seed(rect) + .map(|seed| seed.display) + .unwrap(); assert!(region.begin_review_move((25.0, 25.0))); for x in [25.6, 26.2, 26.8, 27.4, 28.0] { @@ -125,7 +134,10 @@ fn review_move_preserves_subpixel_motion_until_it_reaches_a_pixel() { fn second_device_press_cannot_replace_an_in_progress_review_move() { let mut region = interactive_region(); let rect = ImagePixelRect::new(20, 20, 30, 25, (100, 80)).unwrap(); - let display = region.enter_review(rect).unwrap(); + let display = region + .enter_review_seed(rect) + .map(|seed| seed.display) + .unwrap(); let mut backend = Some(region); let mut input = make_test_input_state(); input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); @@ -175,9 +187,10 @@ fn capture_finalize_is_purpose_aware_and_one_axis_empty_rearms() { (10.8, 20.6), )); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Pointer, (18.2, 35.8), ), @@ -197,9 +210,10 @@ fn capture_finalize_is_purpose_aware_and_one_axis_empty_rearms() { (10.8, 20.6), )); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Touch, (10.0, 40.0), ), @@ -579,7 +593,10 @@ fn pressing_a_grip_resizes_while_pressing_the_interior_still_moves() { let bounds = (100, 80); let mut region = interactive_region(); let rect = ImagePixelRect::new(20, 20, 40, 30, bounds).unwrap(); - let display = region.enter_review(rect).unwrap(); + let display = region + .enter_review_seed(rect) + .map(|seed| seed.display) + .unwrap(); let mut backend = Some(region); let mut input = make_test_input_state(); input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); @@ -612,9 +629,10 @@ fn pressing_a_grip_resizes_while_pressing_the_interior_still_moves() { ); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Pointer, (75.0, 65.0), ), @@ -661,7 +679,10 @@ fn a_held_grip_blocks_nudging_and_a_second_devices_press() { let bounds = (100, 80); let mut region = interactive_region(); let rect = ImagePixelRect::new(20, 20, 40, 30, bounds).unwrap(); - let display = region.enter_review(rect).unwrap(); + let display = region + .enter_review_seed(rect) + .map(|seed| seed.display) + .unwrap(); let mut backend = Some(region); let mut input = make_test_input_state(); input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); @@ -711,7 +732,10 @@ fn selecting_the_whole_image_mid_resize_leaves_review_usable() { let bounds = (100, 80); let mut region = interactive_region(); let rect = ImagePixelRect::new(20, 20, 40, 30, bounds).unwrap(); - let display = region.enter_review(rect).unwrap(); + let display = region + .enter_review_seed(rect) + .map(|seed| seed.display) + .unwrap(); let mut backend = Some(region); let mut input = make_test_input_state(); input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); @@ -731,7 +755,7 @@ fn selecting_the_whole_image_mid_resize_leaves_review_usable() { let whole = ImagePixelRect::whole(bounds).unwrap(); let display = backend .as_mut() - .and_then(|region| region.enter_review(whole)) + .and_then(|region| region.enter_review_seed(whole).map(|seed| seed.display)) .unwrap(); input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); @@ -744,9 +768,10 @@ fn selecting_the_whole_image_mid_resize_leaves_review_usable() { let replaced_backend = backend; let replaced_ui = input.region_state(); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Pointer, (5.0, 5.0) ), @@ -765,9 +790,10 @@ fn selecting_the_whole_image_mid_resize_leaves_review_usable() { Some(RegionInputSource::Touch) ); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Pointer, (5.0, 5.0) ), @@ -778,9 +804,10 @@ fn selecting_the_whole_image_mid_resize_leaves_review_usable() { Some(RegionInputSource::Touch) ); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Touch, (50.0, 40.0) ), @@ -816,7 +843,7 @@ fn a_grip_click_without_motion_leaves_the_rectangle_untouched_at_any_scale() { *purpose = RegionPurposeTag::CaptureInteractive; } let rect = ImagePixelRect::new(21, 17, 43, 31, bounds).unwrap(); - let Some(display) = region.enter_review(rect) else { + let Some(display) = region.enter_review_seed(rect).map(|seed| seed.display) else { continue; }; let mut backend = Some(region); @@ -844,9 +871,10 @@ fn a_grip_click_without_motion_leaves_the_rectangle_untouched_at_any_scale() { "scale {scale}: {grip:?} grabbed the interior instead of a grip" ); assert_eq!( - finalize_region_selection_event( + finalize_region_selection_with_review_edits( &mut backend, &mut input, + &mut None, RegionInputSource::Pointer, grip, ), @@ -867,7 +895,10 @@ fn a_grip_drag_tracks_the_pointer_from_where_it_was_grabbed() { let mut region = interactive_region(); // Tall enough that the right edge keeps its midpoint grip. let rect = ImagePixelRect::new(20, 10, 40, 60, bounds).unwrap(); - let display = region.enter_review(rect).unwrap(); + let display = region + .enter_review_seed(rect) + .map(|seed| seed.display) + .unwrap(); let mut backend = Some(region); let mut input = make_test_input_state(); input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); @@ -899,3 +930,53 @@ fn a_grip_drag_tracks_the_pointer_from_where_it_was_grabbed() { "the grabbed edge keeps its offset from the pointer" ); } + +#[test] +fn rejected_review_rectangle_preserves_the_active_resize() { + let mut region = interactive_region(); + let rect = ImagePixelRect::new(20, 20, 30, 25, (100, 80)).unwrap(); + let seed = region.enter_review_seed(rect).unwrap(); + assert_eq!(seed.rect, rect); + assert_eq!(seed.generation, 1); + assert!(region.begin_review_resize(SelectionHandle::BottomRight, (50.0, 45.0))); + let before = region; + let outside_source = ImagePixelRect::new(95, 70, 20, 20, (200, 200)).unwrap(); + + assert!(region.enter_review_seed(outside_source).is_none()); + assert_eq!(region, before); + assert!(region.update_review_resize((55.0, 50.0))); + assert_eq!(region.selection_rect().unwrap().size(), (35, 30)); +} + +#[test] +fn mismatched_generation_release_preserves_selection_and_owner() { + let mut backend = Some(interactive_region()); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::CaptureInteractive, 1); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (20.0, 20.0), + )); + if let Some(ActiveScreenRegion::Ready { generation, .. }) = backend.as_mut() { + *generation = 2; + } + let before_backend = backend; + let before_ui = input.region_state(); + let mut edits = None; + + assert_eq!( + finalize_region_selection_with_review_edits( + &mut backend, + &mut input, + &mut edits, + RegionInputSource::Pointer, + (60.0, 50.0), + ), + RegionSelectionFinalize::NotOwned + ); + assert_eq!(backend, before_backend); + assert_eq!(input.region_state(), before_ui); + assert!(edits.is_none()); +} From 916dba4b94daf51109faf541695bb4cd1a4575bf Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:02:48 +0200 Subject: [PATCH 34/42] refactor(input): retain text resources through pointer routing --- .../wayland/backend/event_loop/interaction.rs | 15 +- .../backend/event_loop/session_save/tests.rs | 15 +- src/backend/wayland/handlers/pointer/axis.rs | 57 +++- .../wayland/handlers/pointer/cursor.rs | 9 +- .../wayland/handlers/pointer/motion.rs | 24 +- src/backend/wayland/handlers/pointer/press.rs | 31 ++- .../wayland/handlers/pointer/release.rs | 25 +- src/backend/wayland/handlers/tablet/frame.rs | 38 ++- src/backend/wayland/handlers/tablet/tool.rs | 6 +- src/backend/wayland/handlers/touch.rs | 53 +++- .../runtime_ui_state/tests/visibility.rs | 27 +- .../tests/visibility_recovery.rs | 37 ++- src/backend/wayland/session/tests.rs | 23 +- src/backend/wayland/state/pointer_runtime.rs | 35 ++- .../wayland/state/toolbar/events/tests.rs | 33 ++- src/input/state/actions/action_dispatch.rs | 6 - src/input/state/core/board/pages.rs | 41 --- .../state/core/board_picker/state/actions.rs | 12 +- .../state/core/board_picker/state/drag.rs | 19 +- src/input/state/core/dirty.rs | 6 - src/input/state/core/font_picker/input.rs | 9 +- src/input/state/core/font_picker/mod.rs | 6 +- src/input/state/core/font_picker/tests.rs | 20 +- src/input/state/core/properties/apply.rs | 6 +- .../apply_selection/actions/arrow.rs | 12 +- src/input/state/core/radial_menu/state.rs | 41 +-- src/input/state/core/region_select.rs | 9 +- src/input/state/core/selection.rs | 6 +- .../core/selection_actions/arrow_bend.rs | 6 +- .../selection_actions/arrow_bend/tests.rs | 69 +++-- .../state/core/selection_actions/delete.rs | 6 +- .../state/core/selection_actions/geometry.rs | 6 +- .../state/core/selection_actions/handles.rs | 13 +- .../core/selection_actions/handles/tests.rs | 27 +- .../state/core/selection_actions/resize.rs | 13 - .../state/core/selection_actions/spotlight.rs | 12 - .../state/core/selection_actions/text/edit.rs | 6 +- .../core/selection_actions/text/handles.rs | 4 - .../state/core/selection_actions/text/wrap.rs | 8 +- .../core/selection_actions/translation/mod.rs | 6 +- src/input/state/core/session.rs | 4 +- src/input/state/core/status_hud.rs | 13 +- .../state/core/tool_controls/settings.rs | 7 - src/input/state/core/tour.rs | 9 +- src/input/state/core/utility/interaction.rs | 12 +- .../interaction/adapters/active_motion.rs | 29 +- .../state/interaction/adapters/pointer.rs | 79 ++++-- src/input/state/interaction/mod.rs | 44 ++- src/input/state/interaction/pointer.rs | 61 +++-- src/input/state/mouse/motion.rs | 17 +- src/input/state/mouse/press.rs | 94 ++++--- src/input/state/mouse/press/panels.rs | 19 +- src/input/state/mouse/press/polygon.rs | 35 ++- src/input/state/mouse/release/drawing.rs | 13 +- src/input/state/mouse/release/mod.rs | 43 ++- src/input/state/mouse/release/panels.rs | 45 ++- src/input/state/mouse/release/selection.rs | 10 +- src/input/state/mouse/release/text.rs | 3 +- src/input/state/spotlight.rs | 16 +- src/input/state/tests/basics.rs | 105 +++++-- src/input/state/tests/boards.rs | 12 +- src/input/state/tests/delete_restore.rs | 8 +- src/input/state/tests/drawing.rs | 32 ++- src/input/state/tests/erase.rs | 29 +- src/input/state/tests/focus_mode.rs | 199 +++++++++++--- src/input/state/tests/input_hud.rs | 37 ++- src/input/state/tests/light_mode.rs | 75 ++++- src/input/state/tests/pages.rs | 79 +++++- src/input/state/tests/presenter_mode.rs | 13 +- src/input/state/tests/properties_panel.rs | 20 +- src/input/state/tests/radial_menu.rs | 44 ++- src/input/state/tests/selection/actions.rs | 9 +- src/input/state/tests/selection/deletion.rs | 2 +- src/input/state/tests/selection/duplicate.rs | 163 +++++++++-- src/input/state/tests/spotlight.rs | 94 +++++-- src/input/state/tests/status_hud.rs | 71 +++-- src/input/state/tests/text_input/actions.rs | 56 +++- src/input/state/tests/text_input/editing.rs | 23 +- src/input/state/tests/tool_controls.rs | 48 +++- src/input/state/tests/toolbar_display.rs | 258 ++++++++++++++---- src/input/state/tests/transform.rs | 96 +++++-- src/input/state/tests/zoom_chip.rs | 29 +- src/input/state/text_resources/tests.rs | 60 ++++ src/input/tablet/mod.rs | 4 +- src/session/tests/snapshot.rs | 20 +- 85 files changed, 2214 insertions(+), 722 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/interaction.rs b/src/backend/wayland/backend/event_loop/interaction.rs index e1f8dbdef..d6a424675 100644 --- a/src/backend/wayland/backend/event_loop/interaction.rs +++ b/src/backend/wayland/backend/event_loop/interaction.rs @@ -42,6 +42,13 @@ mod tests { #[test] fn polling_the_owning_path_finishes_one_idle_wheel_burst() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut input_state = make_test_input_state(); let shape_id = input_state .boards @@ -57,7 +64,7 @@ mod tests { let mut deadline = Some(now + Duration::from_millis(600)); assert_eq!( - input_state.nudge_spotlight_magnification_at(200, 200, 1), + input_state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); poll_interaction_deadlines( @@ -81,10 +88,10 @@ mod tests { ); assert_eq!( - input_state.nudge_spotlight_magnification_at(200, 200, 1), + input_state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); - input_state.handle_action(Action::Undo); + input_state.handle_action_with_resources(test_text_resources, Action::Undo); let magnification = match input_state .boards .active_frame() @@ -100,7 +107,7 @@ mod tests { "the post-idle tick must be a separately undoable gesture" ); - input_state.handle_action(Action::Undo); + input_state.handle_action_with_resources(test_text_resources, Action::Undo); let magnification = match input_state .boards .active_frame() diff --git a/src/backend/wayland/backend/event_loop/session_save/tests.rs b/src/backend/wayland/backend/event_loop/session_save/tests.rs index ff65ffcba..8ba77bcc1 100644 --- a/src/backend/wayland/backend/event_loop/session_save/tests.rs +++ b/src/backend/wayland/backend/event_loop/session_save/tests.rs @@ -37,6 +37,8 @@ fn pointer_and_stylus_both_gate_persistence_transitions() { #[test] fn a_due_autosave_is_deferred_while_spotlight_wheel_history_is_pending() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = make_test_input_state(); input.boards.active_frame_mut().add_shape(Shape::Spotlight { cx: 200, @@ -46,7 +48,7 @@ fn a_due_autosave_is_deferred_while_spotlight_wheel_history_is_pending() { magnification: 2.0, }); assert_eq!( - input.nudge_spotlight_magnification_at(200, 200, 1), + input.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); assert!(input.has_pending_spotlight_magnification_gesture()); @@ -73,6 +75,13 @@ fn a_due_autosave_is_deferred_while_spotlight_wheel_history_is_pending() { #[test] fn shutdown_persistence_records_wheel_history_before_capturing_the_snapshot() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut input = make_test_input_state(); let shape_id = input.boards.active_frame_mut().add_shape(Shape::Spotlight { cx: 200, @@ -82,7 +91,7 @@ fn shutdown_persistence_records_wheel_history_before_capturing_the_snapshot() { magnification: 2.0, }); assert_eq!( - input.nudge_spotlight_magnification_at(200, 200, 1), + input.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); let mut deadline = Some(Instant::now() + Duration::from_millis(600)); @@ -98,7 +107,7 @@ fn shutdown_persistence_records_wheel_history_before_capturing_the_snapshot() { .expect("changed loupe snapshot"); let mut restored = make_test_input_state(); crate::session::apply_snapshot(&mut restored, snapshot, &options); - restored.handle_action(Action::Undo); + restored.handle_action_with_resources(test_text_resources, Action::Undo); let Shape::Spotlight { magnification, .. } = restored .boards .active_frame() diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index ba51ef93c..86eb01364 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -71,6 +71,7 @@ fn finalize_spotlight_wheel_if_axis_stopped( /// routing: SCTK may aggregate a final movement and stop in one frame. fn try_handle_spotlight_axis( input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, spotlight_wheel_idle_deadline: &mut Option, canvas_position: (i32, i32), vertical: AxisScroll, @@ -96,8 +97,8 @@ fn try_handle_spotlight_axis( } SpotlightWheelClaim::Adjustable(steps) => { if steps != 0 { - let outcome = - input_state.nudge_spotlight_magnification_at(canvas_x, canvas_y, steps); + let outcome = input_state + .nudge_spotlight_magnification_at_with(measurer, canvas_x, canvas_y, steps); debug_assert_ne!(outcome, SpotlightWheelOutcome::NotOverLoupe); debug!("Spotlight wheel at ({canvas_x}, {canvas_y}): {outcome:?}"); } @@ -232,6 +233,7 @@ impl WaylandState { let canvas_position = self.input_state.canvas_pointer_position(); if try_handle_spotlight_axis( &mut self.input_state, + self.render.text_measurer(), self.spotlight.wheel_idle_deadline_mut(), canvas_position, vertical, @@ -286,8 +288,12 @@ impl WaylandState { let prev_thickness = self.input_state.style.current_thickness; let changed = if radial_menu_path { - self.input_state.radial_menu_adjust_thickness(delta) - } else if self.input_state.nudge_thickness_for_active_tool(delta) { + self.input_state + .radial_menu_adjust_thickness_with_measurer(self.render.text_measurer(), delta) + } else if self + .input_state + .nudge_thickness_for_active_tool_with(self.render.text_measurer(), delta) + { self.input_state.needs_redraw = true; true } else { @@ -468,6 +474,13 @@ mod tests { #[test] fn a_final_axis_delta_and_stop_complete_one_spotlight_gesture() { + let pointer_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &pointer_measurer, + ui_engine: &test_ui_engine, + }; + let mut input_state = make_test_input_state(); let shape_id = input_state .boards @@ -484,6 +497,7 @@ mod tests { assert!(try_handle_spotlight_axis( &mut input_state, + &pointer_measurer, &mut deadline, (200, 200), AxisScroll { @@ -498,6 +512,7 @@ mod tests { assert!(deadline.is_some()); assert!(try_handle_spotlight_axis( &mut input_state, + &pointer_measurer, &mut deadline, (200, 200), AxisScroll { @@ -515,7 +530,7 @@ mod tests { "axis stop owns the final deadline clear" ); - input_state.handle_action(Action::Undo); + input_state.handle_action_with_resources(test_text_resources, Action::Undo); let magnification = match input_state .boards .active_frame() @@ -534,6 +549,13 @@ mod tests { #[test] fn a_coalesced_value120_frame_applies_every_logical_step() { + let pointer_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &pointer_measurer, + ui_engine: &test_ui_engine, + }; + let mut input_state = make_test_input_state(); let shape_id = input_state .boards @@ -549,6 +571,7 @@ mod tests { assert!(try_handle_spotlight_axis( &mut input_state, + &pointer_measurer, &mut deadline, (200, 200), AxisScroll { @@ -572,7 +595,7 @@ mod tests { }; assert_eq!(magnification, 2.5); - input_state.handle_action(Action::Undo); + input_state.handle_action_with_resources(test_text_resources, Action::Undo); let Shape::Spotlight { magnification, .. } = input_state .boards .active_frame() @@ -587,6 +610,13 @@ mod tests { #[test] fn partial_value120_frames_accumulate_before_applying_a_logical_step() { + let pointer_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &pointer_measurer, + ui_engine: &test_ui_engine, + }; + let mut input_state = make_test_input_state(); let shape_id = input_state .boards @@ -607,6 +637,7 @@ mod tests { assert!(try_handle_spotlight_axis( &mut input_state, + &pointer_measurer, &mut deadline, (200, 200), partial_tick, @@ -627,6 +658,7 @@ mod tests { assert!(try_handle_spotlight_axis( &mut input_state, + &pointer_measurer, &mut deadline, (200, 200), AxisScroll { @@ -648,7 +680,7 @@ mod tests { }; assert_eq!(magnification, 2.25); - input_state.handle_action(Action::Undo); + input_state.handle_action_with_resources(test_text_resources, Action::Undo); let Shape::Spotlight { magnification, .. } = input_state .boards .active_frame() @@ -663,6 +695,13 @@ mod tests { #[test] fn a_finger_axis_pause_longer_than_the_wheel_timeout_stays_one_gesture() { + let pointer_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &pointer_measurer, + ui_engine: &test_ui_engine, + }; + let mut input_state = make_test_input_state(); let shape_id = input_state .boards @@ -685,6 +724,7 @@ mod tests { assert!(try_handle_spotlight_axis( &mut input_state, + &pointer_measurer, &mut deadline, (200, 200), finger_delta, @@ -695,6 +735,7 @@ mod tests { assert!(try_handle_spotlight_axis( &mut input_state, + &pointer_measurer, &mut deadline, (200, 200), finger_delta, @@ -703,7 +744,7 @@ mod tests { )); finalize_spotlight_wheel_if_axis_stopped(&mut input_state, &mut deadline, true); - input_state.handle_action(Action::Undo); + input_state.handle_action_with_resources(test_text_resources, Action::Undo); let Shape::Spotlight { magnification, .. } = input_state .boards .active_frame() diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index 3d5fde80e..dde91e23d 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -334,14 +334,19 @@ impl WaylandState { } let (canvas_x, canvas_y) = self.input_state.canvas_pointer_position(); - match self.input_state.hit_idle_handle(canvas_x, canvas_y) { + match self + .input_state + .hit_idle_handle_with(self.render.text_measurer(), canvas_x, canvas_y) + { Some(IdleHandle::SpotlightMagnification(_)) => return CursorIcon::EwResize, Some(IdleHandle::ArrowBend(_)) => return CursorIcon::Grab, Some(IdleHandle::TextResize(_)) => return CursorIcon::SeResize, Some(IdleHandle::SelectionResize(handle)) => return resize_cursor(handle), None => {} } - if let Some(hit_id) = self.input_state.hit_test_at(canvas_x, canvas_y) + if let Some(hit_id) = + self.input_state + .hit_test_at_with(self.render.text_measurer(), canvas_x, canvas_y) && self .input_state .selected_shape_ids_set() diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs index 60adb0a9b..537919acd 100644 --- a/src/backend/wayland/handlers/pointer/motion.rs +++ b/src/backend/wayland/handlers/pointer/motion.rs @@ -122,8 +122,16 @@ impl WaylandState { let (wx, wy) = self.zoomed_world_coords(sx, sy); self.input_state .update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy); - self.input_state - .on_mouse_motion_with_canvas(sx.round() as i32, sy.round() as i32, wx, wy); + self.input_state.on_mouse_motion_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + sx.round() as i32, + sy.round() as i32, + wx, + wy, + ); self.update_pointer_cursor(false, conn); true } @@ -236,8 +244,16 @@ impl WaylandState { let (wx, wy) = self.zoomed_world_coords(sx, sy); self.input_state .update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy); - self.input_state - .on_mouse_motion_with_canvas(sx.round() as i32, sy.round() as i32, wx, wy); + self.input_state.on_mouse_motion_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + sx.round() as i32, + sy.round() as i32, + wx, + wy, + ); self.update_pointer_cursor(false, conn); self.mark_mouse_tool_preview_dirty(previous, next); self.record_perf_input_sample( diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index 4d118b3d3..025197d90 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -130,8 +130,17 @@ impl WaylandState { let screen_x = event.position.0.round() as i32; let screen_y = event.position.1.round() as i32; let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state - .on_mouse_press_with_canvas(mb, screen_x, screen_y, wx, wy); + self.input_state.on_mouse_press_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + mb, + screen_x, + screen_y, + wx, + wy, + ); self.input_state.needs_redraw = true; } @@ -232,12 +241,18 @@ impl WaylandState { return false; } if button == BTN_LEFT { - let handled = self.input_state.handle_command_palette_click( - event.position.0 as i32, - event.position.1 as i32, - self.surface.width(), - self.surface.height(), - ); + let handled = self + .input_state + .handle_command_palette_click_with_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + event.position.0 as i32, + event.position.1 as i32, + self.surface.width(), + self.surface.height(), + ); if handled { self.pointer.suppress_release(RegionInputSource::Pointer); } diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index fce313a0f..2d04a903a 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -120,8 +120,17 @@ impl WaylandState { let screen_x = event.position.0.round() as i32; let screen_y = event.position.1.round() as i32; let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state - .on_mouse_release_with_canvas(mb, screen_x, screen_y, wx, wy); + self.input_state.on_mouse_release_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + mb, + screen_x, + screen_y, + wx, + wy, + ); self.input_state.needs_redraw = true; } @@ -184,7 +193,11 @@ impl WaylandState { return true; } if self.pointer.take_status_hud_press() { - let (hit, action) = self.input_state.check_status_hud_click(screen_x, screen_y); + let (hit, action) = self.input_state.check_status_hud_click_with_measurer( + self.render.text_measurer(), + screen_x, + screen_y, + ); if hit && let Some(action) = action { self.dispatch_input_action(action); } @@ -219,7 +232,11 @@ impl WaylandState { return false; }; let (wx, wy) = self.zoomed_world_coords(sx, sy); - self.input_state.on_mouse_release_with_canvas( + self.input_state.on_mouse_release_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, mb, sx.round() as i32, sy.round() as i32, diff --git a/src/backend/wayland/handlers/tablet/frame.rs b/src/backend/wayland/handlers/tablet/frame.rs index 32e7a5e29..92608ce16 100644 --- a/src/backend/wayland/handlers/tablet/frame.rs +++ b/src/backend/wayland/handlers/tablet/frame.rs @@ -127,7 +127,10 @@ impl WaylandState { } if first_pressure_sample { self.input_state - .replace_active_drawing_pressure_samples(self.input_state.style.current_thickness); + .replace_active_drawing_pressure_samples_with( + self.render.text_measurer(), + self.input_state.style.current_thickness, + ); } self.tablet.pressure_thickness = Some(self.input_state.style.current_thickness); self.record_stylus_peak(self.input_state.style.current_thickness); @@ -138,8 +141,16 @@ impl WaylandState { self.pointer.set_position((x as i32, y as i32)); self.tablet.last_pos = Some((x, y)); let (wx, wy) = self.zoomed_world_coords(x, y); - self.input_state - .on_mouse_motion_with_canvas(x.round() as i32, y.round() as i32, wx, wy); + self.input_state.on_mouse_motion_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + x.round() as i32, + y.round() as i32, + wx, + wy, + ); self.record_perf_input_sample( PerfInputSource::Stylus, x.round() as i32, @@ -229,8 +240,17 @@ impl WaylandState { let screen_x = self.pointer.position().0; let screen_y = self.pointer.position().1; let (wx, wy) = self.zoomed_world_coords(x, y); - self.input_state - .on_mouse_press_with_canvas(MouseButton::Left, screen_x, screen_y, wx, wy); + self.input_state.on_mouse_press_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + MouseButton::Left, + screen_x, + screen_y, + wx, + wy, + ); let base_thickness = self.input_state.style.current_thickness; self.tablet.base_thickness = Some(base_thickness); self.record_stylus_motion_thickness(); @@ -250,7 +270,7 @@ impl WaylandState { .or(self.tablet.base_thickness); if let Some(thick) = final_thick { self.input_state - .set_pressure_thickness_for_active_tool(thick); + .set_pressure_thickness_for_active_tool_with(self.render.text_measurer(), thick); self.tablet.base_thickness = Some(thick); } self.tablet.pressure_thickness = None; @@ -271,7 +291,11 @@ impl WaylandState { return; } let (wx, wy) = self.zoomed_world_coords(x, y); - self.input_state.on_mouse_release_with_canvas( + self.input_state.on_mouse_release_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, MouseButton::Left, screen_x, screen_y, diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs index 654640ee4..daec46670 100644 --- a/src/backend/wayland/handlers/tablet/tool.rs +++ b/src/backend/wayland/handlers/tablet/tool.rs @@ -170,7 +170,8 @@ impl WaylandState { return; } self.tablet.pre_eraser_tool_override = self.input_state.tool_override(); - self.input_state.set_tool_override(Some(Tool::Eraser)); + self.input_state + .set_tool_override_with(self.render.text_measurer(), Some(Tool::Eraser)); self.tablet.auto_switched_to_eraser = true; info!( "Auto-switched to eraser (physical eraser detected), saved previous: {:?}", @@ -213,7 +214,8 @@ impl WaylandState { return; } let restored_tool = self.tablet.pre_eraser_tool_override; - self.input_state.set_tool_override(restored_tool); + self.input_state + .set_tool_override_with(self.render.text_measurer(), restored_tool); self.tablet.auto_switched_to_eraser = false; self.tablet.pre_eraser_tool_override = None; info!( diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs index 8024c8e75..fea93f230 100644 --- a/src/backend/wayland/handlers/touch.rs +++ b/src/backend/wayland/handlers/touch.rs @@ -148,7 +148,8 @@ impl WaylandState { if self.pointer.board_pan_active() { self.pointer.stop_board_pan(); } - self.input_state.cancel_active_interaction(); + self.input_state + .cancel_active_interaction_with(self.render.text_measurer()); self.input_state.needs_redraw = true; } @@ -255,12 +256,19 @@ impl WaylandState { if !self.input_state.command_palette.open { return None; } - if self.input_state.handle_command_palette_click( - screen_x, - screen_y, - self.surface.width(), - self.surface.height(), - ) { + if self + .input_state + .handle_command_palette_click_with_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + screen_x, + screen_y, + self.surface.width(), + self.surface.height(), + ) + { self.pointer.suppress_release(RegionInputSource::Touch); } Some(TouchTarget::Foreign) @@ -319,8 +327,17 @@ impl WaylandState { return target; } let (wx, wy) = self.zoomed_world_coords(screen_position.0, screen_position.1); - self.input_state - .on_mouse_press_with_canvas(MouseButton::Left, screen_x, screen_y, wx, wy); + self.input_state.on_mouse_press_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + MouseButton::Left, + screen_x, + screen_y, + wx, + wy, + ); self.input_state.needs_redraw = true; target } @@ -421,8 +438,16 @@ impl WaylandState { let (wx, wy) = self.zoomed_world_coords(screen_position.0, screen_position.1); self.input_state .update_pointer_positions(screen_x, screen_y, wx, wy); - self.input_state - .on_mouse_motion_with_canvas(screen_x, screen_y, wx, wy); + self.input_state.on_mouse_motion_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + screen_x, + screen_y, + wx, + wy, + ); self.record_perf_input_sample(PerfInputSource::Touch, screen_x, screen_y, wx, wy, false); } @@ -548,7 +573,11 @@ impl WaylandState { return; } let (wx, wy) = self.zoomed_world_coords(screen_position.0, screen_position.1); - self.input_state.on_mouse_release_with_canvas( + self.input_state.on_mouse_release_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, MouseButton::Left, screen_x, screen_y, diff --git a/src/backend/wayland/runtime_ui_state/tests/visibility.rs b/src/backend/wayland/runtime_ui_state/tests/visibility.rs index fef7fcaf5..83163bcc8 100644 --- a/src/backend/wayland/runtime_ui_state/tests/visibility.rs +++ b/src/backend/wayland/runtime_ui_state/tests/visibility.rs @@ -6,6 +6,13 @@ use super::*; /// exactly what the toggle left on screen. #[test] fn keyboard_visibility_toggle_persists_both_pins_and_startup_hides_the_toolbar() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::domain::Action; use crate::input::state::PendingToolbarPersistence; @@ -20,7 +27,7 @@ fn keyboard_visibility_toggle_persists_both_pins_and_startup_hides_the_toolbar() // Driven through the real F9 arm and its queue: the toggle already // applied, so the drained entry carries the pre-toggle pins, which // supply the write's rollback. - input.handle_action(Action::ToggleToolbar); + input.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!(!input.toolbar_top_pinned()); assert_eq!( input.take_pending_toolbar_persistence(), @@ -229,6 +236,13 @@ fn visibility_toggle_rollback_through_a_failed_reset_restores_the_screen() { /// instead, and the take's no-op filter then drops the entry as moot. #[test] fn a_barrier_defers_queued_visibility_persistence_instead_of_dropping_it() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::domain::Action; use crate::input::state::PendingToolbarPersistence; @@ -250,7 +264,7 @@ fn a_barrier_defers_queued_visibility_persistence_instead_of_dropping_it() { assert!(runtime.mutation_barrier_active()); // The press lands on screen and queues normally; only the write waits. - input.handle_action(Action::ToggleToolbar); + input.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!(!input.toolbar_visible()); assert!(input.has_pending_toolbar_persistence()); assert!( @@ -323,6 +337,13 @@ fn a_barrier_defers_queued_visibility_persistence_instead_of_dropping_it() { /// not the pre-toggle pins. #[test] fn an_exit_during_an_active_reset_barrier_still_lands_the_deferred_toggle() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::domain::Action; use crate::input::state::PendingToolbarPersistence; @@ -341,7 +362,7 @@ fn an_exit_during_an_active_reset_barrier_still_lands_the_deferred_toggle() { RequestResetResult::Started { .. } )); assert!(runtime.mutation_barrier_active()); - input.handle_action(Action::ToggleToolbar); + input.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!(!input.toolbar_visible()); assert!(input.has_pending_toolbar_persistence()); diff --git a/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs b/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs index cc391b8af..c230e58c8 100644 --- a/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs +++ b/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs @@ -10,6 +10,13 @@ use super::*; /// exit-time screen. #[test] fn an_exit_during_retry_pending_recovery_still_lands_the_deferred_toggle() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::domain::Action; use crate::input::state::PendingToolbarPersistence; use std::os::unix::fs::PermissionsExt; @@ -35,7 +42,7 @@ fn an_exit_during_retry_pending_recovery_still_lands_the_deferred_toggle() { "retry must start a recovery attempt" ); assert!(runtime.mutation_barrier_active()); - input.handle_action(Action::ToggleToolbar); + input.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!(!input.toolbar_visible()); assert!(input.has_pending_toolbar_persistence()); @@ -108,7 +115,7 @@ fn a_deferred_hide_rollback_lands_in_the_presenter_restore_snapshot() { let mut input = input_from_config(&config); let mut positions = config_positions(&config); - input.handle_action(Action::ToggleToolbar); // hide, pin → false + input.handle_action_with_resources(route_resources, Action::ToggleToolbar); // hide, pin → false assert!(!input.toolbar_visible()); input.take_pending_toolbar_persistence(); // the write whose rollback arrives below @@ -142,6 +149,13 @@ fn a_deferred_hide_rollback_lands_in_the_presenter_restore_snapshot() { /// screen agreeing with the rolled-back pins. #[test] fn a_deferred_hide_rollback_lands_in_the_focus_mode_snapshot() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::config::{StatusBarStyle, StatusPosition}; use crate::domain::Action; @@ -158,11 +172,11 @@ fn a_deferred_hide_rollback_lands_in_the_focus_mode_snapshot() { 720, ); - input.handle_action(Action::ToggleToolbar); // hide, pin → false + input.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // hide, pin → false assert!(!input.toolbar_visible()); input.take_pending_toolbar_persistence(); // the write whose rollback arrives below - input.handle_action(Action::ToggleFocusMode); + input.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(input.focus_mode_active()); apply_toolbar_runtime_rollback( @@ -178,7 +192,7 @@ fn a_deferred_hide_rollback_lands_in_the_focus_mode_snapshot() { "the live focus-hidden flags must not move under the owner" ); - input.handle_action(Action::ToggleFocusMode); // restore + input.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); // restore assert!(!input.focus_mode_active()); assert!( input.toolbar_visible() && input.toolbar_top_visible(), @@ -190,6 +204,13 @@ fn a_deferred_hide_rollback_lands_in_the_focus_mode_snapshot() { /// toolbar flags and its exit writes the snapshot back). #[test] fn a_deferred_hide_rollback_lands_in_the_light_mode_snapshot() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::domain::Action; let config = Config::default(); @@ -198,11 +219,11 @@ fn a_deferred_hide_rollback_lands_in_the_light_mode_snapshot() { // Light mode refuses to start without layer-shell passthrough support. input.compositor_capabilities.layer_shell = true; - input.handle_action(Action::ToggleToolbar); // hide, pin → false + input.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // hide, pin → false assert!(!input.toolbar_visible()); input.take_pending_toolbar_persistence(); // the write whose rollback arrives below - input.handle_action(Action::ToggleLightMode); + input.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(input.light_mode_active()); apply_toolbar_runtime_rollback( @@ -218,7 +239,7 @@ fn a_deferred_hide_rollback_lands_in_the_light_mode_snapshot() { "the live light-mode-hidden flags must not move under the owner" ); - input.handle_action(Action::ToggleLightMode); // exit restores the snapshot + input.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); // exit restores the snapshot assert!(!input.light_mode_active()); assert!( input.toolbar_visible() && input.toolbar_top_visible(), diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index be970faaa..7f20e07db 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -1185,6 +1185,8 @@ fn runtime_open_closes_active_board_picker_page_drag() { #[test] fn runtime_open_saves_current_after_canceling_active_selection_move() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let temp = crate::test_temp::tempdir().expect("tempdir"); let _env = EnvGuard::set_xdg_data_home(temp.path()); let current_options = named_options(temp.path(), "current-active-selection-move"); @@ -1196,7 +1198,7 @@ fn runtime_open_saves_current_after_canceling_active_selection_move() { let shape_id = add_line(&mut input, 20); input.set_selection(vec![shape_id]); let snapshots = input.capture_movable_selection_snapshots(); - assert!(input.apply_translation_to_selection(100, 0)); + assert!(input.apply_translation_to_selection_with(&test_text_measurer, 100, 0)); input.state = DrawingState::MovingSelection { last_x: 100, last_y: 0, @@ -1230,6 +1232,8 @@ fn runtime_open_saves_current_after_canceling_active_selection_move() { #[test] fn runtime_open_saves_current_after_canceling_active_text_edit() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let temp = crate::test_temp::tempdir().expect("tempdir"); let _env = EnvGuard::set_xdg_data_home(temp.path()); let current_options = named_options(temp.path(), "current-active-text-edit"); @@ -1249,7 +1253,7 @@ fn runtime_open_saves_current_after_canceling_active_text_edit() { wrap_width: Some(180), }); input.set_selection(vec![shape_id]); - assert!(input.edit_selected_text()); + assert!(input.edit_selected_text_with(&test_text_measurer)); let Shape::Text { text, .. } = &input .boards .active_frame() @@ -1333,6 +1337,8 @@ fn runtime_open_saves_current_after_canceling_color_picker_preview() { #[cfg(unix)] #[test] fn runtime_open_current_save_failure_preserves_active_selection_move() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let temp = crate::test_temp::tempdir().expect("tempdir"); let current_options = named_options(temp.path(), "current-active-save-fail"); let current_target = temp.path().join("current-active-symlink-target"); @@ -1346,7 +1352,7 @@ fn runtime_open_current_save_failure_preserves_active_selection_move() { let shape_id = add_line(&mut input, 9); input.set_selection(vec![shape_id]); let snapshots = input.capture_movable_selection_snapshots(); - assert!(input.apply_translation_to_selection(100, 0)); + assert!(input.apply_translation_to_selection_with(&test_text_measurer, 100, 0)); input.state = DrawingState::MovingSelection { last_x: 100, last_y: 0, @@ -1408,7 +1414,7 @@ fn runtime_open_current_save_failure_preserves_spatial_index_for_active_selectio input.set_selection(vec![shape_id]); let snapshots = input.capture_movable_selection_snapshots(); - assert!(input.apply_translation_to_selection(200, 0)); + assert!(input.apply_translation_to_selection_with(&measurer, 200, 0)); assert!( input .hit_test_all_for_points_with(&measurer, &[(205, 5)], input.hit_test_tolerance()) @@ -1714,6 +1720,13 @@ fn protected_session_path_blocks_save_until_session_is_dirty() { /// autosave or final save would replace the session that failed to restore. #[test] fn protected_session_path_survives_a_run_only_status_bar_toggle() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "display"); options.autosave_enabled = true; options.persist_transparent = true; @@ -1722,7 +1735,7 @@ fn protected_session_path_survives_a_run_only_status_bar_toggle() { state.protect_session_path(path.clone()); let mut input = test_input_state(); - input.handle_action(Action::ToggleStatusBar); + input.handle_action_with_resources(test_text_resources, Action::ToggleStatusBar); assert!( !input.ui_visibility.show_status_bar, diff --git a/src/backend/wayland/state/pointer_runtime.rs b/src/backend/wayland/state/pointer_runtime.rs index 5c8d29235..748c8406a 100644 --- a/src/backend/wayland/state/pointer_runtime.rs +++ b/src/backend/wayland/state/pointer_runtime.rs @@ -643,6 +643,12 @@ mod tests { ) { use crate::input::{MouseButton, state::DrawingState}; + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; let mut runtime = PointerRuntime::new(); let mut input = crate::input::state::test_support::make_test_input_state(); @@ -650,8 +656,15 @@ mod tests { // other device starts drawing. Both canvas press handlers reset chrome. runtime.suppress_release(consumed_source); runtime.clear_chrome_press(); - input.on_mouse_press_with_canvas(MouseButton::Left, 10, 20, 10, 20); - input.on_mouse_motion_with_canvas(30, 40, 30, 40); + input.on_mouse_press_with_canvas_and_resources( + resources, + MouseButton::Left, + 10, + 20, + 10, + 20, + ); + input.on_mouse_motion_with_canvas_and_resources(resources, 30, 40, 30, 40); assert!(matches!(input.state, DrawingState::Drawing { .. })); // Exercise the release gate shared by the pointer and touch handlers. @@ -659,13 +672,27 @@ mod tests { if runtime.take_suppressed_release(consumed_source) { runtime.clear_chrome_press(); } else { - input.on_mouse_release_with_canvas(MouseButton::Left, 30, 40, 30, 40); + input.on_mouse_release_with_canvas_and_resources( + resources, + MouseButton::Left, + 30, + 40, + 30, + 40, + ); } assert!(matches!(input.state, DrawingState::Drawing { .. })); assert!(input.boards.active_frame().shapes.is_empty()); assert!(!runtime.take_suppressed_release(drawing_source)); - input.on_mouse_release_with_canvas(MouseButton::Left, 50, 60, 50, 60); + input.on_mouse_release_with_canvas_and_resources( + resources, + MouseButton::Left, + 50, + 60, + 50, + 60, + ); assert!(matches!(input.state, DrawingState::Idle)); assert_eq!(input.boards.active_frame().shapes.len(), 1); } diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs index b4a7730ea..4f198ae0b 100644 --- a/src/backend/wayland/state/toolbar/events/tests.rs +++ b/src/backend/wayland/state/toolbar/events/tests.rs @@ -168,6 +168,13 @@ fn runtime_toolbar_events_do_not_directly_save_config() { #[test] fn backend_session_dispatch_finalizes_spotlight_history_and_its_deadline() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut input_state = make_test_input_state(); let shape_id = input_state .boards @@ -180,7 +187,7 @@ fn backend_session_dispatch_finalizes_spotlight_history_and_its_deadline() { magnification: 2.0, }); assert_eq!( - input_state.nudge_spotlight_magnification_at(200, 200, 1), + input_state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), crate::input::state::SpotlightWheelOutcome::Adjusted ); let mut deadline = Some(std::time::Instant::now() + std::time::Duration::from_secs(1)); @@ -196,7 +203,7 @@ fn backend_session_dispatch_finalizes_spotlight_history_and_its_deadline() { ); assert!(deadline.is_none()); - input_state.handle_action(Action::Undo); + input_state.handle_action_with_resources(test_text_resources, Action::Undo); let magnification = match input_state .boards .active_frame() @@ -502,6 +509,13 @@ fn the_toolbar_rebind_gesture_opens_capture_for_the_controls_action() { #[test] fn toolbar_rebind_capture_finalizes_a_held_arrow_bend_before_its_early_return() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut input_state = make_test_input_state(); let shape_id = input_state .boards @@ -534,7 +548,7 @@ fn toolbar_rebind_capture_finalizes_a_held_arrow_bend_before_its_early_return() locked: false, }, }; - assert!(input_state.drag_arrow_bend_to(200, 20, false)); + assert!(input_state.drag_arrow_bend_to_with(&test_text_measurer, 200, 20, false)); let mut deadline = None; let event = ToolbarEvent::Undo; @@ -553,7 +567,7 @@ fn toolbar_rebind_capture_finalizes_a_held_arrow_bend_before_its_early_return() input_state.on_key_press(crate::input::Key::Escape); assert!(input_state.keybinding_capture_action().is_none()); input_state.on_mouse_release(crate::input::MouseButton::Left, 200, 20); - input_state.handle_action(Action::Undo); + input_state.handle_action_with_resources(test_text_resources, Action::Undo); match input_state .boards .active_frame() @@ -1045,6 +1059,13 @@ fn section_toggle_event(flag: ToolbarSectionFlag, show: bool) -> ToolbarEvent { #[test] fn backend_session_dispatch_finalizes_a_held_arrow_bend() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + // Session routes return before `apply_toolbar_event`, and an open or clear // replaces the frame the gesture's snapshot belongs to. Shape ids restart // per frame, so a bend flushed after that would attach to an unrelated @@ -1081,7 +1102,7 @@ fn backend_session_dispatch_finalizes_a_held_arrow_bend() { locked: false, }, }; - assert!(input_state.drag_arrow_bend_to(200, 20, false)); + assert!(input_state.drag_arrow_bend_to_with(&test_text_measurer, 200, 20, false)); let mut deadline = None; assert_eq!( @@ -1099,7 +1120,7 @@ fn backend_session_dispatch_finalizes_a_held_arrow_bend() { "the backend barrier left the bend gesture running" ); // Committed rather than discarded, so the undo stack can take it back. - input_state.handle_action(Action::Undo); + input_state.handle_action_with_resources(test_text_resources, Action::Undo); match input_state .boards .active_frame() diff --git a/src/input/state/actions/action_dispatch.rs b/src/input/state/actions/action_dispatch.rs index 131c4d3d6..fc8f5ece9 100644 --- a/src/input/state/actions/action_dispatch.rs +++ b/src/input/state/actions/action_dispatch.rs @@ -7,12 +7,6 @@ impl InputState { /// /// Bound keys enter [`interaction::route_action_with_resources`] directly, so action-wide /// gesture preflights live at that shared boundary rather than here. - pub(crate) fn handle_action(&mut self, action: Action) { - crate::input::state::with_legacy_text_resources(|resources| { - self.handle_action_with_resources(resources, action) - }); - } - pub(crate) fn handle_action_with_resources( &mut self, resources: crate::input::state::InputTextResources<'_>, diff --git a/src/input/state/core/board/pages.rs b/src/input/state/core/board/pages.rs index f1b879efa..df6c3be0a 100644 --- a/src/input/state/core/board/pages.rs +++ b/src/input/state/core/board/pages.rs @@ -120,17 +120,6 @@ impl InputState { true } - pub(crate) fn reorder_page_in_board( - &mut self, - board_index: usize, - from: usize, - to: usize, - ) -> bool { - with_legacy_measurer(|measurer| { - self.reorder_page_in_board_with_measurer(measurer, board_index, from, to) - }) - } - pub(crate) fn reorder_page_in_board_with_measurer( &mut self, measurer: &TextMeasurer, @@ -189,16 +178,6 @@ impl InputState { true } - pub(crate) fn duplicate_page_in_board( - &mut self, - board_index: usize, - page_index: usize, - ) -> bool { - with_legacy_measurer(|measurer| { - self.duplicate_page_in_board_with_measurer(measurer, board_index, page_index) - }) - } - pub(crate) fn duplicate_page_in_board_with_measurer( &mut self, measurer: &TextMeasurer, @@ -271,26 +250,6 @@ impl InputState { true } - pub(crate) fn move_page_between_boards_with_activation( - &mut self, - source_board: usize, - page_index: usize, - target_board: usize, - copy: bool, - activate_target: bool, - ) -> bool { - with_legacy_measurer(|measurer| { - self.move_page_between_boards_with_activation_with_measurer( - measurer, - source_board, - page_index, - target_board, - copy, - activate_target, - ) - }) - } - pub(crate) fn move_page_between_boards_with_activation_with_measurer( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/board_picker/state/actions.rs b/src/input/state/core/board_picker/state/actions.rs index bc002f1fe..d8f01a5ba 100644 --- a/src/input/state/core/board_picker/state/actions.rs +++ b/src/input/state/core/board_picker/state/actions.rs @@ -116,10 +116,20 @@ impl InputState { } pub(crate) fn board_picker_duplicate_page(&mut self, page_index: usize) { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_duplicate_page_with_measurer(measurer, page_index) + }) + } + + pub(crate) fn board_picker_duplicate_page_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + page_index: usize, + ) { let Some(board_index) = self.board_picker_page_panel_board_index() else { return; }; - if self.duplicate_page_in_board(board_index, page_index) + if self.duplicate_page_in_board_with_measurer(measurer, board_index, page_index) && let Some(new_page_index) = self .boards .board_states() diff --git a/src/input/state/core/board_picker/state/drag.rs b/src/input/state/core/board_picker/state/drag.rs index 720296c35..24a00a93a 100644 --- a/src/input/state/core/board_picker/state/drag.rs +++ b/src/input/state/core/board_picker/state/drag.rs @@ -160,13 +160,23 @@ impl InputState { } pub(crate) fn board_picker_finish_page_drag(&mut self) -> bool { + crate::draw::with_legacy_measurer(|measurer| { + self.board_picker_finish_page_drag_with_measurer(measurer) + }) + } + + pub(crate) fn board_picker_finish_page_drag_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + ) -> bool { let Some(drag) = self.board_picker.page_drag.take() else { return false; }; let target_board = drag.target_board.unwrap_or(drag.board_index); if target_board != drag.board_index { let copy = self.modifiers.alt; - let _ = self.move_page_between_boards_with_activation( + let _ = self.move_page_between_boards_with_activation_with_measurer( + measurer, drag.board_index, drag.source_index, target_board, @@ -180,7 +190,12 @@ impl InputState { self.needs_redraw = true; return true; } - self.reorder_page_in_board(drag.board_index, drag.source_index, drag.current_index); + self.reorder_page_in_board_with_measurer( + measurer, + drag.board_index, + drag.source_index, + drag.current_index, + ); self.needs_redraw = true; true } diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs index 1e7d93eda..347197d04 100644 --- a/src/input/state/core/dirty.rs +++ b/src/input/state/core/dirty.rs @@ -36,12 +36,6 @@ impl InputState { } /// Updates tracked provisional shape bounds for dirty-region purposes. - pub(crate) fn update_provisional_dirty(&mut self, current_x: i32, current_y: i32) { - with_legacy_measurer(|measurer| { - self.update_provisional_dirty_with(measurer, current_x, current_y) - }) - } - pub(crate) fn update_provisional_dirty_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/font_picker/input.rs b/src/input/state/core/font_picker/input.rs index c3e6b4670..1d2783255 100644 --- a/src/input/state/core/font_picker/input.rs +++ b/src/input/state/core/font_picker/input.rs @@ -291,7 +291,12 @@ impl InputState { /// /// A press outside the panel closes the picker, which is what clicking away /// from a modal means everywhere else in the overlay. - pub(crate) fn font_picker_press(&mut self, x: f64, y: f64) -> bool { + pub(crate) fn font_picker_press_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + x: f64, + y: f64, + ) -> bool { if !self.font_picker.open { return false; } @@ -300,7 +305,7 @@ impl InputState { let layout = font_picker_layout(screen_width, screen_height, families.len()); if let Some(index) = font_picker_row_at(layout, &families, self.font_picker.scroll, x, y) { self.set_font_picker_selection(index); - self.commit_font_picker(); + self.commit_font_picker_with_measurer(measurer); return true; } let inside_panel = x >= layout.panel_x diff --git a/src/input/state/core/font_picker/mod.rs b/src/input/state/core/font_picker/mod.rs index 2d8a3c9cb..39dd8809d 100644 --- a/src/input/state/core/font_picker/mod.rs +++ b/src/input/state/core/font_picker/mod.rs @@ -25,8 +25,8 @@ pub(crate) use state::FontPickerState; pub use layout::{FontPickerLayout, FontPickerRow, font_picker_layout, font_picker_rows}; use super::InputState; +use crate::draw::TextMeasurer; use crate::draw::{FontDescriptor, families_match, system_font_catalog_is_ready}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; /// The picker's memoized result list, keyed by what produced it. pub type FontPickerResults = Option<((String, FontPickerFilter), Vec)>; @@ -221,10 +221,6 @@ impl InputState { } /// Apply the highlighted family and close. - pub(crate) fn commit_font_picker(&mut self) -> bool { - with_legacy_measurer(|measurer| self.commit_font_picker_with_measurer(measurer)) - } - pub(crate) fn commit_font_picker_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { let families = self.font_picker_families(); let Some(family) = families.get(self.font_picker.selected).cloned() else { diff --git a/src/input/state/core/font_picker/tests.rs b/src/input/state/core/font_picker/tests.rs index 0ca9f7705..8719e7316 100644 --- a/src/input/state/core/font_picker/tests.rs +++ b/src/input/state/core/font_picker/tests.rs @@ -199,7 +199,7 @@ fn a_query_that_matches_nothing_leaves_an_empty_list_rather_than_the_whole_one() assert!(state.font_picker_families().is_empty()); // Committing an empty list must close cleanly rather than index into it. - assert!(!state.commit_font_picker()); + assert!(!state.commit_font_picker_with_measurer(&route_measurer)); assert!(!state.is_font_picker_open()); } @@ -328,12 +328,14 @@ fn tab_switches_to_monospace_and_back() { #[test] fn choosing_a_font_with_nothing_selected_sets_what_the_next_label_uses() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); open_ready_font_picker(&mut state); state.set_font_picker_selection(2); let chosen = state.font_picker_families()[2].clone(); - assert!(state.commit_font_picker()); + assert!(state.commit_font_picker_with_measurer(&test_text_measurer)); assert_eq!(state.style.font_descriptor.family, chosen); assert!(!state.is_font_picker_open()); @@ -341,6 +343,8 @@ fn choosing_a_font_with_nothing_selected_sets_what_the_next_label_uses() { #[test] fn choosing_a_font_with_text_selected_restyles_it_and_leaves_the_tool_alone() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); let tool_font = state.style.font_descriptor.family.clone(); let id = state @@ -353,7 +357,7 @@ fn choosing_a_font_with_text_selected_restyles_it_and_leaves_the_tool_alone() { assert_eq!(state.font_picker_target(), FontPickerTarget::Selection); state.set_font_picker_selection(2); let chosen = state.font_picker_families()[2].clone(); - assert!(state.commit_font_picker()); + assert!(state.commit_font_picker_with_measurer(&test_text_measurer)); let frame = state.boards.active_frame(); let Some(Shape::Text { @@ -371,11 +375,13 @@ fn choosing_a_font_with_text_selected_restyles_it_and_leaves_the_tool_alone() { #[test] fn chosen_fonts_come_back_to_the_top_of_an_unfiltered_list() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); open_ready_font_picker(&mut state); state.set_font_picker_selection(4); let chosen = state.font_picker_families()[4].clone(); - state.commit_font_picker(); + state.commit_font_picker_with_measurer(&test_text_measurer); open_ready_font_picker(&mut state); @@ -389,6 +395,8 @@ fn chosen_fonts_come_back_to_the_top_of_an_unfiltered_list() { #[test] fn recents_keep_the_most_recent_first_without_repeats() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); let families = system_font_families(); let (first, second) = (families[0].clone(), families[1].clone()); @@ -401,7 +409,7 @@ fn recents_keep_the_most_recent_first_without_repeats() { .position(|name| name == family) .expect("family is listed"); state.set_font_picker_selection(index); - state.commit_font_picker(); + state.commit_font_picker_with_measurer(&test_text_measurer); } assert_eq!(state.font_picker_recents(), [first, second]); @@ -440,7 +448,7 @@ fn a_closed_picker_consumes_nothing() { assert!(!state.handle_font_picker_key_with_measurer(&route_measurer, Key::Escape, None)); assert!(!state.font_picker_hover(10.0, 10.0)); - assert!(!state.font_picker_press(10.0, 10.0)); + assert!(!state.font_picker_press_with_measurer(&route_measurer, 10.0, 10.0)); } /// A picker open on a surface big enough for the full twelve-row window. diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index b11ecc24b..fe32ba3b2 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -1,12 +1,8 @@ use super::super::base::InputState; use super::types::SelectionPropertyKind; -use crate::draw::{Shape, TextMeasurer, with_legacy_measurer}; +use crate::draw::{Shape, TextMeasurer}; impl InputState { - pub(crate) fn activate_properties_panel_entry(&mut self) -> bool { - with_legacy_measurer(|measurer| self.activate_properties_panel_entry_with(measurer)) - } - pub(crate) fn activate_properties_panel_entry_with(&mut self, measurer: &TextMeasurer) -> bool { self.adjust_properties_panel_entry_with(measurer, 0) } diff --git a/src/input/state/core/properties/apply_selection/actions/arrow.rs b/src/input/state/core/properties/apply_selection/actions/arrow.rs index 56f6cb663..e70426554 100644 --- a/src/input/state/core/properties/apply_selection/actions/arrow.rs +++ b/src/input/state/core/properties/apply_selection/actions/arrow.rs @@ -361,6 +361,13 @@ mod tests { #[test] fn cycle_arrow_style_reports_a_locked_arrow_while_its_property_control_stays_disabled() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + // There is no style to step a locked selection to, but "no arrows" // is the wrong reason to give: the arrows are right there and the user // needs to be told to unlock them, not to select something. @@ -385,7 +392,10 @@ mod tests { ); assert_eq!(style_entry.value, "Locked"); - state.handle_action(crate::domain::Action::CycleArrowStyle); + state.handle_action_with_resources( + test_text_resources, + crate::domain::Action::CycleArrowStyle, + ); assert_eq!( state.active_toast().map(|toast| toast.message.as_str()), Some("All arrow style shapes are locked.") diff --git a/src/input/state/core/radial_menu/state.rs b/src/input/state/core/radial_menu/state.rs index 4b477be37..859647e74 100644 --- a/src/input/state/core/radial_menu/state.rs +++ b/src/input/state/core/radial_menu/state.rs @@ -182,7 +182,17 @@ impl InputState { /// Adjust thickness via scroll wheel while the menu is open. pub fn radial_menu_adjust_thickness(&mut self, delta: f64) -> bool { - if !self.nudge_thickness_for_active_tool(delta) { + crate::draw::with_legacy_measurer(|measurer| { + self.radial_menu_adjust_thickness_with_measurer(measurer, delta) + }) + } + + pub(crate) fn radial_menu_adjust_thickness_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + delta: f64, + ) -> bool { + if !self.nudge_thickness_for_active_tool_with(measurer, delta) { return false; } self.dirty_tracker.mark_full(); @@ -210,17 +220,6 @@ impl InputState { /// color swatch once the menu is visible. Releasing back inside the /// center deadzone cancels; an unarmed release keeps the menu open /// (click-to-open browsing). - pub(crate) fn radial_menu_handle_release( - &mut self, - button: MouseButton, - x: f64, - y: f64, - ) -> bool { - crate::input::state::with_legacy_text_resources(|resources| { - self.radial_menu_handle_release_with_resources(resources, button, x, y) - }) - } - pub(crate) fn radial_menu_handle_release_with_resources( &mut self, resources: crate::input::state::InputTextResources<'_>, @@ -320,14 +319,24 @@ impl InputState { /// Begin a size-ring drag at the given pointer position (applies the /// value immediately). - pub(crate) fn radial_menu_begin_size_drag(&mut self, x: f64, y: f64) { + pub(crate) fn radial_menu_begin_size_drag_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + x: f64, + y: f64, + ) { self.radial_menu.set_size_dragging(true); - self.radial_menu_drag_size_to(x, y); + self.radial_menu_drag_size_to_with_measurer(measurer, x, y); } /// Drag-capture update: map the pointer angle to a thickness and apply /// it to the active tool, regardless of pointer distance. - pub(crate) fn radial_menu_drag_size_to(&mut self, x: f64, y: f64) { + pub(crate) fn radial_menu_drag_size_to_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + x: f64, + y: f64, + ) { if !self.radial_menu_is_size_dragging() { return; } @@ -336,7 +345,7 @@ impl InputState { }; let angle = (y - cy).atan2(x - cx); let value = size_ring_value_for_angle(angle); - let _ = self.set_thickness_for_active_tool(value); + let _ = self.set_thickness_for_active_tool_with(measurer, value); } /// End a size-ring drag (the menu stays open). diff --git a/src/input/state/core/region_select.rs b/src/input/state/core/region_select.rs index d43c0f129..5b2d5ce79 100644 --- a/src/input/state/core/region_select.rs +++ b/src/input/state/core/region_select.rs @@ -813,6 +813,13 @@ mod tests { /// remove the stroke drawn before it, proving no region history entry was added. #[test] fn the_selector_lifecycle_adds_no_shape_and_no_history_entry() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = make_test_input_state(); state.on_mouse_press(MouseButton::Left, 0, 0); state.on_mouse_motion(10, 10); @@ -825,7 +832,7 @@ mod tests { state.cancel_region_ui_only(); assert_eq!(state.boards.active_frame().shapes.len(), 1); - state.handle_action(crate::domain::Action::Undo); + state.handle_action_with_resources(test_text_resources, crate::domain::Action::Undo); assert_eq!(state.boards.active_frame().shapes.len(), 0); } diff --git a/src/input/state/core/selection.rs b/src/input/state/core/selection.rs index 86dbf86ce..e26fa5f59 100644 --- a/src/input/state/core/selection.rs +++ b/src/input/state/core/selection.rs @@ -4,7 +4,7 @@ pub(crate) use clipboard::LocalSelectionContext; pub(in crate::input::state::core) use clipboard::SelectionClipboard; use super::base::{InputState, SelectionAxis}; -use crate::draw::{ShapeId, TextMeasurer, with_legacy_measurer}; +use crate::draw::{ShapeId, TextMeasurer}; use crate::util::Rect; use std::collections::HashSet; use std::time::Instant; @@ -174,10 +174,6 @@ impl InputState { self.close_properties_panel(); } - pub(crate) fn selection_bounding_box(&self, ids: &[ShapeId]) -> Option { - with_legacy_measurer(|measurer| self.selection_bounding_box_with(measurer, ids)) - } - pub(crate) fn selection_bounding_box_with( &self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/arrow_bend.rs b/src/input/state/core/selection_actions/arrow_bend.rs index 547e664bd..167c2a90e 100644 --- a/src/input/state/core/selection_actions/arrow_bend.rs +++ b/src/input/state/core/selection_actions/arrow_bend.rs @@ -6,8 +6,8 @@ //! dragging it sets that scalar from the pointer's perpendicular distance to //! the chord. +use crate::draw::TextMeasurer; use crate::draw::{ArrowStyle, Shape, ShapeId}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; use crate::util::{self, Rect}; @@ -88,10 +88,6 @@ impl InputState { /// The chord is re-read from the shape rather than frozen at press: bending /// never moves the endpoints, so the mapping is stable for the whole /// gesture and cannot drift from what is on screen. - pub(crate) fn drag_arrow_bend_to(&mut self, x: i32, y: i32, snap: bool) -> bool { - with_legacy_measurer(|measurer| self.drag_arrow_bend_to_with(measurer, x, y, snap)) - } - pub(crate) fn drag_arrow_bend_to_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/arrow_bend/tests.rs b/src/input/state/core/selection_actions/arrow_bend/tests.rs index 2cff7a0f2..0580a676d 100644 --- a/src/input/state/core/selection_actions/arrow_bend/tests.rs +++ b/src/input/state/core/selection_actions/arrow_bend/tests.rs @@ -97,6 +97,8 @@ fn handle_is_offered_only_for_a_single_unlocked_curved_arrow() { #[test] fn dragging_the_handle_bends_toward_the_pointer() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); let id = add_curved_arrow(&mut state, 0.0); state.set_selection(vec![id]); @@ -116,17 +118,19 @@ fn dragging_the_handle_bends_toward_the_pointer() { // Pointer 80px above the chord midpoint: the arc's midpoint should follow // it, which needs bend = 2 * 80 / 400 = 0.4 on the left-of-travel side. - assert!(state.drag_arrow_bend_to(200, 20, false)); + assert!(state.drag_arrow_bend_to_with(&test_text_measurer, 200, 20, false)); assert!((arrow_bend(&state, id) - 0.4).abs() < 1e-9); // And the other way. A sign flip here means the arrow curves away from // the drag. - assert!(state.drag_arrow_bend_to(200, 180, false)); + assert!(state.drag_arrow_bend_to_with(&test_text_measurer, 200, 180, false)); assert!((arrow_bend(&state, id) + 0.4).abs() < 1e-9); } #[test] fn dragging_along_the_chord_does_not_change_the_bend() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); let id = add_curved_arrow(&mut state, 0.0); state.set_selection(vec![id]); @@ -146,10 +150,10 @@ fn dragging_along_the_chord_does_not_change_the_bend() { // Only the perpendicular component counts, which is what keeps the arc // symmetric however far along it the user grabs. - assert!(state.drag_arrow_bend_to(120, 40, false)); + assert!(state.drag_arrow_bend_to_with(&test_text_measurer, 120, 40, false)); let from_left = arrow_bend(&state, id); assert!( - !state.drag_arrow_bend_to(330, 40, false), + !state.drag_arrow_bend_to_with(&test_text_measurer, 330, 40, false), "sliding along the chord should be a no-op, not a new bend" ); assert!((arrow_bend(&state, id) - from_left).abs() < 1e-9); @@ -157,6 +161,8 @@ fn dragging_along_the_chord_does_not_change_the_bend() { #[test] fn shift_snaps_the_bend_to_tenths() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); let id = add_curved_arrow(&mut state, 0.0); state.set_selection(vec![id]); @@ -175,7 +181,7 @@ fn shift_snaps_the_bend_to_tenths() { }; // 43px off the chord is bend 0.215, which snaps to 0.2. - assert!(state.drag_arrow_bend_to(200, 57, true)); + assert!(state.drag_arrow_bend_to_with(&test_text_measurer, 200, 57, true)); assert!( (arrow_bend(&state, id) - 0.2).abs() < 1e-9, "shift did not snap: got {}", @@ -216,6 +222,13 @@ fn arrow_bend(state: &crate::input::InputState, id: crate::draw::ShapeId) -> f64 #[test] fn restyling_mid_gesture_ends_the_bend_instead_of_stacking_on_it() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + // `CycleArrowStyle` is bindable, so it can land while the bend handle is // still held. Restyling on top of a live gesture pushes an undo entry while // the gesture is still holding a pre-bend snapshot, and the eventual @@ -239,11 +252,11 @@ fn restyling_mid_gesture_ends_the_bend_instead_of_stacking_on_it() { shape_id: id, snapshot: before, }; - assert!(state.drag_arrow_bend_to(200, 20, false)); + assert!(state.drag_arrow_bend_to_with(&test_text_measurer, 200, 20, false)); let bent = arrow_bend(&state, id); assert!(bent.abs() > 0.1, "test setup should have bent the arrow"); - state.handle_action(crate::config::Action::CycleArrowStyle); + state.handle_action_with_resources(test_text_resources, crate::config::Action::CycleArrowStyle); assert!( matches!(state.state, DrawingState::Idle), @@ -255,10 +268,10 @@ fn restyling_mid_gesture_ends_the_bend_instead_of_stacking_on_it() { // Undo the restyle, then the bend, and the arrow is back where it started // with nothing in between that was never drawn. - state.handle_action(crate::config::Action::Undo); + state.handle_action_with_resources(test_text_resources, crate::config::Action::Undo); assert_eq!(arrow_style(&state, id), ArrowStyle::Curved); assert!((arrow_bend(&state, id) - bent).abs() < 1e-9); - state.handle_action(crate::config::Action::Undo); + state.handle_action_with_resources(test_text_resources, crate::config::Action::Undo); assert_eq!(arrow_bend(&state, id), 0.0); assert_eq!(arrow_style(&state, id), ArrowStyle::Curved); } @@ -273,6 +286,12 @@ fn arrow_style(state: &crate::input::InputState, id: crate::draw::ShapeId) -> Ar #[test] fn any_selection_property_change_ends_a_live_bend_first() { let route_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &test_ui_engine, + }; + // The guard sits on `dispatch_selection_property`, not on the arrow-style // action, because the toolbar and the shape properties panel reach the same // mutators by other routes — and because the hazard is not style-specific. @@ -294,7 +313,7 @@ fn any_selection_property_change_ends_a_live_bend_first() { locked: false, }, }; - assert!(state.drag_arrow_bend_to(200, 20, false)); + assert!(state.drag_arrow_bend_to_with(&route_measurer, 200, 20, false)); let bent = arrow_bend(&state, id); state.adjust_selection_property_kind_with( @@ -309,12 +328,19 @@ fn any_selection_property_change_ends_a_live_bend_first() { ); // Undoing the thickness change must leave the bend intact rather than // rolling the arrow back past a gesture that had already been committed. - state.handle_action(crate::config::Action::Undo); + state.handle_action_with_resources(test_text_resources, crate::config::Action::Undo); assert!((arrow_bend(&state, id) - bent).abs() < 1e-9); } #[test] fn a_nudge_key_mid_gesture_ends_the_bend_instead_of_stacking_on_it() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + // Pressed as a real key, not dispatched as an action: a bound key goes // from `keyboard.rs` straight into `route_action` and never touches // `handle_action`, so a preflight hung off the latter would leave the @@ -340,7 +366,7 @@ fn a_nudge_key_mid_gesture_ends_the_bend_instead_of_stacking_on_it() { locked: false, }, }; - assert!(state.drag_arrow_bend_to(200, 20, false)); + assert!(state.drag_arrow_bend_to_with(&test_text_measurer, 200, 20, false)); let bent = arrow_bend(&state, id); assert!(bent.abs() > 0.1, "test setup should have bent the arrow"); @@ -353,14 +379,16 @@ fn a_nudge_key_mid_gesture_ends_the_bend_instead_of_stacking_on_it() { assert_eq!(state.boards.active_frame().undo_stack_len(), 2); // Undo the nudge, then the bend. Neither step may resurrect the other. - state.handle_action(crate::config::Action::Undo); + state.handle_action_with_resources(test_text_resources, crate::config::Action::Undo); assert!((arrow_bend(&state, id) - bent).abs() < 1e-9); - state.handle_action(crate::config::Action::Undo); + state.handle_action_with_resources(test_text_resources, crate::config::Action::Undo); assert_eq!(arrow_bend(&state, id), 0.0); } #[test] fn escape_still_cancels_a_bend_rather_than_committing_it() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + // `route_action` ends a live bend for every action except Exit, whose whole // job is to back out of the gesture. Committing first would leave Escape // with nothing to cancel and quietly keep the arc. Driven through the key @@ -381,7 +409,7 @@ fn escape_still_cancels_a_bend_rather_than_committing_it() { locked: false, }, }; - assert!(state.drag_arrow_bend_to(200, 20, false)); + assert!(state.drag_arrow_bend_to_with(&test_text_measurer, 200, 20, false)); state.on_key_press(crate::input::Key::Escape); @@ -419,6 +447,13 @@ fn an_action_with_no_bend_running_leaves_the_interaction_alone() { #[test] fn a_toolbar_event_mid_gesture_ends_the_bend_before_it_can_lose_the_arrow() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + // Toolbar events never reach `route_action`, and touch, tablet, and the GTK // toolbar all deliver them while a pointer-held gesture is running. Undo All // is the sharp case: it can remove the arrow outright, after which the @@ -439,7 +474,7 @@ fn a_toolbar_event_mid_gesture_ends_the_bend_before_it_can_lose_the_arrow() { locked: false, }, }; - assert!(state.drag_arrow_bend_to(200, 20, false)); + assert!(state.drag_arrow_bend_to_with(&test_text_measurer, 200, 20, false)); let bent = arrow_bend(&state, id); state.apply_toolbar_event(crate::ui::toolbar::ToolbarEvent::UndoAll); @@ -450,7 +485,7 @@ fn a_toolbar_event_mid_gesture_ends_the_bend_before_it_can_lose_the_arrow() { ); // The bend was recorded before Undo All ran, so it is on the stack to be // undone rather than lost with the shape. - state.handle_action(crate::config::Action::RedoAll); + state.handle_action_with_resources(test_text_resources, crate::config::Action::RedoAll); assert!( (arrow_bend(&state, id) - bent).abs() < 1e-9, "the bend was dropped instead of committed before the toolbar event" diff --git a/src/input/state/core/selection_actions/delete.rs b/src/input/state/core/selection_actions/delete.rs index e8d09da17..ce3a55de6 100644 --- a/src/input/state/core/selection_actions/delete.rs +++ b/src/input/state/core/selection_actions/delete.rs @@ -1,7 +1,7 @@ use super::super::base::InputState; use crate::draw::ShapeId; +use crate::draw::TextMeasurer; use crate::draw::frame::UndoAction; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use std::borrow::Cow; use std::collections::HashSet; @@ -82,10 +82,6 @@ impl InputState { true } - pub(crate) fn erase_strokes_by_points(&mut self, points: &[(i32, i32)]) -> bool { - with_legacy_measurer(|measurer| self.erase_strokes_by_points_with(measurer, points)) - } - pub(crate) fn erase_strokes_by_points_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/geometry.rs b/src/input/state/core/selection_actions/geometry.rs index c95e5c552..a283d8732 100644 --- a/src/input/state/core/selection_actions/geometry.rs +++ b/src/input/state/core/selection_actions/geometry.rs @@ -1,6 +1,6 @@ use super::super::base::InputState; use crate::draw::ShapeId; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::TextMeasurer; use crate::util::Rect; fn selection_rect(start_x: i32, start_y: i32, end_x: i32, end_y: i32) -> Option { @@ -25,10 +25,6 @@ impl InputState { selection_rect(start_x, start_y, end_x, end_y) } - pub(crate) fn shape_ids_in_rect(&self, rect: Rect) -> Vec { - with_legacy_measurer(|measurer| self.shape_ids_in_rect_with(measurer, rect)) - } - pub(crate) fn shape_ids_in_rect_with( &self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/handles.rs b/src/input/state/core/selection_actions/handles.rs index f2852e65e..996a10b54 100644 --- a/src/input/state/core/selection_actions/handles.rs +++ b/src/input/state/core/selection_actions/handles.rs @@ -37,17 +37,22 @@ impl InputState { /// their pixels and letting the selection box swallow them would make them /// unusable on exactly the shapes that need them most — a shallow arc, a /// loupe with the Spotlight tool still active. - pub(crate) fn hit_idle_handle(&self, x: i32, y: i32) -> Option { - if let Some(control) = self.hit_spotlight_magnification_track(x, y) { + pub(crate) fn hit_idle_handle_with( + &self, + measurer: &crate::draw::TextMeasurer, + x: i32, + y: i32, + ) -> Option { + if let Some(control) = self.hit_spotlight_magnification_track_with(measurer, x, y) { return Some(IdleHandle::SpotlightMagnification(control.shape_id)); } if let Some(handle) = self.hit_arrow_bend_handle(x, y) { return Some(IdleHandle::ArrowBend(handle.shape_id)); } - if let Some(shape_id) = self.hit_text_resize_handle(x, y) { + if let Some(shape_id) = self.hit_text_resize_handle_with(measurer, x, y) { return Some(IdleHandle::TextResize(shape_id)); } - self.hit_selection_handle(x, y) + self.hit_selection_handle_with(measurer, x, y) .map(IdleHandle::SelectionResize) } diff --git a/src/input/state/core/selection_actions/handles/tests.rs b/src/input/state/core/selection_actions/handles/tests.rs index 3487c944c..8514845da 100644 --- a/src/input/state/core/selection_actions/handles/tests.rs +++ b/src/input/state/core/selection_actions/handles/tests.rs @@ -32,6 +32,7 @@ fn a_shallow_bend_grip_outranks_the_selection_edge_handle_it_overlaps() { // This is the collision the ordering exists for. At a shallow bend the grip // sits a couple of pixels off the chord, well inside the top edge handle's // tolerance, and whichever probe runs first wins the pixel. + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); let id = add_shallow_curved_arrow(&mut state); state.set_selection(vec![id]); @@ -45,11 +46,13 @@ fn a_shallow_bend_grip_outranks_the_selection_edge_handle_it_overlaps() { ); assert!( - state.hit_selection_handle(center.0, center.1).is_some(), + state + .hit_selection_handle_with(&measurer, center.0, center.1) + .is_some(), "test setup should have put the grip inside an edge handle" ); assert_eq!( - state.hit_idle_handle(center.0, center.1), + state.hit_idle_handle_with(&measurer, center.0, center.1), Some(IdleHandle::ArrowBend(id)), "the edge handle swallowed the bend grip" ); @@ -62,6 +65,7 @@ fn what_the_routing_reports_is_what_a_press_starts() { // keep matching the variants. This is what notices if one drifts, and it is // the bug the shared routing replaced: the pointer showed a resize arrow // over a grip that a click would bend. + let measurer = crate::draw::TextMeasurer::default(); let mut state = make_test_input_state(); let id = add_shallow_curved_arrow(&mut state); state.set_selection(vec![id]); @@ -72,11 +76,23 @@ fn what_the_routing_reports_is_what_a_press_starts() { grip.rect.y + grip.rect.height / 2, ); assert_eq!( - state.hit_idle_handle(center.0, center.1), + state.hit_idle_handle_with(&measurer, center.0, center.1), Some(IdleHandle::ArrowBend(id)) ); - state.on_mouse_press(crate::input::events::MouseButton::Left, center.0, center.1); + let ui_engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + state.on_mouse_press_with_canvas_and_resources( + resources, + crate::input::events::MouseButton::Left, + center.0, + center.1, + center.0, + center.1, + ); assert!( matches!(state.state, DrawingState::BendingArrow { .. }), "routing promised a bend but the press started {:?}", @@ -86,6 +102,7 @@ fn what_the_routing_reports_is_what_a_press_starts() { #[test] fn empty_canvas_routes_to_no_handle() { + let measurer = crate::draw::TextMeasurer::default(); let state = make_test_input_state(); - assert_eq!(state.hit_idle_handle(50, 50), None); + assert_eq!(state.hit_idle_handle_with(&measurer, 50, 50), None); } diff --git a/src/input/state/core/selection_actions/resize.rs b/src/input/state/core/selection_actions/resize.rs index bf96b8daa..4e5217359 100644 --- a/src/input/state/core/selection_actions/resize.rs +++ b/src/input/state/core/selection_actions/resize.rs @@ -59,19 +59,6 @@ impl InputState { } /// Apply resize transformation to all selected shapes. - pub(crate) fn apply_selection_resize( - &mut self, - handle: SelectionHandle, - original_bounds: &Rect, - dx: i32, - dy: i32, - snapshots: &[(ShapeId, ShapeSnapshot)], - ) { - with_legacy_measurer(|measurer| { - self.apply_selection_resize_with(measurer, handle, original_bounds, dx, dy, snapshots) - }) - } - pub(crate) fn apply_selection_resize_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/spotlight.rs b/src/input/state/core/selection_actions/spotlight.rs index 3e32781ba..811ae903c 100644 --- a/src/input/state/core/selection_actions/spotlight.rs +++ b/src/input/state/core/selection_actions/spotlight.rs @@ -192,10 +192,6 @@ impl InputState { /// The track is recomputed rather than frozen at press: it hangs off the /// loupe's bounding box, which magnification does not move, so the mapping /// is stable for the whole gesture. - pub(crate) fn drag_spotlight_magnification_to(&mut self, x: i32) -> bool { - with_legacy_measurer(|measurer| self.drag_spotlight_magnification_to_with(measurer, x)) - } - pub(crate) fn drag_spotlight_magnification_to_with( &mut self, measurer: &TextMeasurer, @@ -221,14 +217,6 @@ impl InputState { /// Whether the pointer is on the magnification control, and which loupe it /// belongs to. - pub(crate) fn hit_spotlight_magnification_track( - &self, - x: i32, - y: i32, - ) -> Option { - with_legacy_measurer(|measurer| self.hit_spotlight_magnification_track_with(measurer, x, y)) - } - pub(crate) fn hit_spotlight_magnification_track_with( &self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/text/edit.rs b/src/input/state/core/selection_actions/text/edit.rs index 2121da22c..7df610503 100644 --- a/src/input/state/core/selection_actions/text/edit.rs +++ b/src/input/state/core/selection_actions/text/edit.rs @@ -1,12 +1,8 @@ -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::TextMeasurer; use crate::input::{DrawingState, InputState}; use std::time::Instant; impl InputState { - pub(crate) fn edit_selected_text(&mut self) -> bool { - with_legacy_measurer(|measurer| self.edit_selected_text_with(measurer)) - } - pub(crate) fn edit_selected_text_with(&mut self, measurer: &TextMeasurer) -> bool { if self.selected_shape_ids().len() != 1 { return false; diff --git a/src/input/state/core/selection_actions/text/handles.rs b/src/input/state/core/selection_actions/text/handles.rs index ea6a10573..c2dd08ae2 100644 --- a/src/input/state/core/selection_actions/text/handles.rs +++ b/src/input/state/core/selection_actions/text/handles.rs @@ -40,10 +40,6 @@ impl InputState { Some((shape_id, handle)) } - pub(crate) fn hit_text_resize_handle(&self, x: i32, y: i32) -> Option { - with_legacy_measurer(|measurer| self.hit_text_resize_handle_with(measurer, x, y)) - } - pub(crate) fn hit_text_resize_handle_with( &self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/text/wrap.rs b/src/input/state/core/selection_actions/text/wrap.rs index 34ceb3c32..0dc68faeb 100644 --- a/src/input/state/core/selection_actions/text/wrap.rs +++ b/src/input/state/core/selection_actions/text/wrap.rs @@ -1,5 +1,5 @@ +use crate::draw::TextMeasurer; use crate::draw::{Shape, ShapeId}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; const TEXT_WRAP_MIN_WIDTH: i32 = 40; @@ -21,12 +21,6 @@ impl InputState { width } - pub(crate) fn update_text_wrap_width(&mut self, shape_id: ShapeId, new_width: i32) -> bool { - with_legacy_measurer(|measurer| { - self.update_text_wrap_width_with(measurer, shape_id, new_width) - }) - } - pub(crate) fn update_text_wrap_width_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/translation/mod.rs b/src/input/state/core/selection_actions/translation/mod.rs index 7a3719915..340ac2999 100644 --- a/src/input/state/core/selection_actions/translation/mod.rs +++ b/src/input/state/core/selection_actions/translation/mod.rs @@ -1,6 +1,6 @@ use crate::draw::ShapeId; +use crate::draw::TextMeasurer; use crate::draw::frame::ShapeSnapshot; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; mod bounds; @@ -30,10 +30,6 @@ impl InputState { .collect() } - pub(crate) fn apply_translation_to_selection(&mut self, dx: i32, dy: i32) -> bool { - with_legacy_measurer(|measurer| self.apply_translation_to_selection_with(measurer, dx, dy)) - } - pub(crate) fn apply_translation_to_selection_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/session.rs b/src/input/state/core/session.rs index 5612c559c..a9f8827a8 100644 --- a/src/input/state/core/session.rs +++ b/src/input/state/core/session.rs @@ -341,6 +341,8 @@ mod tests { #[test] fn persistence_snapshot_keeps_original_text_during_in_place_edit() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + use crate::session::SessionOptions; use std::path::PathBuf; @@ -359,7 +361,7 @@ mod tests { wrap_width: Some(180), }); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&test_text_measurer)); let DrawingState::TextInput { buffer, .. } = &mut state.state else { panic!("expected text input"); }; diff --git a/src/input/state/core/status_hud.rs b/src/input/state/core/status_hud.rs index 64eacb219..4b6a6d83a 100644 --- a/src/input/state/core/status_hud.rs +++ b/src/input/state/core/status_hud.rs @@ -298,7 +298,12 @@ impl InputState { /// picker popup, radial menu at the pointer) and/or returns the action /// for the backend to dispatch (help, toolbar restore). Returns /// `(hit, action)` mirroring toast release resolver. - pub(crate) fn check_status_hud_click(&mut self, x: i32, y: i32) -> (bool, Option) { + pub(crate) fn check_status_hud_click_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + x: i32, + y: i32, + ) -> (bool, Option) { // `status_hud_contains` also applies the open-overlay guard, so a // release cannot activate a chip when an overlay opened between the // press and the release. @@ -316,7 +321,7 @@ impl InputState { }; match kind { StatusHudSegmentKind::Board => { - self.toggle_board_picker(); + self.toggle_board_picker_with_measurer(measurer); (true, None) } StatusHudSegmentKind::Page => { @@ -324,13 +329,13 @@ impl InputState { // the Page chip is distinguishable from the Board chip (the // setter also scrolls the panel to the active page). if !self.is_board_picker_open() { - self.open_board_picker(); + self.open_board_picker_with_measurer(measurer); } self.board_picker_set_focus(BoardPickerFocus::PagePanel); (true, None) } StatusHudSegmentKind::Color => { - self.open_color_picker_popup(); + self.open_color_picker_popup_with_measurer(measurer); (true, None) } StatusHudSegmentKind::Tool | StatusHudSegmentKind::Size => { diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index 45631a279..133b8f5eb 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -85,13 +85,6 @@ impl InputState { clamped } - #[cfg(feature = "tablet-input")] - pub(crate) fn replace_active_drawing_pressure_samples(&mut self, thickness: f64) -> bool { - with_legacy_measurer(|measurer| { - self.replace_active_drawing_pressure_samples_with(measurer, thickness) - }) - } - #[cfg(feature = "tablet-input")] pub(crate) fn replace_active_drawing_pressure_samples_with( &mut self, diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs index ab85e198d..73f00330d 100644 --- a/src/input/state/core/tour.rs +++ b/src/input/state/core/tour.rs @@ -464,8 +464,15 @@ mod tests { #[test] fn starting_tour_exits_focus_mode_before_tour_owns_chrome() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = make_test_input_state(); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); assert!(!state.ui_visibility.show_status_bar); diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index 20694ee4f..52a493260 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -169,10 +169,6 @@ impl InputState { /// Cancels the current interaction when one is active. /// /// Returns `true` when an active interaction consumed the caller's event. - pub(crate) fn try_cancel_active_interaction(&mut self) -> bool { - with_legacy_measurer(|measurer| self.try_cancel_active_interaction_with(measurer)) - } - pub(crate) fn try_cancel_active_interaction_with(&mut self, measurer: &TextMeasurer) -> bool { if matches!(self.state, DrawingState::Idle) { return false; @@ -365,10 +361,12 @@ mod tests { #[test] fn try_cancel_active_interaction_reports_false_when_idle() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); state.needs_redraw = false; - assert!(!state.try_cancel_active_interaction()); + assert!(!state.try_cancel_active_interaction_with(&test_text_measurer)); assert!(matches!(state.state, DrawingState::Idle)); assert!(!state.needs_redraw); @@ -376,6 +374,8 @@ mod tests { #[test] fn try_cancel_active_interaction_cancels_drawing_and_ends_drag() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); state.state = DrawingState::Drawing { tool: Tool::Pen, @@ -387,7 +387,7 @@ mod tests { state.begin_pointer_drag(MouseButton::Left, None); state.needs_redraw = false; - assert!(state.try_cancel_active_interaction()); + assert!(state.try_cancel_active_interaction_with(&test_text_measurer)); assert!(matches!(state.state, DrawingState::Idle)); assert!(!state.pointer_drag_active()); diff --git a/src/input/state/interaction/adapters/active_motion.rs b/src/input/state/interaction/adapters/active_motion.rs index f055589d5..52e88e95d 100644 --- a/src/input/state/interaction/adapters/active_motion.rs +++ b/src/input/state/interaction/adapters/active_motion.rs @@ -10,13 +10,14 @@ use std::sync::Arc; pub(crate) fn handle_active_motion( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, points: PointerPoints, ) -> Option { let canvas = points.canvas(); // An Alt+drag moves the active text block; keep TextInput draggable while // the flag is set (it is otherwise a passive, non-draggable state). if state.text_block_drag_active() && matches!(state.state, DrawingState::TextInput { .. }) { - state.drag_text_block_to(canvas.x(), canvas.y()); + state.drag_text_block_to_with(measurer, canvas.x(), canvas.y()); return Some(RoutingOutcome::Continued(ActiveInteractionKind::TextInput)); } @@ -24,7 +25,7 @@ pub(crate) fn handle_active_motion( state.state, DrawingState::AdjustingSpotlightMagnification { .. } ) { - state.drag_spotlight_magnification_to(canvas.x()); + state.drag_spotlight_magnification_to_with(measurer, canvas.x()); return Some(RoutingOutcome::Continued( ActiveInteractionKind::AdjustingSpotlightMagnification, )); @@ -33,7 +34,7 @@ pub(crate) fn handle_active_motion( if matches!(state.state, DrawingState::BendingArrow { .. }) { // Shift snaps the magnitude; the arc is symmetric either way. let snap = state.modifiers.shift; - state.drag_arrow_bend_to(canvas.x(), canvas.y(), snap); + state.drag_arrow_bend_to_with(measurer, canvas.x(), canvas.y(), snap); return Some(RoutingOutcome::Continued( ActiveInteractionKind::BendingArrow, )); @@ -47,7 +48,7 @@ pub(crate) fn handle_active_motion( } = &state.state { let new_width = state.clamp_text_wrap_width(*base_x, canvas.x(), *size); - let _ = state.update_text_wrap_width(*shape_id, new_width); + let _ = state.update_text_wrap_width_with(measurer, *shape_id, new_width); return Some(RoutingOutcome::Continued( ActiveInteractionKind::ResizingText, )); @@ -84,7 +85,7 @@ pub(crate) fn handle_active_motion( }; state.text_editing.set_last_click(None); state.pointer.replace_provisional_bounds(None); - state.update_provisional_dirty(canvas.x(), canvas.y()); + state.update_provisional_dirty_with(measurer, canvas.x(), canvas.y()); state.needs_redraw = true; } } @@ -97,7 +98,7 @@ pub(crate) fn handle_active_motion( let dx = canvas.x() - *last_x; let dy = canvas.y() - *last_y; if (dx != 0 || dy != 0) - && state.apply_translation_to_selection(dx, dy) + && state.apply_translation_to_selection_with(measurer, dx, dy) && let DrawingState::MovingSelection { last_x, last_y, @@ -127,7 +128,14 @@ pub(crate) fn handle_active_motion( let handle = *handle; let original_bounds = *original_bounds; let snapshots = Arc::clone(snapshots); - state.apply_selection_resize(handle, &original_bounds, dx, dy, snapshots.as_ref()); + state.apply_selection_resize_with( + measurer, + handle, + &original_bounds, + dx, + dy, + snapshots.as_ref(), + ); state.needs_redraw = true; return Some(RoutingOutcome::Continued( ActiveInteractionKind::ResizingSelection, @@ -135,7 +143,7 @@ pub(crate) fn handle_active_motion( } if matches!(state.state, DrawingState::Selecting { .. }) { - state.update_provisional_dirty(canvas.x(), canvas.y()); + state.update_provisional_dirty_with(measurer, canvas.x(), canvas.y()); state.needs_redraw = true; return Some(RoutingOutcome::Continued( ActiveInteractionKind::BoxSelecting, @@ -144,7 +152,7 @@ pub(crate) fn handle_active_motion( if let DrawingState::BuildingPolygon { preview, .. } = &mut state.state { *preview = Some((canvas.x(), canvas.y())); - state.update_provisional_dirty(canvas.x(), canvas.y()); + state.update_provisional_dirty_with(measurer, canvas.x(), canvas.y()); state.needs_redraw = true; return Some(RoutingOutcome::Continued( ActiveInteractionKind::BuildingPolygon, @@ -156,6 +164,7 @@ pub(crate) fn handle_active_motion( pub(crate) fn handle_drawing_or_idle_motion( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, points: PointerPoints, ) -> RoutingOutcome { let canvas = points.canvas(); @@ -179,7 +188,7 @@ pub(crate) fn handle_drawing_or_idle_motion( } if drawing { - state.update_provisional_dirty(canvas.x(), canvas.y()); + state.update_provisional_dirty_with(measurer, canvas.x(), canvas.y()); state.needs_redraw = true; RoutingOutcome::Continued(ActiveInteractionKind::Drawing) } else if state.style.eraser_mode == EraserMode::Stroke diff --git a/src/input/state/interaction/adapters/pointer.rs b/src/input/state/interaction/adapters/pointer.rs index 668946029..24c9385da 100644 --- a/src/input/state/interaction/adapters/pointer.rs +++ b/src/input/state/interaction/adapters/pointer.rs @@ -17,18 +17,27 @@ pub(crate) fn update_pointer_positions(state: &mut InputState, points: PointerPo pub(crate) fn handle_radial_menu_press( state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, button: MouseButton, points: PointerPoints, ) -> Option { let screen = points.screen(); let canvas = points.canvas(); state - .handle_radial_menu_press(button, screen.x(), screen.y(), canvas.x(), canvas.y()) + .handle_radial_menu_press_with_resources( + resources, + button, + screen.x(), + screen.y(), + canvas.x(), + canvas.y(), + ) .then_some(RoutingOutcome::Consumed(ConsumedBy::RadialMenu)) } pub(crate) fn handle_building_polygon_non_left_press( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, button: MouseButton, points: PointerPoints, ) -> Option { @@ -41,7 +50,7 @@ pub(crate) fn handle_building_polygon_non_left_press( state.update_pointer_positions(screen.x(), screen.y(), canvas.x(), canvas.y()); match button { MouseButton::Right => { - state.cancel_active_interaction(); + state.cancel_active_interaction_with(measurer); Some(RoutingOutcome::Canceled(CancelTarget::ActiveInteraction( ActiveInteractionKind::BuildingPolygon, ))) @@ -154,6 +163,7 @@ pub(crate) fn close_properties_panel_before_tool_routing(state: &mut InputState) pub(crate) fn handle_tool_button_press( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, button: MouseButton, points: PointerPoints, ) -> Option { @@ -169,7 +179,8 @@ pub(crate) fn handle_tool_button_press( let before = active_interaction_kind(state); let screen = points.screen(); let canvas = points.canvas(); - state.handle_tool_button_press_at( + state.handle_tool_button_press_at_with_measurer( + measurer, button, tool, binding.color, @@ -188,6 +199,7 @@ pub(crate) fn handle_tool_button_press( pub(crate) fn handle_unbound_left_press( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, points: PointerPoints, ) -> RoutingOutcome { let screen = points.screen(); @@ -199,7 +211,7 @@ pub(crate) fn handle_unbound_left_press( return RoutingOutcome::Consumed(ConsumedBy::ContextMenu); } - if state.handle_text_input_left_press(canvas.x(), canvas.y(), None) { + if state.handle_text_input_left_press_with(measurer, canvas.x(), canvas.y(), None) { return if state.text_block_drag_active() { RoutingOutcome::Started(ActiveInteractionKind::TextInput) } else { @@ -211,7 +223,11 @@ pub(crate) fn handle_unbound_left_press( DrawingState::Idle => RoutingOutcome::NoRoute(NoRouteReason::NoPointerBinding), DrawingState::TextInput { .. } => RoutingOutcome::Consumed(ConsumedBy::TextInput), DrawingState::BuildingPolygon { .. } => { - state.handle_building_polygon_left_click(canvas.x(), canvas.y()); + state.handle_building_polygon_left_click_with_measurer( + measurer, + canvas.x(), + canvas.y(), + ); RoutingOutcome::Continued(ActiveInteractionKind::BuildingPolygon) } DrawingState::Drawing { .. } @@ -227,7 +243,11 @@ pub(crate) fn handle_unbound_left_press( } } -pub(crate) fn handle_right_press(state: &mut InputState, points: PointerPoints) -> RoutingOutcome { +pub(crate) fn handle_right_press( + state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + points: PointerPoints, +) -> RoutingOutcome { let screen = points.screen(); let canvas = points.canvas(); if state.should_toggle_radial_menu_from_mouse(MouseButton::Right) { @@ -238,7 +258,7 @@ pub(crate) fn handle_right_press(state: &mut InputState, points: PointerPoints) state.update_pointer_positions(screen.x(), screen.y(), canvas.x(), canvas.y()); state.text_editing.set_last_click(None); if let Some(kind) = active_interaction_kind(state) - && state.try_cancel_active_interaction() + && state.try_cancel_active_interaction_with(measurer) { return RoutingOutcome::Canceled(CancelTarget::ActiveInteraction(kind)); } @@ -253,7 +273,14 @@ pub(crate) fn handle_right_press(state: &mut InputState, points: PointerPoints) )); } - open_context_menu_from_right_click(state, screen.x(), screen.y(), canvas.x(), canvas.y()); + open_context_menu_from_right_click( + state, + measurer, + screen.x(), + screen.y(), + canvas.x(), + canvas.y(), + ); RoutingOutcome::Consumed(ConsumedBy::RightClickContextMenu) } @@ -269,12 +296,13 @@ pub(crate) fn handle_middle_press(state: &mut InputState, points: PointerPoints) fn open_context_menu_from_right_click( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, screen_x: i32, screen_y: i32, canvas_x: i32, canvas_y: i32, ) { - let hit_shape = state.hit_test_at(canvas_x, canvas_y); + let hit_shape = state.hit_test_at_with(measurer, canvas_x, canvas_y); let mut focus_edit = false; if let Some(id) = hit_shape { if state.modifiers.shift { @@ -318,6 +346,7 @@ fn open_context_menu_from_right_click( pub(crate) fn handle_radial_menu_motion( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, points: PointerPoints, ) -> Option { if !state.is_radial_menu_open() { @@ -329,7 +358,7 @@ pub(crate) fn handle_radial_menu_motion( if state.radial_menu_is_size_dragging() { // Drag capture: while the size gauge is held, every motion adjusts // thickness, even outside the band. - state.radial_menu_drag_size_to(x, y); + state.radial_menu_drag_size_to_with_measurer(measurer, x, y); } else { state.update_radial_menu_hover(x, y); state.radial_menu_sample_flick(x, y); @@ -372,6 +401,7 @@ pub(crate) fn handle_font_picker_motion( pub(crate) fn handle_font_picker_press( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, button: MouseButton, points: PointerPoints, ) -> Option { @@ -385,7 +415,7 @@ pub(crate) fn handle_font_picker_press( return Some(RoutingOutcome::Consumed(ConsumedBy::FontPicker)); } let screen = points.screen(); - state.font_picker_press(f64::from(screen.x()), f64::from(screen.y())); + state.font_picker_press_with_measurer(measurer, f64::from(screen.x()), f64::from(screen.y())); Some(RoutingOutcome::Consumed(ConsumedBy::FontPicker)) } @@ -435,6 +465,7 @@ pub(crate) fn handle_context_menu_motion( pub(crate) fn handle_release_overlays( state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, button: MouseButton, points: PointerPoints, ) -> Option { @@ -445,13 +476,17 @@ pub(crate) fn handle_release_overlays( if state.handle_color_picker_popup_release_at(screen.x(), screen.y()) { return Some(RoutingOutcome::Consumed(ConsumedBy::ColorPickerPopup)); } - if state.handle_context_menu_release_at(screen.x(), screen.y()) { + if state.handle_context_menu_release_at_with_resources(resources, screen.x(), screen.y()) { return Some(RoutingOutcome::Consumed(ConsumedBy::ContextMenu)); } - if state.handle_board_picker_release_at(screen.x(), screen.y()) { + if state.handle_board_picker_release_at_with_resources(resources, screen.x(), screen.y()) { return Some(RoutingOutcome::Consumed(ConsumedBy::BoardPicker)); } - if state.handle_properties_panel_release_at(screen.x(), screen.y()) { + if state.handle_properties_panel_release_at_with_measurer( + resources.measurer, + screen.x(), + screen.y(), + ) { return Some(RoutingOutcome::Consumed(ConsumedBy::PropertiesPanel)); } None @@ -459,16 +494,26 @@ pub(crate) fn handle_release_overlays( pub(crate) fn handle_radial_menu_release( state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, button: MouseButton, points: PointerPoints, ) -> Option { let screen = points.screen(); state - .radial_menu_handle_release(button, screen.x() as f64, screen.y() as f64) + .radial_menu_handle_release_with_resources( + resources, + button, + screen.x() as f64, + screen.y() as f64, + ) .then_some(RoutingOutcome::Consumed(ConsumedBy::RadialMenu)) } -pub(crate) fn finish_pointer_interaction(state: &mut InputState, points: PointerPoints) { +pub(crate) fn finish_pointer_interaction( + state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + points: PointerPoints, +) { let canvas = points.canvas(); - state.finish_pointer_interaction_at(canvas.x(), canvas.y()); + state.finish_pointer_interaction_at_with_measurer(measurer, canvas.x(), canvas.y()); } diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index 89dff4833..ebc9dab23 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -163,6 +163,13 @@ mod tests { #[test] fn right_click_cancels_active_interaction_before_context_menu_policy() { + let pointer_measurer = crate::draw::TextMeasurer::default(); + let pointer_ui_engine = crate::ui_text::UiTextEngine::default(); + let pointer_resources = crate::input::state::InputTextResources { + measurer: &pointer_measurer, + ui_engine: &pointer_ui_engine, + }; + let mut state = make_test_input_state(); state.state = crate::input::state::DrawingState::Drawing { tool: Tool::Pen, @@ -174,7 +181,11 @@ mod tests { state.begin_pointer_drag(MouseButton::Left, None); assert_eq!( - route_pointer_press(&mut state, PointerPress::new(MouseButton::Right, points())), + route_pointer_press( + &mut state, + pointer_resources, + PointerPress::new(MouseButton::Right, points()) + ), RoutingOutcome::Canceled(CancelTarget::ActiveInteraction( ActiveInteractionKind::Drawing )) @@ -188,10 +199,21 @@ mod tests { #[test] fn right_click_suppression_paths_return_named_side_effects() { + let pointer_measurer = crate::draw::TextMeasurer::default(); + let pointer_ui_engine = crate::ui_text::UiTextEngine::default(); + let pointer_resources = crate::input::state::InputTextResources { + measurer: &pointer_measurer, + ui_engine: &pointer_ui_engine, + }; + let mut zoomed = make_test_input_state(); zoomed.set_zoom_status(true, false, 2.0, (0.0, 0.0)); assert_eq!( - route_pointer_press(&mut zoomed, PointerPress::new(MouseButton::Right, points())), + route_pointer_press( + &mut zoomed, + pointer_resources, + PointerPress::new(MouseButton::Right, points()) + ), RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( PointerSideEffect::RightClickSuppressedByZoom )) @@ -202,6 +224,7 @@ mod tests { assert_eq!( route_pointer_press( &mut disabled, + pointer_resources, PointerPress::new(MouseButton::Right, points()) ), RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( @@ -212,11 +235,22 @@ mod tests { #[test] fn radial_menu_release_is_consumed() { + let pointer_measurer = crate::draw::TextMeasurer::default(); + let pointer_ui_engine = crate::ui_text::UiTextEngine::default(); + let pointer_resources = crate::input::state::InputTextResources { + measurer: &pointer_measurer, + ui_engine: &pointer_ui_engine, + }; + let mut state = make_test_input_state(); state.toggle_radial_menu(10.0, 20.0); assert_eq!( - route_pointer_release(&mut state, PointerRelease::new(MouseButton::Left, points())), + route_pointer_release( + &mut state, + pointer_resources, + PointerRelease::new(MouseButton::Left, points()) + ), RoutingOutcome::Consumed(ConsumedBy::RadialMenu) ); assert!(state.is_radial_menu_open()); @@ -224,12 +258,14 @@ mod tests { #[test] fn idle_eraser_hover_returns_named_pointer_side_effect() { + let pointer_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_test_input_state(); state.style.eraser_mode = EraserMode::Stroke; assert!(state.set_tool_override(Some(Tool::Eraser))); assert_eq!( - route_pointer_motion(&mut state, PointerMotion::new(points())), + route_pointer_motion(&mut state, &pointer_measurer, PointerMotion::new(points())), RoutingOutcome::SideEffect(InteractionSideEffect::Pointer( PointerSideEffect::IdleEraserHover )) diff --git a/src/input/state/interaction/pointer.rs b/src/input/state/interaction/pointer.rs index eefbe7072..930d77e08 100644 --- a/src/input/state/interaction/pointer.rs +++ b/src/input/state/interaction/pointer.rs @@ -5,25 +5,36 @@ use crate::input::MouseButton; use crate::input::state::InputState; use crate::ui::ZoomChipPress; -pub(crate) fn route_pointer_press(state: &mut InputState, event: PointerPress) -> RoutingOutcome { +pub(crate) fn route_pointer_press( + state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, + event: PointerPress, +) -> RoutingOutcome { let points = event.points(); // A new press invalidates any HUD or zoom-chip press still awaiting its // release, so a stale flag can never swallow the release of an unrelated // interaction. state.clear_status_hud_press_pending(); state.clear_zoom_chip_press_pending(); - if let Some(outcome) = - adapters::handle_building_polygon_non_left_press(state, event.button(), points) - { + if let Some(outcome) = adapters::handle_building_polygon_non_left_press( + state, + resources.measurer, + event.button(), + points, + ) { return outcome; } - if let Some(outcome) = adapters::handle_radial_menu_press(state, event.button(), points) { + if let Some(outcome) = + adapters::handle_radial_menu_press(state, resources, event.button(), points) + { return outcome; } // The precise-entry popup is keyboard-only: any overlay press cancels // it and the press then routes normally. let _ = state.cancel_precision_entry(); - if let Some(outcome) = adapters::handle_font_picker_press(state, event.button(), points) { + if let Some(outcome) = + adapters::handle_font_picker_press(state, resources.measurer, event.button(), points) + { return outcome; } if let Some(outcome) = adapters::handle_color_picker_press(state, event.button(), points) { @@ -51,18 +62,24 @@ pub(crate) fn route_pointer_press(state: &mut InputState, event: PointerPress) - adapters::close_properties_panel_before_tool_routing(state); - if let Some(outcome) = adapters::handle_tool_button_press(state, event.button(), points) { + if let Some(outcome) = + adapters::handle_tool_button_press(state, resources.measurer, event.button(), points) + { return outcome; } match event.button() { - MouseButton::Right => adapters::handle_right_press(state, points), - MouseButton::Left => adapters::handle_unbound_left_press(state, points), + MouseButton::Right => adapters::handle_right_press(state, resources.measurer, points), + MouseButton::Left => adapters::handle_unbound_left_press(state, resources.measurer, points), MouseButton::Middle => adapters::handle_middle_press(state, points), } } -pub(crate) fn route_pointer_motion(state: &mut InputState, event: PointerMotion) -> RoutingOutcome { +pub(crate) fn route_pointer_motion( + state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + event: PointerMotion, +) -> RoutingOutcome { let points = event.points(); adapters::update_pointer_positions(state, points); // Chrome hover affordances update on every motion, before any modal @@ -74,7 +91,7 @@ pub(crate) fn route_pointer_motion(state: &mut InputState, event: PointerMotion) state.update_status_hud_hover_from_pointer(screen.x(), screen.y()); state.update_zoom_chip_hover_from_pointer(screen.x(), screen.y()); } - if let Some(outcome) = adapters::handle_radial_menu_motion(state, points) { + if let Some(outcome) = adapters::handle_radial_menu_motion(state, measurer, points) { return outcome; } if let Some(outcome) = adapters::handle_font_picker_motion(state, points) { @@ -89,17 +106,18 @@ pub(crate) fn route_pointer_motion(state: &mut InputState, event: PointerMotion) if let Some(outcome) = adapters::handle_properties_panel_motion(state, points) { return outcome; } - if let Some(outcome) = adapters::handle_active_motion(state, points) { + if let Some(outcome) = adapters::handle_active_motion(state, measurer, points) { return outcome; } if let Some(outcome) = adapters::handle_context_menu_motion(state, points) { return outcome; } - adapters::handle_drawing_or_idle_motion(state, points) + adapters::handle_drawing_or_idle_motion(state, measurer, points) } pub(crate) fn route_pointer_release( state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, event: PointerRelease, ) -> RoutingOutcome { let points = event.points(); @@ -114,9 +132,10 @@ pub(crate) fn route_pointer_release( // backend uses via `dispatch_input_action`. if event.button() == MouseButton::Left && state.take_status_hud_press_pending() { let screen = points.screen(); - let (_, action) = state.check_status_hud_click(screen.x(), screen.y()); + let (_, action) = + state.check_status_hud_click_with_measurer(resources.measurer, screen.x(), screen.y()); if let Some(action) = action { - state.handle_action(action); + state.handle_action_with_resources(resources, action); } state.needs_redraw = true; return RoutingOutcome::Consumed(ConsumedBy::StatusHud); @@ -137,7 +156,7 @@ pub(crate) fn route_pointer_release( let screen = points.screen(); let (_, action) = state.check_zoom_chip_click(kind, screen.x(), screen.y()); if let Some(action) = action { - state.handle_action(action); + state.handle_action_with_resources(resources, action); } } state.needs_redraw = true; @@ -145,11 +164,15 @@ pub(crate) fn route_pointer_release( } } - if let Some(outcome) = adapters::handle_radial_menu_release(state, event.button(), points) { + if let Some(outcome) = + adapters::handle_radial_menu_release(state, resources, event.button(), points) + { return outcome; } - if let Some(outcome) = adapters::handle_release_overlays(state, event.button(), points) { + if let Some(outcome) = + adapters::handle_release_overlays(state, resources, event.button(), points) + { return outcome; } @@ -165,6 +188,6 @@ pub(crate) fn route_pointer_release( return RoutingOutcome::NoRoute(NoRouteReason::ReleaseButtonMismatch); } - adapters::finish_pointer_interaction(state, points); + adapters::finish_pointer_interaction(state, resources.measurer, points); RoutingOutcome::Finished(kind) } diff --git a/src/input/state/mouse/motion.rs b/src/input/state/mouse/motion.rs index ca2a0586a..44db3f12a 100644 --- a/src/input/state/mouse/motion.rs +++ b/src/input/state/mouse/motion.rs @@ -25,6 +25,21 @@ impl InputState { screen_y: i32, canvas_x: i32, canvas_y: i32, + ) { + crate::input::state::with_legacy_text_resources(|resources| { + self.on_mouse_motion_with_canvas_and_resources( + resources, screen_x, screen_y, canvas_x, canvas_y, + ) + }) + } + + pub(crate) fn on_mouse_motion_with_canvas_and_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, ) { // Moving off the loupe ends a wheel adjustment of it. Nothing else runs // between two wheel bursts over one shape, so without this a visit @@ -34,7 +49,7 @@ impl InputState { ScreenPoint::new(screen_x, screen_y), CanvasPoint::new(canvas_x, canvas_y), ); - let _ = route_pointer_motion(self, PointerMotion::new(points)); + let _ = route_pointer_motion(self, resources.measurer, PointerMotion::new(points)); } } diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index 648d10a5a..570a68922 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -1,5 +1,5 @@ use crate::draw::Shape; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::TextMeasurer; use crate::input::tool::ToolPressBehavior; use crate::input::{DragTool, Tool, events::MouseButton}; use std::sync::Arc; @@ -43,8 +43,9 @@ impl InputState { && self.is_radial_menu_toggle_button(button) } - pub(in crate::input::state) fn handle_right_click( + pub(in crate::input::state) fn handle_right_click_with_measurer( &mut self, + measurer: &crate::draw::TextMeasurer, screen_x: i32, screen_y: i32, canvas_x: i32, @@ -52,7 +53,7 @@ impl InputState { ) { self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); self.text_editing.set_last_click(None); - if self.try_cancel_active_interaction() { + if self.try_cancel_active_interaction_with(measurer) { return; } if self.zoom_active() { @@ -62,7 +63,7 @@ impl InputState { return; } - let hit_shape = self.hit_test_at(canvas_x, canvas_y); + let hit_shape = self.hit_test_at_with(measurer, canvas_x, canvas_y); let mut focus_edit = false; if let Some(id) = hit_shape { if self.modifiers.shift { @@ -130,6 +131,22 @@ impl InputState { screen_y: i32, canvas_x: i32, canvas_y: i32, + ) { + crate::input::state::with_legacy_text_resources(|resources| { + self.on_mouse_press_with_canvas_and_resources( + resources, button, screen_x, screen_y, canvas_x, canvas_y, + ) + }) + } + + pub(crate) fn on_mouse_press_with_canvas_and_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + button: MouseButton, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, ) { // Any press ends a wheel adjustment of a loupe, so the burst lands in // history as its own entry rather than merging with what follows. @@ -138,7 +155,7 @@ impl InputState { ScreenPoint::new(screen_x, screen_y), CanvasPoint::new(canvas_x, canvas_y), ); - let _ = route_pointer_press(self, PointerPress::new(button, points)); + let _ = route_pointer_press(self, resources, PointerPress::new(button, points)); } pub(in crate::input::state) fn tool_for_button_press( @@ -167,8 +184,9 @@ impl InputState { configured_tool } - fn handle_tool_button_press( + fn handle_tool_button_press_with_measurer( &mut self, + measurer: &crate::draw::TextMeasurer, button: MouseButton, tool: Tool, color: Option, @@ -191,17 +209,31 @@ impl InputState { // Holding Alt turns the drag into a move of the whole block, which frees // plain-drag for text selection later. if button == MouseButton::Left - && self.handle_text_input_left_press(coords.canvas_x, coords.canvas_y, color) + && self.handle_text_input_left_press_with( + measurer, + coords.canvas_x, + coords.canvas_y, + color, + ) { return; } match &mut self.state { - DrawingState::Idle => { - self.handle_idle_tool_click(button, tool, color, coords.canvas_x, coords.canvas_y) - } + DrawingState::Idle => self.handle_idle_tool_click_with_measurer( + measurer, + button, + tool, + color, + coords.canvas_x, + coords.canvas_y, + ), DrawingState::BuildingPolygon { .. } if button == MouseButton::Left => { - self.handle_building_polygon_left_click(coords.canvas_x, coords.canvas_y); + self.handle_building_polygon_left_click_with_measurer( + measurer, + coords.canvas_x, + coords.canvas_y, + ); } DrawingState::TextInput { .. } | DrawingState::BuildingPolygon { .. } @@ -219,17 +251,6 @@ impl InputState { /// Apply the editor-owned meaning of a left press independently of drawing /// tool bindings: click positions/extends the caret, Alt+drag moves the /// block. Returns whether an active text editor consumed the press. - pub(in crate::input::state) fn handle_text_input_left_press( - &mut self, - canvas_x: i32, - canvas_y: i32, - color: Option, - ) -> bool { - with_legacy_measurer(|measurer| { - self.handle_text_input_left_press_with(measurer, canvas_x, canvas_y, color) - }) - } - pub(in crate::input::state) fn handle_text_input_left_press_with( &mut self, measurer: &TextMeasurer, @@ -359,10 +380,6 @@ impl InputState { /// Update the active text block's origin from a canvas-space pointer during /// an Alt+drag, preserving the grab offset. No-op when not dragging. - pub(in crate::input::state) fn drag_text_block_to(&mut self, canvas_x: i32, canvas_y: i32) { - with_legacy_measurer(|measurer| self.drag_text_block_to_with(measurer, canvas_x, canvas_y)) - } - pub(in crate::input::state) fn drag_text_block_to_with( &mut self, measurer: &TextMeasurer, @@ -378,15 +395,17 @@ impl InputState { } } - pub(in crate::input::state) fn handle_tool_button_press_at( + pub(in crate::input::state) fn handle_tool_button_press_at_with_measurer( &mut self, + measurer: &crate::draw::TextMeasurer, button: MouseButton, tool: Tool, color: Option, screen: (i32, i32), canvas: (i32, i32), ) { - self.handle_tool_button_press( + self.handle_tool_button_press_with_measurer( + measurer, button, tool, color, @@ -399,8 +418,9 @@ impl InputState { ); } - fn handle_idle_tool_click( + fn handle_idle_tool_click_with_measurer( &mut self, + measurer: &crate::draw::TextMeasurer, button: MouseButton, tool: Tool, color: Option, @@ -409,12 +429,12 @@ impl InputState { ) { let selection_click = self.modifiers.alt || matches!(tool.press_behavior(), ToolPressBehavior::Selection); - let hit_id = self.hit_test_at(x, y); + let hit_id = self.hit_test_at_with(measurer, x, y); // Handles are claimed before tool dispatch, in the order // `hit_idle_handle` fixes — the same order the pointer cursor previews, // so what the user sees is what the press does. - match self.hit_idle_handle(x, y) { + match self.hit_idle_handle_with(measurer, x, y) { Some(IdleHandle::SpotlightMagnification(shape_id)) => { if let Some(snapshot) = self.shape_snapshot(shape_id) { self.text_editing.set_last_click(None); @@ -423,7 +443,7 @@ impl InputState { DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot }; // Jump to where the user pressed, so a click anywhere on // the track is itself an adjustment rather than dead travel. - self.drag_spotlight_magnification_to(x); + self.drag_spotlight_magnification_to_with(measurer, x); return; } } @@ -434,7 +454,7 @@ impl InputState { self.state = DrawingState::BendingArrow { shape_id, snapshot }; // Jump the arc to where the user pressed, so a click beside // the handle is itself an adjustment rather than dead travel. - self.drag_arrow_bend_to(x, y, self.modifiers.shift); + self.drag_arrow_bend_to_with(measurer, x, y, self.modifiers.shift); return; } } @@ -457,7 +477,7 @@ impl InputState { } } Some(IdleHandle::SelectionResize(handle)) => { - if let Some(original_bounds) = self.selection_bounds() { + if let Some(original_bounds) = self.selection_bounds_with(measurer) { let snapshots = self.capture_resize_selection_snapshots(); if !snapshots.is_empty() { self.text_editing.set_last_click(None); @@ -528,7 +548,7 @@ impl InputState { additive: self.modifiers.shift, }; self.pointer.replace_provisional_bounds(None); - self.update_provisional_dirty(x, y); + self.update_provisional_dirty_with(measurer, x, y); self.needs_redraw = true; return; } @@ -538,7 +558,7 @@ impl InputState { ToolPressBehavior::Selection | ToolPressBehavior::HighlightNoop => {} ToolPressBehavior::StartFreeformPolygon => { self.mark_draw_activity(); - self.start_building_polygon(x, y); + self.start_building_polygon_with_measurer(measurer, x, y); } ToolPressBehavior::StartDrawing { request_blur_capture, @@ -562,7 +582,7 @@ impl InputState { point_thicknesses: vec![drawing_thickness as f32], }; self.pointer.replace_provisional_bounds(None); - self.update_provisional_dirty(x, y); + self.update_provisional_dirty_with(measurer, x, y); self.needs_redraw = true; } } diff --git a/src/input/state/mouse/press/panels.rs b/src/input/state/mouse/press/panels.rs index 080121c31..2fd7d6215 100644 --- a/src/input/state/mouse/press/panels.rs +++ b/src/input/state/mouse/press/panels.rs @@ -36,8 +36,9 @@ impl InputState { true } - pub(in crate::input::state) fn handle_radial_menu_press( + pub(in crate::input::state) fn handle_radial_menu_press_with_resources( &mut self, + resources: crate::input::state::InputTextResources<'_>, button: MouseButton, screen_x: i32, screen_y: i32, @@ -55,9 +56,13 @@ impl InputState { if self.radial_menu_hover_is_size_ring() { // Pressing the size gauge starts a drag-capture along the // arc instead of selecting. - self.radial_menu_begin_size_drag(screen_x as f64, screen_y as f64); + self.radial_menu_begin_size_drag_with_measurer( + resources.measurer, + screen_x as f64, + screen_y as f64, + ); } else { - self.radial_menu_select_hovered(); + self.radial_menu_select_hovered_with_resources(resources); } } MouseButton::Right => { @@ -65,7 +70,13 @@ impl InputState { if !self.is_radial_menu_toggle_button(MouseButton::Right) { // Keep right-click context-menu flow when right button is not the // configured radial-menu trigger. - self.handle_right_click(screen_x, screen_y, canvas_x, canvas_y); + self.handle_right_click_with_measurer( + resources.measurer, + screen_x, + screen_y, + canvas_x, + canvas_y, + ); } } MouseButton::Middle => { diff --git a/src/input/state/mouse/press/polygon.rs b/src/input/state/mouse/press/polygon.rs index f598ddf81..20a2e3d68 100644 --- a/src/input/state/mouse/press/polygon.rs +++ b/src/input/state/mouse/press/polygon.rs @@ -9,7 +9,12 @@ use super::super::super::{DrawingState, InputState}; use super::super::{TEXT_DOUBLE_CLICK_DISTANCE, TEXT_DOUBLE_CLICK_MS}; impl InputState { - pub(crate) fn start_building_polygon(&mut self, x: i32, y: i32) { + pub(crate) fn start_building_polygon_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + x: i32, + y: i32, + ) { self.sync_current_settings_for_tool(Tool::FreeformPolygon); let color = self.color_for_tool(Tool::FreeformPolygon); let thick = self.thickness_for_tool(Tool::FreeformPolygon); @@ -24,7 +29,7 @@ impl InputState { thick, }; self.pointer.replace_provisional_bounds(None); - self.update_provisional_dirty(x, y); + self.update_provisional_dirty_with(measurer, x, y); self.push_toast( ToastPriority::Info, "draw.polygon", @@ -48,15 +53,25 @@ impl InputState { ) } - pub(crate) fn handle_building_polygon_left_click(&mut self, x: i32, y: i32) { + pub(crate) fn handle_building_polygon_left_click_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + x: i32, + y: i32, + ) { if self.should_finish_building_polygon_on_click(x, y) { - self.finish_building_polygon(); + self.finish_building_polygon_with_measurer(measurer); } else { - self.append_building_polygon_point(x, y); + self.append_building_polygon_point_with_measurer(measurer, x, y); } } - pub(crate) fn append_building_polygon_point(&mut self, x: i32, y: i32) { + pub(crate) fn append_building_polygon_point_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + x: i32, + y: i32, + ) { let DrawingState::BuildingPolygon { points, preview, .. } = &mut self.state @@ -67,7 +82,7 @@ impl InputState { *preview = None; self.selection_interaction .record_polygon_click(x, y, Instant::now()); - self.update_provisional_dirty(x, y); + self.update_provisional_dirty_with(measurer, x, y); self.needs_redraw = true; } @@ -91,12 +106,6 @@ impl InputState { self.needs_redraw = true; } - pub(crate) fn finish_building_polygon(&mut self) { - crate::draw::with_legacy_measurer(|measurer| { - self.finish_building_polygon_with_measurer(measurer) - }) - } - pub(crate) fn finish_building_polygon_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index 934ed2b5b..0b19e3308 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -16,7 +16,12 @@ pub(super) struct DrawingRelease { pub(super) point_thicknesses: Vec, } -pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: DrawingRelease) { +pub(super) fn finish_drawing( + state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + tool: Tool, + release: DrawingRelease, +) { state.mark_draw_activity(); let drawing_color = state.active_drag_color_or_tool(tool); let drawing_thickness = state.thickness_for_tool(tool); @@ -85,7 +90,7 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin FinishedToolStroke::Shape { shape, usage } => (shape, usage), FinishedToolStroke::EraseStroke { path } => { state.clear_provisional_dirty(); - if state.erase_strokes_by_points(&path) { + if state.erase_strokes_by_points_with(measurer, &path) { state.mark_session_dirty(); } return; @@ -96,7 +101,7 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin } }; - let bounds = shape.bounding_box(); + let bounds = shape.bounding_box_with(measurer); let magnified_spotlight = matches!( shape, Shape::Spotlight { magnification, .. } @@ -143,7 +148,7 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin }; if let Some((new_id, _snapshot)) = addition { - state.invalidate_hit_cache_for(new_id); + state.invalidate_hit_cache_for_with(measurer, new_id); if let Some(path_damage) = path_damage { let provisional_bounds = state.take_provisional_dirty_bounds(); for region in path_damage { diff --git a/src/input/state/mouse/release/mod.rs b/src/input/state/mouse/release/mod.rs index 0ffcbb133..470dbba4f 100644 --- a/src/input/state/mouse/release/mod.rs +++ b/src/input/state/mouse/release/mod.rs @@ -36,12 +36,28 @@ impl InputState { screen_y: i32, canvas_x: i32, canvas_y: i32, + ) { + crate::input::state::with_legacy_text_resources(|resources| { + self.on_mouse_release_with_canvas_and_resources( + resources, button, screen_x, screen_y, canvas_x, canvas_y, + ) + }) + } + + pub(crate) fn on_mouse_release_with_canvas_and_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + button: MouseButton, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, ) { let points = PointerPoints::new( ScreenPoint::new(screen_x, screen_y), CanvasPoint::new(canvas_x, canvas_y), ); - let _ = route_pointer_release(self, PointerRelease::new(button, points)); + let _ = route_pointer_release(self, resources, PointerRelease::new(button, points)); } pub(in crate::input::state) fn handle_color_picker_popup_release_at( @@ -52,32 +68,36 @@ impl InputState { panels::handle_color_picker_popup_release(self, x, y) } - pub(in crate::input::state) fn handle_context_menu_release_at( + pub(in crate::input::state) fn handle_context_menu_release_at_with_resources( &mut self, + resources: crate::input::state::InputTextResources<'_>, x: i32, y: i32, ) -> bool { - panels::handle_context_menu_release(self, x, y) + panels::handle_context_menu_release(self, resources, x, y) } - pub(in crate::input::state) fn handle_board_picker_release_at( + pub(in crate::input::state) fn handle_board_picker_release_at_with_resources( &mut self, + resources: crate::input::state::InputTextResources<'_>, x: i32, y: i32, ) -> bool { - panels::handle_board_picker_release(self, x, y) + panels::handle_board_picker_release(self, resources, x, y) } - pub(in crate::input::state) fn handle_properties_panel_release_at( + pub(in crate::input::state) fn handle_properties_panel_release_at_with_measurer( &mut self, + measurer: &crate::draw::TextMeasurer, x: i32, y: i32, ) -> bool { - panels::handle_properties_panel_release(self, x, y) + panels::handle_properties_panel_release(self, measurer, x, y) } - pub(in crate::input::state) fn finish_pointer_interaction_at( + pub(in crate::input::state) fn finish_pointer_interaction_at_with_measurer( &mut self, + measurer: &crate::draw::TextMeasurer, canvas_x: i32, canvas_y: i32, ) { @@ -94,7 +114,7 @@ impl InputState { additive, } => { selection::finish_selection_drag( - self, start_x, start_y, canvas_x, canvas_y, additive, + self, measurer, start_x, start_y, canvas_x, canvas_y, additive, ); } DrawingState::ResizingText { @@ -103,7 +123,7 @@ impl InputState { selection::finish_text_resize(self, shape_id, snapshot); } DrawingState::ResizingSelection { snapshots, .. } => { - selection::finish_selection_resize(self, snapshots.as_ref()); + selection::finish_selection_resize(self, measurer, snapshots.as_ref()); } DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot } => { selection::finish_spotlight_magnification(self, shape_id, snapshot); @@ -120,6 +140,7 @@ impl InputState { } => { drawing::finish_drawing( self, + measurer, tool, drawing::DrawingRelease { start: (start_x, start_y), @@ -130,7 +151,7 @@ impl InputState { ); } DrawingState::PendingTextClick { x, y, shape_id, .. } => { - text::handle_pending_text_click(self, x, y, shape_id); + text::handle_pending_text_click(self, measurer, x, y, shape_id); } other_state => { self.state = other_state; diff --git a/src/input/state/mouse/release/panels.rs b/src/input/state/mouse/release/panels.rs index 9a35a98c7..761119cf1 100644 --- a/src/input/state/mouse/release/panels.rs +++ b/src/input/state/mouse/release/panels.rs @@ -104,12 +104,17 @@ pub(super) fn handle_color_picker_popup_release(state: &mut InputState, x: i32, true } -pub(super) fn handle_board_picker_release(state: &mut InputState, x: i32, y: i32) -> bool { +pub(super) fn handle_board_picker_release( + state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, + x: i32, + y: i32, +) -> bool { if !state.is_board_picker_open() { return false; } if state.board_picker_is_page_dragging() { - state.board_picker_finish_page_drag(); + state.board_picker_finish_page_drag_with_measurer(resources.measurer); return true; } if state.board_picker_is_dragging() { @@ -127,12 +132,12 @@ pub(super) fn handle_board_picker_release(state: &mut InputState, x: i32, y: i32 } if let Some(index) = state.board_picker_swatch_index_at(x, y) { state.board_picker_set_selected(index); - state.board_picker_edit_color_selected(); + state.board_picker_edit_color_selected_with_measurer(resources.measurer); state.needs_redraw = true; return true; } if state.board_picker_page_add_button_at(x, y) { - state.board_picker_add_page(); + state.board_picker_add_page_with_measurer(resources.measurer); state.needs_redraw = true; return true; } @@ -144,12 +149,12 @@ pub(super) fn handle_board_picker_release(state: &mut InputState, x: i32, y: i32 return true; } if let Some(index) = state.board_picker_page_duplicate_index_at(x, y) { - state.board_picker_duplicate_page(index); + state.board_picker_duplicate_page_with_measurer(resources.measurer, index); state.needs_redraw = true; return true; } if let Some(index) = state.board_picker_page_delete_index_at(x, y) { - state.board_picker_delete_page(index); + state.board_picker_delete_page_with_measurer(resources.measurer, index); state.needs_redraw = true; return true; } @@ -161,27 +166,27 @@ pub(super) fn handle_board_picker_release(state: &mut InputState, x: i32, y: i32 return true; } if let Some(index) = state.board_picker_page_index_at(x, y) { - state.board_picker_activate_page(index); + state.board_picker_activate_page_with_measurer(resources.measurer, index); state.needs_redraw = true; return true; } if state.board_picker_page_overflow_at(x, y) { state.update_pointer_position_synthetic(x, y); - state.execute_menu_command(MenuCommand::OpenPagesMenu); + state.execute_menu_command_with_resources(resources, MenuCommand::OpenPagesMenu); return true; } if let Some(index) = state.board_picker_open_icon_index_at(x, y) && !state.board_picker_is_new_row(index) { state.board_picker_set_selected(index); - state.board_picker_activate_row(index); + state.board_picker_activate_row_with_measurer(resources.measurer, index); state.needs_redraw = true; return true; } if let Some(index) = state.board_picker_index_at(x, y) { state.board_picker_set_selected(index); if state.board_picker_is_quick() || state.board_picker_is_new_row(index) { - state.board_picker_activate_row(index); + state.board_picker_activate_row_with_measurer(resources.measurer, index); state.needs_redraw = true; return true; } @@ -199,7 +204,7 @@ pub(super) fn handle_board_picker_release(state: &mut InputState, x: i32, y: i32 .unwrap_or(false); if is_double { state.board_picker.last_click = None; - state.board_picker_activate_row(index); + state.board_picker_activate_row_with_measurer(resources.measurer, index); } else { state.board_picker.last_click = Some(BoardPickerClickState { row: index, @@ -215,7 +220,12 @@ pub(super) fn handle_board_picker_release(state: &mut InputState, x: i32, y: i32 true } -pub(super) fn handle_properties_panel_release(state: &mut InputState, x: i32, y: i32) -> bool { +pub(super) fn handle_properties_panel_release( + state: &mut InputState, + measurer: &crate::draw::TextMeasurer, + x: i32, + y: i32, +) -> bool { if !state.is_properties_panel_open() { return false; } @@ -224,7 +234,7 @@ pub(super) fn handle_properties_panel_release(state: &mut InputState, x: i32, y: } if let Some(index) = state.properties_panel_index_at(x, y) { state.set_properties_panel_focus(Some(index)); - state.activate_properties_panel_entry(); + state.activate_properties_panel_entry_with(measurer); } else { state.close_properties_panel(); } @@ -232,7 +242,12 @@ pub(super) fn handle_properties_panel_release(state: &mut InputState, x: i32, y: true } -pub(super) fn handle_context_menu_release(state: &mut InputState, x: i32, y: i32) -> bool { +pub(super) fn handle_context_menu_release( + state: &mut InputState, + resources: crate::input::state::InputTextResources<'_>, + x: i32, + y: i32, +) -> bool { if !state.is_context_menu_open() { return false; } @@ -241,7 +256,7 @@ pub(super) fn handle_context_menu_release(state: &mut InputState, x: i32, y: i32 if let Some(entry) = entries.get(index) { if !entry.disabled { if let Some(command) = entry.command.clone() { - state.execute_menu_command(command); + state.execute_menu_command_with_resources(resources, command); } else { state.close_context_menu(); } diff --git a/src/input/state/mouse/release/selection.rs b/src/input/state/mouse/release/selection.rs index 0ffb32e98..74fcf902c 100644 --- a/src/input/state/mouse/release/selection.rs +++ b/src/input/state/mouse/release/selection.rs @@ -16,6 +16,7 @@ pub(super) fn finish_moving_selection( pub(super) fn finish_selection_drag( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, start_x: i32, start_y: i32, end_x: i32, @@ -27,7 +28,7 @@ pub(super) fn finish_selection_drag( let dy = (end_y - start_y).abs(); if dx < SELECTION_DRAG_THRESHOLD && dy < SELECTION_DRAG_THRESHOLD { if !additive { - let bounds = state.selection_bounding_box(state.selected_shape_ids()); + let bounds = state.selection_bounding_box_with(measurer, state.selected_shape_ids()); state.clear_selection(); state.mark_selection_dirty_region(bounds); state.needs_redraw = true; @@ -36,7 +37,7 @@ pub(super) fn finish_selection_drag( } if let Some(rect) = InputState::selection_rect_from_points(start_x, start_y, end_x, end_y) { - let ids = state.shape_ids_in_rect(rect); + let ids = state.shape_ids_in_rect_with(measurer, rect); if additive { state.extend_selection(ids); } else { @@ -115,6 +116,7 @@ pub(super) fn finish_text_resize( pub(super) fn finish_selection_resize( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, snapshots: &[(ShapeId, ShapeSnapshot)], ) { // Capture after-snapshots and push undo actions @@ -127,8 +129,8 @@ pub(super) fn finish_selection_resize( locked: shape.locked, }; // Check if shape bounds changed (simpler than full PartialEq on Shape) - let before_bounds = before_snapshot.shape.bounding_box(); - let after_bounds = after_snapshot.shape.bounding_box(); + let before_bounds = before_snapshot.shape.bounding_box_with(measurer); + let after_bounds = after_snapshot.shape.bounding_box_with(measurer); if before_bounds != after_bounds { frame.push_undo_action( UndoAction::modify_from_snapshots( diff --git a/src/input/state/mouse/release/text.rs b/src/input/state/mouse/release/text.rs index c4e9fa8b9..88c9c5477 100644 --- a/src/input/state/mouse/release/text.rs +++ b/src/input/state/mouse/release/text.rs @@ -6,6 +6,7 @@ use super::super::{TEXT_DOUBLE_CLICK_DISTANCE, TEXT_DOUBLE_CLICK_MS}; pub(super) fn handle_pending_text_click( state: &mut InputState, + measurer: &crate::draw::TextMeasurer, x: i32, y: i32, shape_id: crate::draw::ShapeId, @@ -20,6 +21,6 @@ pub(super) fn handle_pending_text_click( ); if is_double { state.set_selection(vec![shape_id]); - let _ = state.edit_selected_text(); + let _ = state.edit_selected_text_with(measurer); } } diff --git a/src/input/state/spotlight.rs b/src/input/state/spotlight.rs index 85d49a508..0916cdf03 100644 --- a/src/input/state/spotlight.rs +++ b/src/input/state/spotlight.rs @@ -4,10 +4,7 @@ //! layer and punches all the openings out of it. That makes spotlights the only //! shape kind the renderer collects up front instead of drawing in z-order. -use crate::draw::{ - Shape, ShapeId, SpotlightRegion, TextMeasurer, spotlight_regions_for_frame, - with_legacy_measurer, -}; +use crate::draw::{Shape, ShapeId, SpotlightRegion, TextMeasurer, spotlight_regions_for_frame}; use crate::input::Tool; use super::{DrawingState, InputState}; @@ -325,17 +322,6 @@ impl InputState { /// toolbar trip, and the loupe follows the ticks live. Returns whether /// anything changed, so the caller can fall through to its usual wheel /// behaviour when the pointer is not over a loupe. - pub(crate) fn nudge_spotlight_magnification_at( - &mut self, - x: i32, - y: i32, - steps: i32, - ) -> SpotlightWheelOutcome { - with_legacy_measurer(|measurer| { - self.nudge_spotlight_magnification_at_with(measurer, x, y, steps) - }) - } - pub(crate) fn nudge_spotlight_magnification_at_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/tests/basics.rs b/src/input/state/tests/basics.rs index 1070f94f9..7859bc95d 100644 --- a/src/input/state/tests/basics.rs +++ b/src/input/state/tests/basics.rs @@ -4,13 +4,23 @@ use crate::input::{DragBinding, DragTool, DragToolBindings}; #[test] fn toggle_floating_badge_action_flips_runtime_visibility() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); assert!( state.ui_visibility.show_floating_badge, "badge visible by default" ); - state.handle_action(crate::config::Action::ToggleFloatingBadge); + state.handle_action_with_resources( + test_text_resources, + crate::config::Action::ToggleFloatingBadge, + ); assert!(!state.ui_visibility.show_floating_badge); assert!(state.needs_redraw); // Persisting is the backend's job: it diffs the state before and after @@ -18,7 +28,10 @@ fn toggle_floating_badge_action_flips_runtime_visibility() { assert!(!state.has_pending_backend_actions()); assert!(state.take_pending_backend_action().is_none()); - state.handle_action(crate::config::Action::ToggleFloatingBadge); + state.handle_action_with_resources( + test_text_resources, + crate::config::Action::ToggleFloatingBadge, + ); assert!(state.ui_visibility.show_floating_badge); } @@ -26,12 +39,25 @@ fn toggle_floating_badge_action_flips_runtime_visibility() { /// through a queued request, however many times they are pressed. #[test] fn repeated_chrome_visibility_toggles_queue_no_backend_work() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(crate::config::Action::ToggleFloatingBadge); - state.handle_action(crate::config::Action::ToggleFloatingBadge); - state.handle_action(crate::config::Action::ToggleZoomChip); - state.handle_action(crate::config::Action::ToggleZoomChip); + state.handle_action_with_resources( + test_text_resources, + crate::config::Action::ToggleFloatingBadge, + ); + state.handle_action_with_resources( + test_text_resources, + crate::config::Action::ToggleFloatingBadge, + ); + state.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleZoomChip); + state.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleZoomChip); assert!(state.ui_visibility.show_floating_badge); assert!(state.ui_visibility.show_zoom_chip); @@ -237,6 +263,13 @@ fn test_adjust_font_size_multiple_adjustments() { /// queue through `handle_action`. #[test] fn chrome_actions_queue_their_own_durable_entry() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::input::state::PendingToolbarPersistence as Pending; for (action, expected) in [ @@ -254,7 +287,7 @@ fn chrome_actions_queue_their_own_durable_entry() { ), ] { let mut state = create_test_input_state(); - state.handle_action(action); + state.handle_action_with_resources(test_text_resources, action); assert_eq!( state.take_pending_toolbar_persistence(), vec![expected], @@ -268,16 +301,23 @@ fn chrome_actions_queue_their_own_durable_entry() { /// which a before/after read of the focus flag would get exactly backwards. #[test] fn a_chrome_toggle_that_breaks_focus_mode_still_persists() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::input::state::PendingToolbarPersistence as Pending; let mut state = create_test_input_state(); - state.handle_action(crate::config::Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleFocusMode); assert!(state.focus_mode_active()); // Focus mode taking chrome over is not a preference. assert!(state.take_pending_toolbar_persistence().is_empty()); let previous = state.ui_visibility.show_status_bar; - state.handle_action(crate::config::Action::ToggleStatusBar); + state.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleStatusBar); assert!(!state.focus_mode_active(), "the toggle breaks focus mode"); assert_eq!( state.take_pending_toolbar_persistence(), @@ -289,13 +329,20 @@ fn a_chrome_toggle_that_breaks_focus_mode_still_persists() { /// neither is the user choosing to live without it. #[test] fn focus_mode_transitions_queue_no_durable_chrome_change() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(crate::config::Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleFocusMode); assert!(!state.ui_visibility.show_status_bar); assert!(state.take_pending_toolbar_persistence().is_empty()); - state.handle_action(crate::config::Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleFocusMode); assert!(state.ui_visibility.show_status_bar); assert!(state.take_pending_toolbar_persistence().is_empty()); } @@ -304,12 +351,22 @@ fn focus_mode_transitions_queue_no_durable_chrome_change() { /// preferences keeps one entry each rather than the first swallowing the rest. #[test] fn a_burst_across_chrome_kinds_keeps_one_entry_each() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::input::state::PendingToolbarPersistence as Pending; let mut state = create_test_input_state(); - state.handle_action(crate::config::Action::ToggleStatusBar); - state.handle_action(crate::config::Action::ToggleFloatingBadge); - state.handle_action(crate::config::Action::ToggleZoomChip); + state.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleStatusBar); + state.handle_action_with_resources( + test_text_resources, + crate::config::Action::ToggleFloatingBadge, + ); + state.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleZoomChip); assert_eq!( state.take_pending_toolbar_persistence(), @@ -324,9 +381,16 @@ fn a_burst_across_chrome_kinds_keeps_one_entry_each() { /// A burst that lands where it started is dropped: nothing durable changed. #[test] fn a_chrome_toggle_pressed_twice_queues_nothing() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(crate::config::Action::ToggleStatusBar); - state.handle_action(crate::config::Action::ToggleStatusBar); + state.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleStatusBar); + state.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleStatusBar); assert!(state.ui_visibility.show_status_bar); assert!(state.take_pending_toolbar_persistence().is_empty()); @@ -336,6 +400,13 @@ fn a_chrome_toggle_pressed_twice_queues_nothing() { /// effect, and the explicit toggles move it directly; all of them persist. #[test] fn highlight_actions_queue_the_click_highlight() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::input::state::PendingToolbarPersistence as Pending; for action in [ @@ -346,7 +417,7 @@ fn highlight_actions_queue_the_click_highlight() { let mut state = create_test_input_state(); let previous_enabled = state.click_highlight_enabled(); let previous_tool_ring = state.highlight_tool_ring_enabled(); - state.handle_action(action); + state.handle_action_with_resources(test_text_resources, action); assert_eq!( state.take_pending_toolbar_persistence(), vec![Pending::ClickHighlight { diff --git a/src/input/state/tests/boards.rs b/src/input/state/tests/boards.rs index 58969f703..237a84651 100644 --- a/src/input/state/tests/boards.rs +++ b/src/input/state/tests/boards.rs @@ -143,6 +143,8 @@ fn failed_switch_board_preserves_active_interaction() { #[test] fn switch_board_cancels_text_edit_on_source_board_before_switching() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Text { x: 40, @@ -155,7 +157,7 @@ fn switch_board_cancels_text_edit_on_source_board_before_switching() { wrap_width: None, }); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&test_text_measurer)); assert_board_text(&state, BOARD_ID_TRANSPARENT, shape_id, ""); state.switch_board(BOARD_ID_WHITEBOARD); @@ -168,6 +170,8 @@ fn switch_board_cancels_text_edit_on_source_board_before_switching() { #[test] fn switch_board_cancels_selection_move_on_source_board_before_switching() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 40, @@ -180,7 +184,7 @@ fn switch_board_cancels_selection_move_on_source_board_before_switching() { }); state.set_selection(vec![shape_id]); let snapshots = state.capture_movable_selection_snapshots(); - assert!(state.apply_translation_to_selection(25, 35)); + assert!(state.apply_translation_to_selection_with(&test_text_measurer, 25, 35)); state.state = DrawingState::MovingSelection { last_x: 25, last_y: 35, @@ -354,6 +358,8 @@ fn duplicate_board_preflight_handles_existing_copy_board_when_over_image_limit() #[test] fn duplicate_board_cancels_text_edit_before_cloning_board() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); state.switch_board(BOARD_ID_WHITEBOARD); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Text { @@ -367,7 +373,7 @@ fn duplicate_board_cancels_text_edit_before_cloning_board() { wrap_width: None, }); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&test_text_measurer)); assert_board_text(&state, BOARD_ID_WHITEBOARD, shape_id, ""); state.duplicate_board(); diff --git a/src/input/state/tests/delete_restore.rs b/src/input/state/tests/delete_restore.rs index d5f1885ef..274e31c03 100644 --- a/src/input/state/tests/delete_restore.rs +++ b/src/input/state/tests/delete_restore.rs @@ -81,11 +81,13 @@ fn delete_active_board_requires_confirmation_then_restore_recovers_board() { #[test] fn delete_active_board_restore_preserves_cancelled_text_edit() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); state.switch_board(BOARD_ID_BLACKBOARD); let shape_id = add_text_shape(&mut state, "Original"); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&test_text_measurer)); assert_active_text(&state, shape_id, ""); state.delete_active_board(); @@ -220,13 +222,15 @@ fn page_delete_requires_confirmation_and_restore_recovers_deleted_page() { #[test] fn page_delete_restore_preserves_cancelled_text_edit() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let board = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); set_page_count(&mut state, board, 2); let shape_id = add_text_shape(&mut state, "Original"); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&test_text_measurer)); assert_active_text(&state, shape_id, ""); assert_eq!(state.page_delete(), PageDeleteOutcome::Pending); diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index c0bd761dc..bebe92d23 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -184,13 +184,15 @@ fn freeform_polygon_backspace_does_not_prime_double_click_commit() { #[test] fn freeform_polygon_commit_records_first_stroke_onboarding() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); assert!(state.set_tool_override(Some(Tool::FreeformPolygon))); state.on_mouse_press(MouseButton::Left, 0, 0); state.on_mouse_press(MouseButton::Left, 20, 0); state.on_mouse_press(MouseButton::Left, 20, 20); - state.finish_building_polygon(); + state.finish_building_polygon_with_measurer(&test_text_measurer); assert!(state.pending_onboarding_usage.first_stroke_done); } @@ -534,6 +536,8 @@ fn cancel_active_path_dirties_full_accumulated_provisional_bounds() { #[test] fn freeform_polygon_freezes_style_on_first_click() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); assert!(state.set_tool_override(Some(Tool::FreeformPolygon))); let original = state.style.current_color; @@ -548,7 +552,7 @@ fn freeform_polygon_freezes_style_on_first_click() { assert!(state.set_color(changed)); state.on_mouse_press(MouseButton::Left, 20, 0); state.on_mouse_press(MouseButton::Left, 20, 20); - state.finish_building_polygon(); + state.finish_building_polygon_with_measurer(&test_text_measurer); match &state.boards.active_frame().shapes[0].shape { Shape::Polygon { color, .. } => assert_eq!(*color, original), @@ -716,15 +720,22 @@ fn drag_binding_color_overrides_stroke_without_changing_current_color() { #[test] fn toggle_click_highlight_action_changes_state() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); assert!(!state.click_highlight_enabled()); - state.handle_action(Action::ToggleClickHighlight); + state.handle_action_with_resources(test_text_resources, Action::ToggleClickHighlight); assert!(state.click_highlight_enabled()); assert!(state.needs_redraw); state.needs_redraw = false; - state.handle_action(Action::ToggleClickHighlight); + state.handle_action_with_resources(test_text_resources, Action::ToggleClickHighlight); assert!(!state.click_highlight_enabled()); assert!(state.needs_redraw); } @@ -764,16 +775,23 @@ fn toolbar_select_highlight_sticks_when_highlight_is_active_via_modifier() { #[test] fn highlight_tool_prevents_drawing() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); assert_eq!(state.active_tool(), Tool::Pen); assert!(!state.highlight_tool_active()); - state.handle_action(Action::ToggleHighlightTool); + state.handle_action_with_resources(test_text_resources, Action::ToggleHighlightTool); assert!(state.highlight_tool_active()); assert_eq!(state.active_tool(), Tool::Highlight); // Enable highlight effect to ensure no shapes are added while clicks happen - state.handle_action(Action::ToggleClickHighlight); + state.handle_action_with_resources(test_text_resources, Action::ToggleClickHighlight); let initial_shapes = state.boards.active_frame().shapes.len(); state.on_mouse_press(MouseButton::Left, 10, 10); @@ -782,7 +800,7 @@ fn highlight_tool_prevents_drawing() { assert!(matches!(state.state, DrawingState::Idle)); // Toggle highlight tool off and ensure pen drawing resumes - state.handle_action(Action::ToggleHighlightTool); + state.handle_action_with_resources(test_text_resources, Action::ToggleHighlightTool); assert!(!state.highlight_tool_active()); state.on_mouse_press(MouseButton::Left, 0, 0); state.on_mouse_release(MouseButton::Left, 5, 5); diff --git a/src/input/state/tests/erase.rs b/src/input/state/tests/erase.rs index ba219a273..1fa48956e 100644 --- a/src/input/state/tests/erase.rs +++ b/src/input/state/tests/erase.rs @@ -3,6 +3,8 @@ use crate::draw::ArrowStyle; #[test] fn erase_stroke_samples_sparse_path() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); state.style.eraser_size = 4.0; state.style.eraser_mode = EraserMode::Stroke; @@ -21,7 +23,7 @@ fn erase_stroke_samples_sparse_path() { thick: 1.0, }); - let erased = state.erase_strokes_by_points(&[(0, -10), (100, 10)]); + let erased = state.erase_strokes_by_points_with(&test_text_measurer, &[(0, -10), (100, 10)]); assert!(erased, "stroke eraser should remove intersected line"); assert!(state.boards.active_frame().shape(line_id).is_none()); } @@ -55,6 +57,8 @@ fn erase_stroke_includes_release_segment() { #[test] fn erase_stroke_skips_locked_shapes() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); state.style.eraser_size = 4.0; state.style.eraser_mode = EraserMode::Stroke; @@ -90,7 +94,7 @@ fn erase_stroke_skips_locked_shapes() { state.boards.active_frame_mut().shapes[index].locked = true; } - let erased = state.erase_strokes_by_points(&[(0, -10), (100, 10)]); + let erased = state.erase_strokes_by_points_with(&test_text_measurer, &[(0, -10), (100, 10)]); assert!(erased, "eraser should remove unlocked shapes"); assert!(state.boards.active_frame().shape(unlocked_id).is_none()); assert!(state.boards.active_frame().shape(locked_id).is_some()); @@ -98,6 +102,8 @@ fn erase_stroke_skips_locked_shapes() { #[test] fn erase_stroke_samples_randomized_crossings() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + fn next_unit(seed: &mut u64) -> f64 { *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); let value = ((*seed >> 33) as u32) as f64; @@ -134,10 +140,13 @@ fn erase_stroke_samples_randomized_crossings() { let x1 = 50.0 + dx * length; let y1 = 0.0 + dy * length; - let erased = state.erase_strokes_by_points(&[ - (x0.round() as i32, y0.round() as i32), - (x1.round() as i32, y1.round() as i32), - ]); + let erased = state.erase_strokes_by_points_with( + &test_text_measurer, + &[ + (x0.round() as i32, y0.round() as i32), + (x1.round() as i32, y1.round() as i32), + ], + ); assert!( erased, @@ -150,6 +159,8 @@ fn erase_stroke_samples_randomized_crossings() { #[test] fn erase_stroke_hits_various_shapes() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let cases = vec![ ( Shape::Rect { @@ -215,7 +226,7 @@ fn erase_stroke_hits_various_shapes() { state.style.eraser_mode = EraserMode::Stroke; let shape_id = state.boards.active_frame_mut().add_shape(shape); - let erased = state.erase_strokes_by_points(&path); + let erased = state.erase_strokes_by_points_with(&test_text_measurer, &path); assert!(erased, "stroke eraser should remove intersected shape"); assert!(state.boards.active_frame().shape(shape_id).is_none()); } @@ -225,6 +236,8 @@ fn erase_stroke_hits_various_shapes() { /// Uses a low threshold to force the grid path to be exercised. #[test] fn spatial_grid_eraser_hits_after_add_move_delete() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); state.style.eraser_size = 10.0; state.style.eraser_mode = EraserMode::Stroke; @@ -301,7 +314,7 @@ fn spatial_grid_eraser_hits_after_add_move_delete() { // Test eraser with spatial grid using large tolerance state.set_hit_test_tolerance(20.0); - let erased = state.erase_strokes_by_points(&[(25, 25)]); + let erased = state.erase_strokes_by_points_with(&test_text_measurer, &[(25, 25)]); assert!(erased, "eraser should hit first shape with large tolerance"); assert!(state.boards.active_frame().shape(shape_ids[0]).is_none()); } diff --git a/src/input/state/tests/focus_mode.rs b/src/input/state/tests/focus_mode.rs index 97a068484..1dbcb26f7 100644 --- a/src/input/state/tests/focus_mode.rs +++ b/src/input/state/tests/focus_mode.rs @@ -10,21 +10,28 @@ use crate::input::state::{Toast, ToastPriority}; #[test] fn focus_mode_hides_all_chrome_and_restores_exactly() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); // Non-default pre-state: a micro top strip and a hidden floating badge // must both survive the round trip untouched. - state.handle_action(Action::CycleToolbarDisplay); // micro - state.handle_action(Action::ToggleFloatingBadge); // badge hidden + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // micro + state.handle_action_with_resources(test_text_resources, Action::ToggleFloatingBadge); // badge hidden assert_eq!(state.top_display_state(), TopDisplayMode::Micro); assert!(!state.ui_visibility.show_floating_badge); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); assert!(!state.toolbar_visible()); assert!(!state.ui_visibility.show_status_bar); assert!(!state.ui_visibility.show_floating_badge); assert!(!state.zoom_chip_enabled()); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(!state.focus_mode_active()); assert!(state.toolbar_visible()); assert!(state.ui_visibility.show_status_bar); @@ -49,8 +56,15 @@ fn focus_mode_hides_all_chrome_and_restores_exactly() { #[test] fn focus_mode_toast_offers_restore_action() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); let toast = state.active_toast().expect("focus mode toast"); assert!( @@ -64,10 +78,17 @@ fn focus_mode_toast_offers_restore_action() { #[test] fn focus_mode_suppresses_fallback_mode_badges_but_keeps_restore_toast() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.set_zoom_status(true, false, 2.0, (0.0, 0.0)); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); assert!(state.zoom_active()); @@ -89,13 +110,20 @@ fn focus_mode_suppresses_fallback_mode_badges_but_keeps_restore_toast() { #[test] fn manual_chrome_toggle_breaks_focus_mode() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); // F9 during focus mode: the user takes manual ownership. The toolbar // comes back, the snapshot is dropped, and the rest stays hidden. - state.handle_action(Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!(!state.focus_mode_active()); assert!(state.toolbar_visible()); assert!(!state.ui_visibility.show_status_bar); @@ -110,13 +138,20 @@ fn manual_chrome_toggle_breaks_focus_mode() { // The next focus-mode press starts a fresh snapshot (hide again), not // a stale restore. - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); assert!(!state.toolbar_visible()); } #[test] fn breaking_focus_mode_retracts_a_restore_toast_queued_behind_a_warning() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.push_toast( ToastPriority::Critical, @@ -124,14 +159,14 @@ fn breaking_focus_mode_retracts_a_restore_toast_queued_behind_a_warning() { Toast::error("Keep this warning"), ); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); assert!( state.test_pending_toast_count() > 0, "the lower-priority Restore toast should be queued" ); - state.handle_action(Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!(!state.focus_mode_active()); assert!( @@ -147,6 +182,13 @@ fn breaking_focus_mode_retracts_a_restore_toast_queued_behind_a_warning() { #[test] fn preset_status_bar_update_stays_hidden_until_focus_mode_restores_it() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.preset_slots.presets_mut_for_test()[0] = Some(ToolPresetConfig { name: None, @@ -168,7 +210,7 @@ fn preset_status_bar_update_stays_hidden_until_focus_mode_restores_it() { drag_tools: None, }); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); assert!(state.apply_preset(1)); @@ -178,7 +220,7 @@ fn preset_status_bar_update_stays_hidden_until_focus_mode_restores_it() { "Focus Mode keeps chrome suppressed" ); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!( !state.ui_visibility.show_status_bar, "Focus restore must honor the status-bar value authored by the preset" @@ -187,12 +229,19 @@ fn preset_status_bar_update_stays_hidden_until_focus_mode_restores_it() { #[test] fn focus_mode_rescues_a_fully_hidden_ui() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); // Hide everything by hand: no snapshot exists. - state.handle_action(Action::ToggleToolbar); - state.handle_action(Action::ToggleStatusBar); - state.handle_action(Action::ToggleFloatingBadge); - state.handle_action(Action::ToggleZoomChip); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleStatusBar); + state.handle_action_with_resources(test_text_resources, Action::ToggleFloatingBadge); + state.handle_action_with_resources(test_text_resources, Action::ToggleZoomChip); assert!(!state.focus_mode_active()); assert!(!state.toolbar_visible()); assert!(!state.ui_visibility.show_status_bar); @@ -209,7 +258,7 @@ fn focus_mode_rescues_a_fully_hidden_ui() { // With nothing left to hide, the action restores the full UI instead // of snapshotting an empty screen. - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(!state.focus_mode_active()); assert!(state.toolbar_visible()); assert!(state.ui_visibility.show_status_bar); @@ -226,6 +275,13 @@ fn focus_mode_rescues_a_fully_hidden_ui() { #[test] fn focus_mode_rescues_when_the_enabled_status_bar_has_no_visible_content() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.set_toolbar_visible(false); state.ui_visibility.show_floating_badge = false; @@ -249,7 +305,7 @@ fn focus_mode_rescues_when_the_enabled_status_bar_has_no_visible_content() { ); assert!(!state.status_hud_effectively_visible()); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!( !state.focus_mode_active(), @@ -266,6 +322,13 @@ fn focus_mode_rescues_when_the_enabled_status_bar_has_no_visible_content() { #[test] fn focus_mode_hides_a_floating_badge_when_it_is_the_only_visible_chrome() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); assert!( state.boards.board_count() > 1 || state.boards.page_count() > 1, @@ -276,7 +339,7 @@ fn focus_mode_hides_a_floating_badge_when_it_is_the_only_visible_chrome() { state.ui_visibility.show_zoom_chip = false; state.ui_visibility.show_floating_badge = true; - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!( state.focus_mode_active(), @@ -290,6 +353,13 @@ fn focus_mode_hides_a_floating_badge_when_it_is_the_only_visible_chrome() { #[test] fn focus_mode_hides_a_zoom_badge_when_it_is_the_only_visible_chrome() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.set_toolbar_visible(false); state.ui_visibility.show_status_bar = false; @@ -299,7 +369,7 @@ fn focus_mode_hides_a_zoom_badge_when_it_is_the_only_visible_chrome() { assert!(state.zoom_active()); assert!(!state.zoom_chip_enabled()); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!( state.focus_mode_active(), @@ -312,6 +382,13 @@ fn focus_mode_hides_a_zoom_badge_when_it_is_the_only_visible_chrome() { #[test] fn focus_mode_hides_a_fallback_badge_when_the_enabled_status_bar_is_empty() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.set_toolbar_visible(false); state.ui_visibility.show_floating_badge = false; @@ -333,7 +410,7 @@ fn focus_mode_hides_a_fallback_badge_when_the_enabled_status_bar_is_empty() { assert!(state.ui_visibility.show_status_bar); assert!(!state.status_hud_effectively_visible()); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!( state.focus_mode_active(), @@ -344,17 +421,24 @@ fn focus_mode_hides_a_fallback_badge_when_the_enabled_status_bar_is_empty() { #[test] fn focus_mode_never_enqueues_persistence() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + // Focus mode's hide/restore is transient by contract, and so is every // explicit ToggleFloatingBadge/ToggleZoomChip: neither reaches the disk // through either pending channel. let mut state = create_test_input_state(); let _ = state.take_pending_backend_action(); - state.handle_action(Action::ToggleFocusMode); // hide all + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); // hide all assert!(state.take_pending_backend_action().is_none()); assert!(!state.has_pending_toolbar_persistence()); - state.handle_action(Action::ToggleFocusMode); // restore all + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); // restore all assert!(state.take_pending_backend_action().is_none()); assert!(!state.has_pending_toolbar_persistence()); } @@ -363,22 +447,29 @@ fn focus_mode_never_enqueues_persistence() { /// transient and neither of them queues durable work. #[test] fn visibility_toggles_stay_process_only_across_focus_mode() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.ui_visibility.show_floating_badge = false; state.ui_visibility.show_zoom_chip = false; - state.handle_action(Action::ToggleFloatingBadge); - state.handle_action(Action::ToggleZoomChip); + state.handle_action_with_resources(test_text_resources, Action::ToggleFloatingBadge); + state.handle_action_with_resources(test_text_resources, Action::ToggleZoomChip); assert!(state.ui_visibility.show_floating_badge); assert!(state.ui_visibility.show_zoom_chip); // Suppress both live flags; focus mode owns them until it restores. - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(!state.ui_visibility.show_floating_badge); assert!(!state.ui_visibility.show_zoom_chip); assert!(state.take_pending_backend_action().is_none()); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.ui_visibility.show_floating_badge); assert!(state.ui_visibility.show_zoom_chip); assert!(state.take_pending_backend_action().is_none()); @@ -396,7 +487,7 @@ fn presenter_mode_gates_focus_mode() { state.toggle_presenter_mode_with_resources(route_resources); assert!(state.presenter_mode_active()); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(route_resources, Action::ToggleFocusMode); assert!( !state.focus_mode_active(), "presenter mode owns chrome; focus mode must not double-snapshot" @@ -405,48 +496,69 @@ fn presenter_mode_gates_focus_mode() { #[test] fn focus_mode_exits_light_mode_before_taking_ownership() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.compositor_capabilities.layer_shell = true; - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(state.light_mode_active()); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!( !state.light_mode_active(), "transient chrome owners must not nest" ); assert!(state.focus_mode_active()); - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.ui_visibility.show_status_bar); } #[test] fn light_mode_exits_focus_mode_before_taking_ownership() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.compositor_capabilities.layer_shell = true; - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(state.light_mode_active()); assert!( !state.focus_mode_active(), "transient chrome owners must not nest" ); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(state.ui_visibility.show_status_bar); } #[test] fn unsupported_light_mode_does_not_break_focus_mode() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.compositor_capabilities.layer_shell = false; - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(!state.light_mode_active()); assert!( @@ -458,12 +570,19 @@ fn unsupported_light_mode_does_not_break_focus_mode() { #[test] fn light_mode_drawing_exits_focus_mode_before_taking_ownership() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.compositor_capabilities.layer_shell = true; - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); - state.handle_action(Action::ToggleLightModeDrawing); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightModeDrawing); assert!(state.light_mode_active()); assert!(state.light_mode_drawing_active()); @@ -471,6 +590,6 @@ fn light_mode_drawing_exits_focus_mode_before_taking_ownership() { !state.focus_mode_active(), "every Light Mode entry path must own chrome exclusively" ); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(state.ui_visibility.show_status_bar); } diff --git a/src/input/state/tests/input_hud.rs b/src/input/state/tests/input_hud.rs index e7375a10b..2388d96a4 100644 --- a/src/input/state/tests/input_hud.rs +++ b/src/input/state/tests/input_hud.rs @@ -15,15 +15,22 @@ fn enabled_hud_state() -> InputState { #[test] fn toggle_input_hud_action_changes_state_and_redraws() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); assert!(!state.input_hud_enabled()); - state.handle_action(Action::ToggleInputHud); + state.handle_action_with_resources(test_text_resources, Action::ToggleInputHud); assert!(state.input_hud_enabled()); assert!(state.needs_redraw); state.needs_redraw = false; - state.handle_action(Action::ToggleInputHud); + state.handle_action_with_resources(test_text_resources, Action::ToggleInputHud); assert!(!state.input_hud_enabled()); assert!(state.needs_redraw); } @@ -34,8 +41,15 @@ fn toggle_input_hud_action_changes_state_and_redraws() { /// source-independent and toasts immediately. #[test] fn toggle_input_hud_defers_the_source_announcement_to_the_backend() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::ToggleInputHud); + state.handle_action_with_resources(test_text_resources, Action::ToggleInputHud); assert!( state.active_toast().is_none(), "the enable path must not toast a source before reconciliation" @@ -49,7 +63,7 @@ fn toggle_input_hud_defers_the_source_announcement_to_the_backend() { "the announcement request is consumed by the take" ); - state.handle_action(Action::ToggleInputHud); + state.handle_action_with_resources(test_text_resources, Action::ToggleInputHud); let toast = state.active_toast().expect("disable toast"); assert_eq!(toast.message, "Input HUD disabled"); assert!( @@ -81,7 +95,7 @@ fn presenter_mode_forces_input_hud_and_gates_the_manual_toggle() { starting is privacy-relevant regardless of what flipped the toggle" ); - state.handle_action(Action::ToggleInputHud); + state.handle_action_with_resources(route_resources, Action::ToggleInputHud); assert!( state.input_hud_enabled(), "presenter mode must swallow the manual toggle while it forces the HUD on" @@ -106,7 +120,7 @@ fn presenter_mode_restores_a_manually_enabled_input_hud() { }; let mut state = create_test_input_state(); state.presenter_mode_config_mut_for_test().enable_input_hud = true; - state.handle_action(Action::ToggleInputHud); + state.handle_action_with_resources(route_resources, Action::ToggleInputHud); assert!(state.input_hud_enabled()); state.toggle_presenter_mode_with_resources(route_resources); @@ -162,15 +176,22 @@ fn system_source_suppresses_overlay_notes() { /// an earlier session of the feature. #[test] fn disabling_the_hud_drops_its_chips() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = enabled_hud_state(); state.note_input_hud_key(Key::Char('a'), Modifiers::new()); assert!(state.input_hud_visible()); - state.handle_action(Action::ToggleInputHud); + state.handle_action_with_resources(test_text_resources, Action::ToggleInputHud); assert!(!state.input_hud_enabled()); assert_eq!(state.input_hud_entries().len(), 0); - state.handle_action(Action::ToggleInputHud); + state.handle_action_with_resources(test_text_resources, Action::ToggleInputHud); assert!(state.input_hud_enabled()); assert!(!state.input_hud_visible()); } diff --git a/src/input/state/tests/light_mode.rs b/src/input/state/tests/light_mode.rs index 023e74cdd..1035bd100 100644 --- a/src/input/state/tests/light_mode.rs +++ b/src/input/state/tests/light_mode.rs @@ -18,13 +18,20 @@ fn create_light_mode_test_state_with_click_highlight( #[test] fn light_mode_enters_passthrough_and_hides_heavy_ui() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_light_mode_test_state(); state.ui_visibility.show_status_bar = true; state.test_set_toolbar_visibility_state(true, true, state.toolbar_top_pinned()); state.ui_visibility.show_tool_preview = true; state.set_tool_override(Some(Tool::Arrow)); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(state.light_mode_active()); assert!(!state.light_mode_drawing_active()); @@ -38,16 +45,23 @@ fn light_mode_enters_passthrough_and_hides_heavy_ui() { #[test] fn light_mode_drawing_toggle_disables_passthrough_without_exiting() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_light_mode_test_state(); - state.handle_action(Action::ToggleLightMode); - state.handle_action(Action::ToggleLightModeDrawing); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightModeDrawing); assert!(state.light_mode_active()); assert!(state.light_mode_drawing_active()); assert!(!state.light_mode_passthrough()); - state.handle_action(Action::ToggleLightModeDrawing); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightModeDrawing); assert!(state.light_mode_active()); assert!(!state.light_mode_drawing_active()); @@ -66,14 +80,21 @@ fn light_draw_off_does_not_enter_light_mode() { #[test] fn light_mode_restores_previous_ui_and_tool_on_exit() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_light_mode_test_state(); state.ui_visibility.show_status_bar = true; state.test_set_toolbar_visibility_state(true, false, state.toolbar_top_pinned()); state.ui_visibility.show_tool_preview = true; state.set_tool_override(Some(Tool::Marker)); - state.handle_action(Action::ToggleLightMode); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(!state.light_mode_active()); assert!(!state.light_mode_drawing_active()); @@ -87,15 +108,22 @@ fn light_mode_restores_previous_ui_and_tool_on_exit() { #[test] fn light_mode_force_enables_click_highlight_by_default_and_restores_on_exit() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_light_mode_test_state(); assert!(!state.click_highlight_enabled()); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(state.light_mode_active()); assert!(state.click_highlight_enabled()); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(!state.light_mode_active()); assert!(!state.click_highlight_enabled()); @@ -103,16 +131,23 @@ fn light_mode_force_enables_click_highlight_by_default_and_restores_on_exit() { #[test] fn light_mode_can_leave_click_highlight_disabled() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut settings = ClickHighlightSettings::disabled(); settings.force_in_light_mode = false; let mut state = create_light_mode_test_state_with_click_highlight(settings); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(state.light_mode_active()); assert!(!state.click_highlight_enabled()); - state.handle_action(Action::ToggleLightModeDrawing); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightModeDrawing); assert!(state.light_mode_drawing_active()); assert!(!state.click_highlight_enabled()); @@ -120,12 +155,19 @@ fn light_mode_can_leave_click_highlight_disabled() { #[test] fn light_mode_and_presenter_mode_are_mutually_exclusive() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_light_mode_test_state(); - state.handle_action(Action::TogglePresenterMode); + state.handle_action_with_resources(test_text_resources, Action::TogglePresenterMode); assert!(state.presenter_mode_active()); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(state.light_mode_active()); assert!(!state.presenter_mode_active()); @@ -133,9 +175,16 @@ fn light_mode_and_presenter_mode_are_mutually_exclusive() { #[test] fn light_mode_does_not_enter_without_layer_shell() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::ToggleLightMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(!state.light_mode_active()); assert!(!state.light_mode_passthrough()); diff --git a/src/input/state/tests/pages.rs b/src/input/state/tests/pages.rs index 3eaad550c..a24664750 100644 --- a/src/input/state/tests/pages.rs +++ b/src/input/state/tests/pages.rs @@ -116,11 +116,13 @@ fn set_board_background_color_updates_active_auto_adjust_pen_color() { #[test] fn reorder_page_in_board_moves_named_pages_and_active_index() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let index = board_index(&state, BOARD_ID_BLACKBOARD); set_named_pages(&mut state, index, &["One", "Two", "Three"], 0); - assert!(state.reorder_page_in_board(index, 0, 2)); + assert!(state.reorder_page_in_board_with_measurer(&test_text_measurer, index, 0, 2)); let pages = &state.boards.board_states()[index].pages; assert_eq!(pages.page_name(0), Some("Two")); @@ -131,13 +133,24 @@ fn reorder_page_in_board_moves_named_pages_and_active_index() { #[test] fn move_page_between_boards_copy_preserves_source_and_adds_page_to_target() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let source = board_index(&state, BOARD_ID_WHITEBOARD); let target = board_index(&state, BOARD_ID_BLACKBOARD); set_named_pages(&mut state, source, &["Copied page"], 0); set_named_pages(&mut state, target, &["Target page"], 0); - assert!(state.move_page_between_boards_with_activation(source, 0, target, true, false)); + assert!( + state.move_page_between_boards_with_activation_with_measurer( + &test_text_measurer, + source, + 0, + target, + true, + false + ) + ); assert_eq!(state.boards.board_states()[source].pages.page_count(), 1); assert_eq!(state.boards.board_states()[target].pages.page_count(), 2); @@ -168,13 +181,24 @@ fn reset_active_canvas_position_clears_view_offset_on_solid_board() { #[test] fn move_page_between_boards_move_removes_source_page_and_activates_target_copy() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let source = board_index(&state, BOARD_ID_WHITEBOARD); let target = board_index(&state, BOARD_ID_BLACKBOARD); set_named_pages(&mut state, source, &["Keep", "Move me"], 1); set_named_pages(&mut state, target, &["Target page"], 0); - assert!(state.move_page_between_boards_with_activation(source, 1, target, false, false)); + assert!( + state.move_page_between_boards_with_activation_with_measurer( + &test_text_measurer, + source, + 1, + target, + false, + false + ) + ); assert_eq!(state.boards.board_states()[source].pages.page_count(), 1); assert_eq!( @@ -196,13 +220,15 @@ fn move_page_between_boards_move_removes_source_page_and_activates_target_copy() #[test] fn switch_to_page_cancels_text_edit_before_leaving_source_page() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let board = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); set_named_pages(&mut state, board, &["Source", "Target"], 0); let shape_id = add_active_text_shape(&mut state, "Original"); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&test_text_measurer)); assert!(state.switch_to_page(1)); @@ -213,12 +239,14 @@ fn switch_to_page_cancels_text_edit_before_leaving_source_page() { #[test] fn page_duplicate_cancels_text_edit_before_cloning_source_page() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let board = board_index(&state, BOARD_ID_BLACKBOARD); state.switch_board(BOARD_ID_BLACKBOARD); let shape_id = add_active_text_shape(&mut state, "Original"); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&test_text_measurer)); state.page_duplicate(); @@ -395,6 +423,8 @@ fn page_duplicate_blocks_uncompressed_text_when_real_save_exceeds_limit() { #[test] fn cross_board_page_copy_blocks_when_clone_would_exceed_persisted_session_limit() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let source = board_index(&state, BOARD_ID_WHITEBOARD); let target = board_index(&state, BOARD_ID_BLACKBOARD); @@ -405,7 +435,16 @@ fn cross_board_page_copy_blocks_when_clone_would_exceed_persisted_session_limit( options.max_file_size_bytes = 1024; state.set_session_preflight_options(Some(options)); - assert!(!state.move_page_between_boards_with_activation(source, 0, target, true, false)); + assert!( + !state.move_page_between_boards_with_activation_with_measurer( + &test_text_measurer, + source, + 0, + target, + true, + false + ) + ); assert_eq!(state.boards.board_states()[source].pages.page_count(), 1); assert_eq!(state.boards.board_states()[target].pages.page_count(), 1); @@ -418,6 +457,8 @@ fn cross_board_page_copy_blocks_when_clone_would_exceed_persisted_session_limit( #[test] fn cross_board_page_copy_preflights_when_source_was_not_previously_persisted() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let source = board_index(&state, BOARD_ID_TRANSPARENT); let target = board_index(&state, BOARD_ID_BLACKBOARD); @@ -434,7 +475,16 @@ fn cross_board_page_copy_preflights_when_source_was_not_previously_persisted() { options.max_file_size_bytes = 512; state.set_session_preflight_options(Some(options)); - assert!(!state.move_page_between_boards_with_activation(source, 0, target, true, false)); + assert!( + !state.move_page_between_boards_with_activation_with_measurer( + &test_text_measurer, + source, + 0, + target, + true, + false + ) + ); assert_eq!(state.boards.board_states()[source].pages.page_count(), 1); assert_eq!(state.boards.board_states()[target].pages.page_count(), 1); @@ -447,6 +497,8 @@ fn cross_board_page_copy_preflights_when_source_was_not_previously_persisted() { #[test] fn cross_board_page_copy_cancels_active_source_text_edit_before_cloning() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let source = board_index(&state, BOARD_ID_WHITEBOARD); let target = board_index(&state, BOARD_ID_BLACKBOARD); @@ -455,9 +507,18 @@ fn cross_board_page_copy_cancels_active_source_text_edit_before_cloning() { set_named_pages(&mut state, target, &["Target"], 0); let shape_id = add_active_text_shape(&mut state, "Original"); state.set_selection(vec![shape_id]); - assert!(state.edit_selected_text()); + assert!(state.edit_selected_text_with(&test_text_measurer)); - assert!(state.move_page_between_boards_with_activation(source, 0, target, true, false)); + assert!( + state.move_page_between_boards_with_activation_with_measurer( + &test_text_measurer, + source, + 0, + target, + true, + false + ) + ); assert!(state.text_editing.edit_target().is_none()); assert_page_text(&state, source, 0, shape_id, "Original"); diff --git a/src/input/state/tests/presenter_mode.rs b/src/input/state/tests/presenter_mode.rs index 4c98631cf..b74beb9d0 100644 --- a/src/input/state/tests/presenter_mode.rs +++ b/src/input/state/tests/presenter_mode.rs @@ -30,16 +30,23 @@ fn presenter_mode_forces_click_highlight() { #[test] fn presenter_mode_exits_focus_mode_before_taking_chrome_ownership() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.presenter_mode_config_mut_for_test().hide_status_bar = true; state.presenter_mode_config_mut_for_test().hide_toolbars = true; state.presenter_mode_config_mut_for_test().toolbar_mode = PresenterToolbarMode::Micro; - state.handle_action(Action::ToggleFocusMode); + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(state.focus_mode_active()); assert!(!state.ui_visibility.show_status_bar); - state.handle_action(Action::TogglePresenterMode); + state.handle_action_with_resources(test_text_resources, Action::TogglePresenterMode); assert!(state.presenter_mode_active()); assert!( @@ -48,7 +55,7 @@ fn presenter_mode_exits_focus_mode_before_taking_chrome_ownership() { ); assert_eq!(state.top_display_state(), TopDisplayMode::Micro); - state.handle_action(Action::TogglePresenterMode); + state.handle_action_with_resources(test_text_resources, Action::TogglePresenterMode); assert!(!state.presenter_mode_active()); assert!( state.ui_visibility.show_status_bar, diff --git a/src/input/state/tests/properties_panel.rs b/src/input/state/tests/properties_panel.rs index 02b8fa740..8e85f60ec 100644 --- a/src/input/state/tests/properties_panel.rs +++ b/src/input/state/tests/properties_panel.rs @@ -150,6 +150,12 @@ fn style_pill_selection_docking_routes_through_the_properties_apply_machinery() #[test] fn spotlight_magnification_property_steps_the_selected_shape_and_is_undoable() { let route_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &route_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Spotlight { cx: 100, @@ -186,7 +192,7 @@ fn spotlight_magnification_property_steps_the_selected_shape_and_is_undoable() { assert_eq!(magnification(&state), 1.75); assert!(state.take_pending_spotlight_magnifier_feedback()); - state.handle_action(Action::Undo); + state.handle_action_with_resources(test_text_resources, Action::Undo); assert_eq!(magnification(&state), 1.5); } @@ -232,7 +238,7 @@ fn activate_fill_entry_toggles_rectangle_fill_and_refreshes_panel_value() { let fill_index = entry_index(&state, "Fill"); state.set_properties_panel_focus(Some(fill_index)); - assert!(state.activate_properties_panel_entry()); + assert!(state.activate_properties_panel_entry_with(&route_measurer)); match &state .boards @@ -316,7 +322,7 @@ fn activate_text_background_entry_on_mixed_selection_turns_all_backgrounds_on() let bg_index = entry_index(&state, "Text background"); state.set_properties_panel_focus(Some(bg_index)); - assert!(state.activate_properties_panel_entry()); + assert!(state.activate_properties_panel_entry_with(&route_measurer)); for id in [first, second] { match &state @@ -413,6 +419,12 @@ fn magnification_entry(state: &InputState) -> Option (i32, i32) { #[test] fn duplicate_selection_via_action_creates_offset_shape() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -19,7 +26,7 @@ fn duplicate_selection_via_action_creates_offset_shape() { }); state.set_selection(vec![original_id]); - state.handle_action(Action::DuplicateSelection); + state.handle_action_with_resources(test_text_resources, Action::DuplicateSelection); let frame = state.boards.active_frame(); assert_eq!(frame.shapes.len(), 2); @@ -44,6 +51,13 @@ fn duplicate_selection_via_action_creates_offset_shape() { #[test] fn copy_paste_selection_centers_shape_at_pointer() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -56,9 +70,9 @@ fn copy_paste_selection_centers_shape_at_pointer() { }); state.set_selection(vec![original_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); state.update_pointer_positions(200, 300, 200, 300); - state.handle_action(Action::PasteSelection); + state.handle_action_with_resources(test_text_resources, Action::PasteSelection); let request = state .take_pending_clipboard_paste_request() .expect("pending paste request"); @@ -92,6 +106,13 @@ fn copy_paste_selection_centers_shape_at_pointer() { #[test] fn immediate_paste_after_copy_uses_pending_local_publish_shapes() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -103,8 +124,8 @@ fn immediate_paste_after_copy_uses_pending_local_publish_shapes() { thick: state.style.current_thickness, }); state.set_selection(vec![original_id]); - state.handle_action(Action::CopySelection); - state.handle_action(Action::PasteSelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::PasteSelection); let request = state .take_pending_clipboard_paste_request() .expect("pending paste request"); @@ -124,6 +145,13 @@ fn immediate_paste_after_copy_uses_pending_local_publish_shapes() { #[test] fn stale_publish_completion_is_ignored_for_newer_copy() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let first_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -135,7 +163,7 @@ fn stale_publish_completion_is_ignored_for_newer_copy() { thick: state.style.current_thickness, }); state.set_selection(vec![first_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let first_publish = state .take_pending_selection_clipboard_publish() .expect("pending first private clipboard publish"); @@ -150,7 +178,7 @@ fn stale_publish_completion_is_ignored_for_newer_copy() { thick: state.style.current_thickness, }); state.set_selection(vec![second_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let second_publish = state .take_pending_selection_clipboard_publish() .expect("pending second private clipboard publish"); @@ -170,6 +198,13 @@ fn stale_publish_completion_is_ignored_for_newer_copy() { #[test] fn failed_local_clipboard_precedence_clears_when_fingerprint_changes() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -181,7 +216,7 @@ fn failed_local_clipboard_precedence_clears_when_fingerprint_changes() { thick: state.style.current_thickness, }); state.set_selection(vec![original_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let publish = state .take_pending_selection_clipboard_publish() .expect("pending private clipboard publish"); @@ -231,6 +266,13 @@ fn failed_local_clipboard_precedence_clears_when_fingerprint_changes() { #[test] fn failed_local_clipboard_without_failure_fingerprint_supersedes_when_current_is_readable() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -242,7 +284,7 @@ fn failed_local_clipboard_without_failure_fingerprint_supersedes_when_current_is thick: state.style.current_thickness, }); state.set_selection(vec![original_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let publish = state .take_pending_selection_clipboard_publish() .expect("pending private clipboard publish"); @@ -268,6 +310,13 @@ fn failed_local_clipboard_without_failure_fingerprint_supersedes_when_current_is #[test] fn failed_local_clipboard_without_current_fingerprint_does_not_fast_path() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -279,7 +328,7 @@ fn failed_local_clipboard_without_current_fingerprint_does_not_fast_path() { thick: state.style.current_thickness, }); state.set_selection(vec![original_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let publish = state .take_pending_selection_clipboard_publish() .expect("pending private clipboard publish"); @@ -298,6 +347,13 @@ fn failed_local_clipboard_without_current_fingerprint_does_not_fast_path() { #[test] fn published_selection_allows_local_fallback_until_superseded() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -309,7 +365,7 @@ fn published_selection_allows_local_fallback_until_superseded() { thick: state.style.current_thickness, }); state.set_selection(vec![original_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let publish = state .take_pending_selection_clipboard_publish() .expect("pending private clipboard publish"); @@ -339,6 +395,13 @@ fn published_selection_allows_local_fallback_until_superseded() { #[test] fn fallback_generation_rejects_newer_local_copy() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -350,8 +413,8 @@ fn fallback_generation_rejects_newer_local_copy() { thick: state.style.current_thickness, }); state.set_selection(vec![original_id]); - state.handle_action(Action::CopySelection); - state.handle_action(Action::PasteSelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::PasteSelection); let request = state .take_pending_clipboard_paste_request() .expect("pending paste request"); @@ -359,7 +422,7 @@ fn fallback_generation_rejects_newer_local_copy() { .local_selection_fallback_generation .expect("fallback generation"); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); assert_ne!( Some(request_generation), @@ -375,6 +438,13 @@ fn fallback_generation_rejects_newer_local_copy() { #[test] fn private_payload_for_request_rejects_newer_same_instance_generation() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let first_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -386,11 +456,11 @@ fn private_payload_for_request_rejects_newer_same_instance_generation() { thick: state.style.current_thickness, }); state.set_selection(vec![first_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let _first_publish = state .take_pending_selection_clipboard_publish() .expect("pending first private clipboard publish"); - state.handle_action(Action::PasteSelection); + state.handle_action_with_resources(test_text_resources, Action::PasteSelection); let request = state .take_pending_clipboard_paste_request() .expect("pending paste request"); @@ -405,7 +475,7 @@ fn private_payload_for_request_rejects_newer_same_instance_generation() { thick: state.style.current_thickness, }); state.set_selection(vec![second_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let second_publish = state .take_pending_selection_clipboard_publish() .expect("pending second private clipboard publish"); @@ -422,6 +492,13 @@ fn private_payload_for_request_rejects_newer_same_instance_generation() { #[test] fn private_payload_for_request_uses_payload_when_current_generation_changed() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let first_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -433,13 +510,13 @@ fn private_payload_for_request_uses_payload_when_current_generation_changed() { thick: state.style.current_thickness, }); state.set_selection(vec![first_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let first_publish = state .take_pending_selection_clipboard_publish() .expect("pending first private clipboard publish"); let first_payload: WayscriberClipboardSelection = serde_json::from_str(&first_publish.payload_json).expect("first payload json"); - state.handle_action(Action::PasteSelection); + state.handle_action_with_resources(test_text_resources, Action::PasteSelection); let request = state .take_pending_clipboard_paste_request() .expect("pending paste request"); @@ -454,7 +531,7 @@ fn private_payload_for_request_uses_payload_when_current_generation_changed() { thick: state.style.current_thickness, }); state.set_selection(vec![second_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let shapes = state .selection_clipboard_snapshot() @@ -471,6 +548,13 @@ fn private_payload_for_request_uses_payload_when_current_generation_changed() { #[test] fn same_instance_private_payload_with_no_fallback_generation_uses_payload() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let original_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -482,7 +566,7 @@ fn same_instance_private_payload_with_no_fallback_generation_uses_payload() { thick: state.style.current_thickness, }); state.set_selection(vec![original_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let publish = state .take_pending_selection_clipboard_publish() .expect("pending private clipboard publish"); @@ -490,7 +574,7 @@ fn same_instance_private_payload_with_no_fallback_generation_uses_payload() { serde_json::from_str(&publish.payload_json).expect("payload json"); state.mark_selection_clipboard_superseded(); - state.handle_action(Action::PasteSelection); + state.handle_action_with_resources(test_text_resources, Action::PasteSelection); let request = state .take_pending_clipboard_paste_request() .expect("pending paste request"); @@ -510,6 +594,13 @@ fn same_instance_private_payload_with_no_fallback_generation_uses_payload() { #[test] fn request_generation_supersede_ignores_newer_local_copy() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let first_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -521,8 +612,8 @@ fn request_generation_supersede_ignores_newer_local_copy() { thick: state.style.current_thickness, }); state.set_selection(vec![first_id]); - state.handle_action(Action::CopySelection); - state.handle_action(Action::PasteSelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::PasteSelection); let request = state .take_pending_clipboard_paste_request() .expect("pending paste request"); @@ -540,7 +631,7 @@ fn request_generation_supersede_ignores_newer_local_copy() { thick: state.style.current_thickness, }); state.set_selection(vec![second_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let current_generation = state .selection_clipboard_snapshot() .fallback_generation() @@ -558,6 +649,13 @@ fn request_generation_supersede_ignores_newer_local_copy() { #[test] fn failed_local_fast_path_rejects_newer_generation() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let first_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -569,7 +667,7 @@ fn failed_local_fast_path_rejects_newer_generation() { thick: state.style.current_thickness, }); state.set_selection(vec![first_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let first_publish = state .take_pending_selection_clipboard_publish() .expect("pending first private clipboard publish"); @@ -585,7 +683,7 @@ fn failed_local_fast_path_rejects_newer_generation() { Some(fingerprint.clone()), false, ); - state.handle_action(Action::PasteSelection); + state.handle_action_with_resources(test_text_resources, Action::PasteSelection); let request = state .take_pending_clipboard_paste_request() .expect("pending paste request"); @@ -601,7 +699,7 @@ fn failed_local_fast_path_rejects_newer_generation() { thick: state.style.current_thickness, }); state.set_selection(vec![second_id]); - state.handle_action(Action::CopySelection); + state.handle_action_with_resources(test_text_resources, Action::CopySelection); let second_publish = state .take_pending_selection_clipboard_publish() .expect("pending second private clipboard publish"); @@ -630,6 +728,13 @@ fn failed_local_fast_path_rejects_newer_generation() { #[test] fn duplicate_selection_skips_locked_shapes() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let unlocked_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 0, @@ -655,7 +760,7 @@ fn duplicate_selection_skips_locked_shapes() { } state.set_selection(vec![unlocked_id, locked_id]); - state.handle_action(Action::DuplicateSelection); + state.handle_action_with_resources(test_text_resources, Action::DuplicateSelection); let frame = state.boards.active_frame(); assert_eq!(frame.shapes.len(), 3, "only one duplicate should be added"); diff --git a/src/input/state/tests/spotlight.rs b/src/input/state/tests/spotlight.rs index db790dff1..081e2ba2f 100644 --- a/src/input/state/tests/spotlight.rs +++ b/src/input/state/tests/spotlight.rs @@ -282,12 +282,14 @@ fn every_pixel_of_the_track_snaps_to_the_same_grid_the_toolbar_uses() { #[test] fn a_wheel_tick_pulls_an_off_grid_loupe_back_onto_the_grid() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + // A factor from an older session or a hand-edited file. One tick should // land on a real step rather than carrying the offset forever. let (mut state, id) = spotlight_state_with_one_loupe(2.19); assert_eq!( - state.nudge_spotlight_magnification_at(200, 200, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); let value = magnification_of(&state, id); @@ -297,18 +299,20 @@ fn a_wheel_tick_pulls_an_off_grid_loupe_back_onto_the_grid() { #[test] fn the_wheel_over_a_loupe_steps_its_own_magnification() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let (mut state, id) = spotlight_state_with_one_loupe(2.0); // Inside the ellipse: the wheel claims the event and the shape follows. assert_eq!( - state.nudge_spotlight_magnification_at(200, 200, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); assert_eq!(magnification_of(&state, id), 2.25); // Outside it: the wheel keeps its usual meaning for the caller. assert_eq!( - state.nudge_spotlight_magnification_at(400, 400, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 400, 400, 1), SpotlightWheelOutcome::NotOverLoupe ); assert_eq!(magnification_of(&state, id), 2.25); @@ -316,11 +320,18 @@ fn the_wheel_over_a_loupe_steps_its_own_magnification() { #[test] fn a_wheel_burst_over_one_loupe_undoes_as_a_single_step() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let (mut state, id) = spotlight_state_with_one_loupe(2.0); for _ in 0..4 { assert_eq!( - state.nudge_spotlight_magnification_at(200, 200, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); } @@ -328,18 +339,25 @@ fn a_wheel_burst_over_one_loupe_undoes_as_a_single_step() { // Undo flushes the in-flight gesture first, so the whole burst is one // entry rather than four. - state.handle_action(Action::Undo); + state.handle_action_with_resources(test_text_resources, Action::Undo); assert_eq!(magnification_of(&state, id), 2.0); } #[test] fn the_wheel_stops_at_the_end_of_the_range_without_opening_a_gesture() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let (mut state, id) = spotlight_state_with_one_loupe(crate::draw::MAX_SPOTLIGHT_MAGNIFICATION); // Still the loupe's event, so the wheel must not fall through and resize // a brush behind the user's back. assert_eq!( - state.nudge_spotlight_magnification_at(200, 200, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::AtRangeEnd, "an end of the range is not the same as the pointer being elsewhere" ); @@ -347,7 +365,7 @@ fn the_wheel_stops_at_the_end_of_the_range_without_opening_a_gesture() { magnification_of(&state, id), crate::draw::MAX_SPOTLIGHT_MAGNIFICATION ); - state.handle_action(Action::Undo); + state.handle_action_with_resources(test_text_resources, Action::Undo); assert_eq!( magnification_of(&state, id), crate::draw::MAX_SPOTLIGHT_MAGNIFICATION, @@ -357,6 +375,8 @@ fn the_wheel_stops_at_the_end_of_the_range_without_opening_a_gesture() { #[test] fn a_locked_loupe_claims_the_wheel_without_changing() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let (mut state, id) = spotlight_state_with_one_loupe(2.0); let index = state .boards @@ -366,7 +386,7 @@ fn a_locked_loupe_claims_the_wheel_without_changing() { state.boards.active_frame_mut().shapes[index].locked = true; assert_eq!( - state.nudge_spotlight_magnification_at(200, 200, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Locked ); assert_eq!(magnification_of(&state, id), 2.0); @@ -374,6 +394,8 @@ fn a_locked_loupe_claims_the_wheel_without_changing() { #[test] fn a_locked_topmost_loupe_hides_an_unlocked_loupe_from_the_wheel() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let (mut state, lower_id) = spotlight_state_with_one_loupe(2.0); let upper_id = state.boards.active_frame_mut().add_shape(Shape::Spotlight { cx: 200, @@ -390,7 +412,7 @@ fn a_locked_topmost_loupe_hides_an_unlocked_loupe_from_the_wheel() { state.boards.active_frame_mut().shapes[upper_index].locked = true; assert_eq!( - state.nudge_spotlight_magnification_at(200, 200, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Locked ); assert_eq!(magnification_of(&state, lower_id), 2.0); @@ -448,11 +470,18 @@ fn the_control_only_shows_on_a_page_that_already_forces_full_damage() { #[test] fn a_wheel_gesture_never_commits_against_a_different_page() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + // Shape ids restart per frame, so a snapshot flushed after a page change // would attach to an unrelated shape and corrupt that page's history. let (mut state, id) = spotlight_state_with_one_loupe(2.0); assert_eq!( - state.nudge_spotlight_magnification_at(200, 200, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); assert_eq!(magnification_of(&state, id), 2.25); @@ -470,13 +499,13 @@ fn a_wheel_gesture_never_commits_against_a_different_page() { 0, "the test needs an actual page switch" ); - state.handle_action(Action::Undo); + state.handle_action_with_resources(test_text_resources, Action::Undo); // Back on the source page, the burst is still undoable there: the entry // was neither discarded nor written to the page the user moved to. - state.handle_action(Action::PagePrev); + state.handle_action_with_resources(test_text_resources, Action::PagePrev); assert_eq!(magnification_of(&state, id), 2.25); - state.handle_action(Action::Undo); + state.handle_action_with_resources(test_text_resources, Action::Undo); assert_eq!( magnification_of(&state, id), 2.0, @@ -515,9 +544,16 @@ fn a_changed_page_set_never_looks_like_the_frame_a_gesture_started_on() { #[test] fn moving_off_a_loupe_ends_its_wheel_gesture() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let (mut state, id) = spotlight_state_with_one_loupe(2.0); assert_eq!( - state.nudge_spotlight_magnification_at(200, 200, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); @@ -525,14 +561,14 @@ fn moving_off_a_loupe_ends_its_wheel_gesture() { // one merged burst that only unwinds completely. state.on_mouse_motion(600, 600); assert_eq!( - state.nudge_spotlight_magnification_at(200, 200, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); assert_eq!(magnification_of(&state, id), 2.5); - state.handle_action(Action::Undo); + state.handle_action_with_resources(test_text_resources, Action::Undo); assert_eq!(magnification_of(&state, id), 2.25); - state.handle_action(Action::Undo); + state.handle_action_with_resources(test_text_resources, Action::Undo); assert_eq!(magnification_of(&state, id), 2.0); } @@ -560,12 +596,19 @@ fn moving_off_a_loupe_discards_its_partial_wheel_step() { #[test] fn a_toolbar_page_switch_closes_the_wheel_gesture_too() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + // Toolbar events never reach `handle_action`, so this is a separate path // to the same requirement: the burst must be recorded against the page it // happened on, before the switch. let (mut state, id) = spotlight_state_with_one_loupe(2.0); assert_eq!( - state.nudge_spotlight_magnification_at(200, 200, 1), + state.nudge_spotlight_magnification_at_with(&test_text_measurer, 200, 200, 1), SpotlightWheelOutcome::Adjusted ); @@ -574,7 +617,7 @@ fn a_toolbar_page_switch_closes_the_wheel_gesture_too() { state.apply_toolbar_event(crate::ui::toolbar::ToolbarEvent::PagePrev); assert_eq!(magnification_of(&state, id), 2.25); - state.handle_action(Action::Undo); + state.handle_action_with_resources(test_text_resources, Action::Undo); assert_eq!( magnification_of(&state, id), 2.0, @@ -694,6 +737,8 @@ fn an_edge_loupe_keeps_its_control_on_screen() { #[test] fn extreme_loupe_coordinates_do_not_overflow_the_hit_test_or_the_track() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let id = state.boards.active_frame_mut().add_shape(Shape::Spotlight { cx: i32::MIN + 4, @@ -708,11 +753,18 @@ fn extreme_loupe_coordinates_do_not_overflow_the_hit_test_or_the_track() { let _ = state.spotlight_at(i32::MAX, i32::MIN); state.set_selection(vec![id]); let _ = state.selected_spotlight_control(); - let _ = state.hit_spotlight_magnification_track(0, 0); + let _ = state.hit_spotlight_magnification_track_with(&test_text_measurer, 0, 0); } #[test] fn dragging_the_knob_magnifies_live_and_commits_one_undo_entry() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let (mut state, id) = spotlight_state_with_one_loupe(1.0); state.set_selection(vec![id]); let track = state @@ -744,7 +796,7 @@ fn dragging_the_knob_magnifies_live_and_commits_one_undo_entry() { state.on_mouse_release(MouseButton::Left, track.x + track.width, track.y + 6); assert!(matches!(state.state, DrawingState::Idle)); - state.handle_action(Action::Undo); + state.handle_action_with_resources(test_text_resources, Action::Undo); assert_eq!( magnification_of(&state, id), 1.0, diff --git a/src/input/state/tests/status_hud.rs b/src/input/state/tests/status_hud.rs index ff1005ffe..dd5725a63 100644 --- a/src/input/state/tests/status_hud.rs +++ b/src/input/state/tests/status_hud.rs @@ -147,11 +147,13 @@ fn status_hud_press_reports_hit_without_side_effect() { #[test] fn status_hud_click_board_segment_toggles_board_picker() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1280, 720); let (x, y) = segment_center(&input, StatusHudSegmentKind::Board); - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(hit); assert_eq!(action, None); assert!(input.is_board_picker_open()); @@ -162,11 +164,13 @@ fn status_hud_click_board_segment_toggles_board_picker() { #[test] fn status_hud_click_page_segment_opens_board_picker_page_panel() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1280, 720); let (x, y) = segment_center(&input, StatusHudSegmentKind::Page); - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(hit); assert_eq!(action, None); // The page panel lives inside the board picker, and the Page chip @@ -177,11 +181,13 @@ fn status_hud_click_page_segment_opens_board_picker_page_panel() { #[test] fn status_hud_click_color_dot_opens_color_picker_popup() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1280, 720); let (x, y) = segment_center(&input, StatusHudSegmentKind::Color); - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(hit); assert_eq!(action, None); assert!(input.is_color_picker_popup_open()); @@ -189,11 +195,13 @@ fn status_hud_click_color_dot_opens_color_picker_popup() { #[test] fn status_hud_click_tool_segment_opens_radial_menu() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1280, 720); let (x, y) = segment_center(&input, StatusHudSegmentKind::Tool); - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(hit); assert_eq!(action, None); assert!(input.is_radial_menu_open()); @@ -201,11 +209,13 @@ fn status_hud_click_tool_segment_opens_radial_menu() { #[test] fn status_hud_click_size_segment_opens_radial_menu() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1280, 720); let (x, y) = segment_center(&input, StatusHudSegmentKind::Size); - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(hit); assert_eq!(action, None); assert!(input.is_radial_menu_open()); @@ -213,11 +223,13 @@ fn status_hud_click_size_segment_opens_radial_menu() { #[test] fn status_hud_click_help_segment_returns_toggle_help_action() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1280, 720); let (x, y) = segment_center(&input, StatusHudSegmentKind::Help); - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(hit); assert_eq!(action, Some(Action::ToggleHelp)); // The action is dispatched by the backend; no surface opens here. @@ -231,11 +243,13 @@ fn status_hud_click_help_segment_returns_toggle_help_action() { /// dispatch the action rather than open a surface in place. #[test] fn status_hud_click_version_chip_returns_open_about_action() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1920, 1080); let (x, y) = segment_center(&input, StatusHudSegmentKind::About); - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(hit); assert_eq!(action, Some(Action::OpenAbout)); // Dispatched by the backend; nothing opens inside the overlay. @@ -266,6 +280,8 @@ fn a_narrow_status_hud_sheds_the_version_chip_first() { #[test] fn status_hud_click_toolbar_hint_returns_toggle_toolbar_action() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); // The hint chip only exists while every toolbar surface is hidden. @@ -283,7 +299,7 @@ fn status_hud_click_toolbar_hint_returns_toggle_toolbar_action() { update_hud_layout(&mut input, 1280, 720); let (x, y) = segment_center(&input, StatusHudSegmentKind::Toolbar); - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(hit); assert_eq!(action, Some(Action::ToggleToolbar)); // The action is dispatched by the backend; no surface opens here. @@ -300,20 +316,27 @@ fn status_hud_click_toolbar_hint_returns_toggle_toolbar_action() { /// no-op. #[test] fn status_hud_toolbar_hint_recovers_cycle_hidden_top_strip() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut input = create_test_input_state(); - input.handle_action(Action::CycleToolbarDisplay); // micro - input.handle_action(Action::CycleToolbarDisplay); // hidden + input.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // micro + input.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // hidden assert!(!input.toolbar_visible()); update_hud_layout(&mut input, 1280, 720); let (x, y) = segment_center(&input, StatusHudSegmentKind::Toolbar); - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(hit); let action = action.expect("toolbar hint chip returns an action"); assert_eq!(action, Action::ToggleToolbar); // Dispatch the returned action exactly as the backend does. - input.handle_action(action); + input.handle_action_with_resources(test_text_resources, action); assert!( input.toolbar_visible(), "clicking the recovery chip must restore the toolbar" @@ -335,6 +358,8 @@ fn status_hud_toolbar_hint_recovers_cycle_hidden_top_strip() { #[test] fn status_hud_click_between_segments_consumes_without_action() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1280, 720); let layout = input.status_hud_layout().expect("layout"); @@ -351,7 +376,7 @@ fn status_hud_click_between_segments_consumes_without_action() { ); let x = (pill_x + (first_segment_x - pill_x) / 2.0).round() as i32; - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(hit); assert_eq!(action, None); assert!(!input.is_board_picker_open()); @@ -361,10 +386,12 @@ fn status_hud_click_between_segments_consumes_without_action() { #[test] fn status_hud_click_outside_is_ignored() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1280, 720); - let (hit, action) = input.check_status_hud_click(1279, 1); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, 1279, 1); assert!(!hit); assert_eq!(action, None); assert!(!input.is_board_picker_open()); @@ -374,6 +401,8 @@ fn status_hud_click_outside_is_ignored() { #[test] fn status_hud_ignores_clicks_when_not_interactive() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); input.ui_visibility.status_bar_interactive = false; update_hud_layout(&mut input, 1280, 720); @@ -383,7 +412,7 @@ fn status_hud_ignores_clicks_when_not_interactive() { // ...but it consumes no clicks (pure display). assert!(!input.status_hud_contains(x, y)); - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(!hit); assert_eq!(action, None); assert!(!input.is_radial_menu_open()); @@ -482,6 +511,8 @@ fn status_hud_press_routing_consumes_left_press_over_interactive_hud() { #[test] fn status_hud_ignored_while_radial_menu_overlays_it() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1280, 720); let (x, y) = segment_center(&input, StatusHudSegmentKind::Tool); @@ -496,7 +527,7 @@ fn status_hud_ignored_while_radial_menu_overlays_it() { // The release side cannot re-fire the chip either (no board picker or // second surface stacking over the open radial menu). - let (hit, action) = input.check_status_hud_click(x, y); + let (hit, action) = input.check_status_hud_click_with_measurer(&test_text_measurer, x, y); assert!(!hit); assert_eq!(action, None); assert!(!input.is_board_picker_open()); @@ -512,6 +543,8 @@ fn status_hud_ignored_while_radial_menu_overlays_it() { #[test] fn status_hud_ignored_while_other_eclipsing_overlays_are_open() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut input = create_test_input_state(); update_hud_layout(&mut input, 1280, 720); let (x, y) = segment_center(&input, StatusHudSegmentKind::Page); @@ -521,7 +554,11 @@ fn status_hud_ignored_while_other_eclipsing_overlays_are_open() { // check_status_hud_click shares the same guard). input.open_board_picker(); assert!(!input.status_hud_contains(x, y)); - assert!(!input.check_status_hud_click(x, y).0); + assert!( + !input + .check_status_hud_click_with_measurer(&test_text_measurer, x, y) + .0 + ); input.close_board_picker(); assert!(input.status_hud_contains(x, y)); diff --git a/src/input/state/tests/text_input/actions.rs b/src/input/state/tests/text_input/actions.rs index c237256a8..9d3fa3ec8 100644 --- a/src/input/state/tests/text_input/actions.rs +++ b/src/input/state/tests/text_input/actions.rs @@ -2,6 +2,13 @@ use super::super::*; #[test] fn test_redo_restores_shape_after_undo() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); { @@ -27,22 +34,29 @@ fn test_redo_restores_shape_after_undo() { assert_eq!(state.boards.active_frame().shapes.len(), 1); - state.handle_action(Action::Undo); + state.handle_action_with_resources(test_text_resources, Action::Undo); assert_eq!(state.boards.active_frame().shapes.len(), 0); - state.handle_action(Action::Redo); + state.handle_action_with_resources(test_text_resources, Action::Redo); assert_eq!(state.boards.active_frame().shapes.len(), 1); } #[test] fn capture_action_sets_pending_and_clears_modifiers() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.modifiers.ctrl = true; state.modifiers.shift = true; state.modifiers.alt = true; state.modifiers.logo = true; - state.handle_action(Action::CaptureClipboardFull); + state.handle_action_with_resources(test_text_resources, Action::CaptureClipboardFull); assert!(!state.modifiers.ctrl); assert!(!state.modifiers.shift); @@ -60,11 +74,18 @@ fn capture_action_sets_pending_and_clears_modifiers() { #[test] fn region_capture_action_preserves_live_shift_until_the_picker_arms() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.modifiers.ctrl = true; state.modifiers.shift = true; - state.handle_action(Action::CaptureClipboardSelection); + state.handle_action_with_resources(test_text_resources, Action::CaptureClipboardSelection); assert!(state.modifiers.ctrl); assert!(state.modifiers.shift); @@ -78,9 +99,16 @@ fn region_capture_action_preserves_live_shift_until_the_picker_arms() { #[test] fn canvas_export_action_sets_pending_backend_action() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::ExportCanvasClipboard); + state.handle_action_with_resources(test_text_resources, Action::ExportCanvasClipboard); assert_eq!( state.take_pending_backend_action(), @@ -92,9 +120,16 @@ fn canvas_export_action_sets_pending_backend_action() { #[test] fn board_pdf_export_action_sets_pending_backend_action() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::ExportBoardPdfFile); + state.handle_action_with_resources(test_text_resources, Action::ExportBoardPdfFile); assert_eq!( state.take_pending_backend_action(), @@ -106,9 +141,16 @@ fn board_pdf_export_action_sets_pending_backend_action() { #[test] fn all_boards_pdf_export_action_sets_pending_backend_action() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::ExportAllBoardsPdfFile); + state.handle_action_with_resources(test_text_resources, Action::ExportAllBoardsPdfFile); assert_eq!( state.take_pending_backend_action(), diff --git a/src/input/state/tests/text_input/editing.rs b/src/input/state/tests/text_input/editing.rs index cf49815f2..e020c2320 100644 --- a/src/input/state/tests/text_input/editing.rs +++ b/src/input/state/tests/text_input/editing.rs @@ -426,15 +426,21 @@ fn delayed_paste_replaces_the_selection_captured_at_invocation() { #[test] fn paste_generation_does_not_match_a_later_text_edit() { let measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::EnterTextMode); + state.handle_action_with_resources(test_text_resources, Action::EnterTextMode); let first = state .text_editing .generation(&state.state) .expect("text edit is active"); state.cancel_text_input_with(&measurer); - state.handle_action(Action::EnterTextMode); + state.handle_action_with_resources(test_text_resources, Action::EnterTextMode); assert!( !state @@ -445,10 +451,17 @@ fn paste_generation_does_not_match_a_later_text_edit() { #[test] fn first_click_places_a_new_empty_text_block() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + use crate::input::MouseButton; let mut state = create_test_input_state(); - state.handle_action(Action::EnterTextMode); + state.handle_action_with_resources(test_text_resources, Action::EnterTextMode); state.on_mouse_press_with_canvas(MouseButton::Left, 222, 333, 222, 333); assert_eq!(origin(&state), (222, 333)); @@ -634,6 +647,8 @@ fn click_after_visible_preedit_maps_back_to_the_committed_buffer() { #[test] fn edit_ghost_is_hidden_in_place_and_shown_after_moving() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + use crate::draw::Shape; let mut state = create_test_input_state(); @@ -649,7 +664,7 @@ fn edit_ghost_is_hidden_in_place_and_shown_after_moving() { }); state.set_selection(vec![shape_id]); assert!( - state.edit_selected_text(), + state.edit_selected_text_with(&test_text_measurer), "should enter edit mode on the existing text" ); diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index 4851eedf1..9a7bcd04e 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -75,9 +75,16 @@ fn cycling_from_black_out_to_sampling_blur_requests_frozen_capture() { #[test] fn pick_screen_color_requests_backend_eyedropper_activation() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::PickScreenColor); + state.handle_action_with_resources(test_text_resources, Action::PickScreenColor); assert!(state.take_pending_eyedropper_toggle()); } @@ -449,13 +456,23 @@ fn tool_color_and_thickness_are_independent_between_pen_and_marker() { #[test] fn increase_thickness_action_changes_marker_width_not_marker_opacity() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); assert!(state.set_tool_override(Some(Tool::Marker))); assert!(state.set_thickness(24.0)); let original_opacity = state.style.marker_opacity; let pen_thickness = state.thickness_for_tool(Tool::Pen); - state.handle_action(crate::config::Action::IncreaseThickness); + state.handle_action_with_resources( + test_text_resources, + crate::config::Action::IncreaseThickness, + ); assert_eq!(state.thickness_for_tool(Tool::Marker), 25.0); assert_eq!(state.thickness_for_tool(Tool::Pen), pen_thickness); @@ -2223,6 +2240,13 @@ fn a_quick_color_recolor_queues_the_write_without_touching_the_file_itself() { #[test] fn cycling_arrow_style_with_nothing_selected_only_moves_the_next_arrow() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let existing = state.boards.active_frame_mut().add_shape(Shape::Arrow { x1: 0, @@ -2239,7 +2263,7 @@ fn cycling_arrow_style_with_nothing_selected_only_moves_the_next_arrow() { label: None, }); - state.handle_action(Action::CycleArrowStyle); + state.handle_action_with_resources(test_text_resources, Action::CycleArrowStyle); assert_eq!(state.style.arrow_style, ArrowStyle::Pointy); match &state @@ -2260,6 +2284,13 @@ fn cycling_arrow_style_with_nothing_selected_only_moves_the_next_arrow() { #[test] fn cycling_arrow_style_with_arrows_selected_restyles_them_instead() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let arrow = state.boards.active_frame_mut().add_shape(Shape::Arrow { x1: 0, @@ -2277,7 +2308,7 @@ fn cycling_arrow_style_with_arrows_selected_restyles_them_instead() { }); state.set_selection(vec![arrow]); - state.handle_action(Action::CycleArrowStyle); + state.handle_action_with_resources(test_text_resources, Action::CycleArrowStyle); match &state .boards @@ -2298,6 +2329,13 @@ fn cycling_arrow_style_with_arrows_selected_restyles_them_instead() { #[test] fn cycling_arrow_style_with_a_non_arrow_selected_falls_back_to_the_default() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let rect = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 0, @@ -2310,7 +2348,7 @@ fn cycling_arrow_style_with_a_non_arrow_selected_falls_back_to_the_default() { }); state.set_selection(vec![rect]); - state.handle_action(Action::CycleArrowStyle); + state.handle_action_with_resources(test_text_resources, Action::CycleArrowStyle); assert_eq!(state.style.arrow_style, ArrowStyle::Pointy); } diff --git a/src/input/state/tests/toolbar_display.rs b/src/input/state/tests/toolbar_display.rs index 4b2c1f59e..2329fed7d 100644 --- a/src/input/state/tests/toolbar_display.rs +++ b/src/input/state/tests/toolbar_display.rs @@ -16,8 +16,15 @@ fn unbind_chrome_visibility_actions(state: &mut InputState) { } fn hide_all_chrome(state: &mut InputState) { - state.handle_action(Action::ToggleToolbar); - state.handle_action(Action::ToggleStatusBar); + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleStatusBar); assert!(!state.toolbar_visible()); assert!(!state.ui_visibility.show_status_bar); } @@ -33,13 +40,20 @@ fn refresh_status_hud_layout(state: &mut InputState) { #[test] fn cycle_action_walks_full_micro_hidden_full_with_toasts() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); // Keep the status bar up so the hidden rung shows its routine toast // instead of the all-chrome-hidden recovery warning. refresh_status_hud_layout(&mut state); assert_eq!(state.top_display_state(), TopDisplayMode::Full); - state.handle_action(Action::CycleToolbarDisplay); + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); assert_eq!(state.top_display_state(), TopDisplayMode::Micro); assert!( state.toolbar_top_visible(), @@ -57,7 +71,7 @@ fn cycle_action_walks_full_micro_hidden_full_with_toasts() { "keyboard cycle persists like the toolbar-event paths" ); - state.handle_action(Action::CycleToolbarDisplay); + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); assert_eq!(state.top_display_state(), TopDisplayMode::Hidden); assert!(!state.toolbar_top_visible()); assert_eq!( @@ -65,7 +79,7 @@ fn cycle_action_walks_full_micro_hidden_full_with_toasts() { Some("Toolbar: hidden") ); - state.handle_action(Action::CycleToolbarDisplay); + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); assert_eq!(state.top_display_state(), TopDisplayMode::Full); assert!(state.toolbar_top_visible()); assert_eq!( @@ -76,6 +90,13 @@ fn cycle_action_walks_full_micro_hidden_full_with_toasts() { #[test] fn entering_micro_unminimizes_and_closes_top_menus() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + for menu in [ TopMenuState::ShapePicker, TopMenuState::TopOverflow, @@ -87,7 +108,7 @@ fn entering_micro_unminimizes_and_closes_top_menus() { state.test_set_toolbar_display_state(state.toolbar_top_display_mode(), true); state.test_set_toolbar_menu_state(menu, state.toolbar_top_popover_scroll()); - state.handle_action(Action::CycleToolbarDisplay); + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); assert_eq!(state.top_display_state(), TopDisplayMode::Micro); assert!( !state.toolbar_top_minimized(), @@ -99,33 +120,47 @@ fn entering_micro_unminimizes_and_closes_top_menus() { #[test] fn toggle_toolbar_show_restores_a_cycle_hidden_top_strip() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); // A cycle-hidden top strip leaves no visible toolbar surface while every // raw visibility flag stays true. The raw-flag early return in // set_toolbar_visible used to swallow the restore in exactly this state, // leaving F9 (and everything else dispatching ToggleToolbar) dead. - state.handle_action(Action::CycleToolbarDisplay); // micro - state.handle_action(Action::CycleToolbarDisplay); // hidden + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // micro + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // hidden assert!( !state.toolbar_visible(), "a cycle-hidden strip must leave no visible surface" ); // A single ToggleToolbar press must bring the strip back. - state.handle_action(Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!(state.toolbar_visible()); assert_eq!(state.top_display_state(), TopDisplayMode::Full); } #[test] fn toggle_toolbar_drives_the_top_pin_and_queues_its_persistence() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); assert!(state.toolbar_visible()); assert!(state.toolbar_top_pinned()); // F9 hide: the durable form of the toggle unpins the strip, and the // pending action carries the pre-change pin for the preview's rollback. - state.handle_action(Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!(!state.toolbar_visible()); assert!(!state.toolbar_top_pinned()); assert_eq!( @@ -137,7 +172,7 @@ fn toggle_toolbar_drives_the_top_pin_and_queues_its_persistence() { ); // F9 show: the pin comes back on, with the hidden state as rollback. - state.handle_action(Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!(state.toolbar_visible()); assert!(state.toolbar_top_pinned()); assert_eq!( @@ -152,10 +187,17 @@ fn toggle_toolbar_drives_the_top_pin_and_queues_its_persistence() { /// the visible state the user was looking at. #[test] fn toggle_toolbar_resolves_pins_to_what_is_on_screen() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); assert!(state.toolbar_visible()); - state.handle_action(Action::ToggleToolbar); // off + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // off assert!(!state.toolbar_top_pinned()); assert_eq!( state.take_pending_toolbar_persistence(), @@ -164,7 +206,7 @@ fn toggle_toolbar_resolves_pins_to_what_is_on_screen() { }] ); - state.handle_action(Action::ToggleToolbar); // on + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // on assert!(state.toolbar_visible()); assert!( state.toolbar_top_pinned(), @@ -180,14 +222,21 @@ fn toggle_toolbar_resolves_pins_to_what_is_on_screen() { /// runtime-only, exactly like F2's hidden rung. #[test] fn cycle_hidden_show_with_unchanged_pins_queues_no_persistence() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::CycleToolbarDisplay); // micro - state.handle_action(Action::CycleToolbarDisplay); // hidden + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // micro + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // hidden assert!(!state.toolbar_visible()); assert!(state.toolbar_top_pinned()); state.take_pending_toolbar_persistence(); // drain the cycle's display-mode write - state.handle_action(Action::ToggleToolbar); // show: unfolds Hidden → Full + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // show: unfolds Hidden → Full assert!(state.toolbar_visible()); assert_eq!(state.top_display_state(), TopDisplayMode::Full); assert!(state.toolbar_top_pinned()); @@ -204,11 +253,18 @@ fn cycle_hidden_show_with_unchanged_pins_queues_no_persistence() { /// additional persistence and queues nothing. #[test] fn hide_with_an_already_unpinned_strip_queues_no_persistence() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.set_toolbar_top_pinned(false); assert!(state.toolbar_visible()); - state.handle_action(Action::ToggleToolbar); // hide + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // hide assert!(!state.toolbar_visible()); assert!(!state.toolbar_top_pinned()); assert!( @@ -231,7 +287,7 @@ fn presenter_swallowed_toggle_leaves_pins_and_persistence_untouched() { state.toggle_presenter_mode_with_resources(route_resources); assert!(!state.toolbar_visible()); - state.handle_action(Action::ToggleToolbar); + state.handle_action_with_resources(route_resources, Action::ToggleToolbar); assert!(state.toolbar_top_pinned()); assert!( !state.has_pending_toolbar_persistence(), @@ -245,6 +301,13 @@ fn presenter_swallowed_toggle_leaves_pins_and_persistence_untouched() { /// flags or queue the visibility persistence the explicit toggle uses. #[test] fn focus_and_presenter_transitions_never_queue_pin_persistence() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.presenter_mode_config_mut_for_test().hide_toolbars = true; @@ -254,7 +317,7 @@ fn focus_and_presenter_transitions_never_queue_pin_persistence() { Action::TogglePresenterMode, Action::TogglePresenterMode, ] { - state.handle_action(action); + state.handle_action_with_resources(test_text_resources, action); assert!( state.toolbar_top_pinned(), "{action:?} must not touch the pin overrides" @@ -277,13 +340,20 @@ fn focus_and_presenter_transitions_never_queue_pin_persistence() { /// (correctly) drops. #[test] fn a_toggle_and_a_cycle_in_one_batch_both_keep_their_persistence() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); assert!(state.toolbar_top_pinned()); - state.handle_action(Action::CycleToolbarDisplay); // micro + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // micro state.take_pending_toolbar_persistence(); // drain the setup cycle's write - state.handle_action(Action::ToggleToolbar); // F9 hide - state.handle_action(Action::CycleToolbarDisplay); // F2: unfolds to full + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // F9 hide + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // F2: unfolds to full assert_eq!( state.take_pending_toolbar_persistence(), @@ -304,10 +374,17 @@ fn a_toggle_and_a_cycle_in_one_batch_both_keep_their_persistence() { /// persistence queue, and neither may cost the other its delivery. #[test] fn visibility_persistence_survives_a_capture_request() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::ToggleToolbar); // F9 hide - state.handle_action(Action::CaptureFileFull); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // F9 hide + state.handle_action_with_resources(test_text_resources, Action::CaptureFileFull); assert_eq!( state.take_pending_backend_action(), Some(PendingBackendAction::Screenshot(Action::CaptureFileFull)), @@ -322,8 +399,8 @@ fn visibility_persistence_survives_a_capture_request() { ); // The reverse order: capture first, then the toggle (a show this time). - state.handle_action(Action::CaptureFileFull); - state.handle_action(Action::ToggleToolbar); // F9 show + state.handle_action_with_resources(test_text_resources, Action::CaptureFileFull); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // F9 show assert_eq!( state.take_pending_backend_action(), Some(PendingBackendAction::Screenshot(Action::CaptureFileFull)), @@ -344,11 +421,18 @@ fn visibility_persistence_survives_a_capture_request() { /// write would be byte-identical to its own rollback. #[test] fn a_toggle_burst_coalesces_to_the_original_rollback_baseline() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); assert!(state.toolbar_top_pinned()); - state.handle_action(Action::ToggleToolbar); // hide - state.handle_action(Action::ToggleToolbar); // show: pins back where they started + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // hide + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // show: pins back where they started assert!(state.toolbar_top_pinned()); assert!( @@ -366,10 +450,17 @@ fn a_toggle_burst_coalesces_to_the_original_rollback_baseline() { /// side may drop a queued entry when the same batch also requests an exit. #[test] fn an_exit_request_does_not_clear_queued_toolbar_persistence() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::ToggleToolbar); // F9 hide - state.handle_action(Action::Exit); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // F9 hide + state.handle_action_with_resources(test_text_resources, Action::Exit); assert!(state.should_exit, "the exit request must have landed"); assert_eq!( @@ -383,10 +474,17 @@ fn an_exit_request_does_not_clear_queued_toolbar_persistence() { #[test] fn micro_form_survives_a_visibility_toggle() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::CycleToolbarDisplay); // micro - state.handle_action(Action::ToggleToolbar); // hide all - state.handle_action(Action::ToggleToolbar); // show all + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // micro + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // hide all + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); // show all assert_eq!( state.top_display_state(), TopDisplayMode::Micro, @@ -396,10 +494,17 @@ fn micro_form_survives_a_visibility_toggle() { #[test] fn hidden_cycle_toast_offers_a_show_action() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); refresh_status_hud_layout(&mut state); - state.handle_action(Action::CycleToolbarDisplay); // micro - state.handle_action(Action::CycleToolbarDisplay); // hidden + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // micro + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // hidden let toast = state.active_toast().expect("hidden toast"); assert_eq!(toast.message, "Toolbar: hidden"); let action = toast.action.as_ref().expect("show action chip"); @@ -410,18 +515,25 @@ fn hidden_cycle_toast_offers_a_show_action() { #[test] fn hiding_the_last_chrome_surface_warns_with_recovery_bindings() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); refresh_status_hud_layout(&mut state); // F9 alone hides every toolbar surface. The status bar is // still up, so its hint chip covers recovery — no warning yet. - state.handle_action(Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!( state.active_toast().is_none(), "no warning while the status bar remains" ); // Hiding the status bar too removes the last interactive chrome. - state.handle_action(Action::ToggleStatusBar); + state.handle_action_with_resources(test_text_resources, Action::ToggleStatusBar); let toast = state.active_toast().expect("all-chrome warning"); assert!( toast.message.starts_with("All UI hidden"), @@ -439,6 +551,13 @@ fn hiding_the_last_chrome_surface_warns_with_recovery_bindings() { #[test] fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); refresh_status_hud_layout(&mut state); assert!(state.status_hud_layout().is_some()); @@ -478,7 +597,7 @@ fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { false )); - state.handle_action(Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); let toast = state.active_toast().expect("all-chrome warning"); assert!(toast.message.starts_with("All UI hidden")); @@ -531,6 +650,13 @@ fn width_shed_content_never_reports_an_effectively_visible_hud() { #[test] fn toolbar_hint_prevents_a_false_all_chrome_warning_when_it_becomes_visible() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); for item in StatusBarItem::ALL { state.set_status_bar_item_visible_with_engine( @@ -545,7 +671,7 @@ fn toolbar_hint_prevents_a_false_all_chrome_warning_when_it_becomes_visible() { "the hint is absent while a toolbar surface is visible" ); - state.handle_action(Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); assert!(!state.toolbar_visible()); assert!( @@ -560,15 +686,22 @@ fn toolbar_hint_prevents_a_false_all_chrome_warning_when_it_becomes_visible() { #[test] fn all_chrome_warning_fires_from_the_cycle_path_and_supersedes_its_toast() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::ToggleStatusBar); + state.handle_action_with_resources(test_text_resources, Action::ToggleStatusBar); assert!( state.active_toast().is_none(), "toolbar still up: no warning" ); - state.handle_action(Action::CycleToolbarDisplay); // micro - state.handle_action(Action::CycleToolbarDisplay); // hidden: last chrome + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // micro + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // hidden: last chrome let toast = state.active_toast().expect("toast"); assert!( toast.message.starts_with("All UI hidden"), @@ -648,7 +781,7 @@ fn all_chrome_warning_suppressed_while_presenting() { // Hiding the status bar now leaves no chrome, but presenter mode hides // chrome by design and restores it on exit — no nag mid-presentation. - state.handle_action(Action::ToggleStatusBar); + state.handle_action_with_resources(route_resources, Action::ToggleStatusBar); assert!(!state.ui_visibility.show_status_bar); assert!( state.active_toast().is_none(), @@ -694,14 +827,14 @@ fn presenter_owned_hidden_toolbar_falls_back_to_status_bar_recovery() { ui_engine: &route_ui_engine, }; let mut state = create_test_input_state(); - state.handle_action(Action::ToggleToolbar); + state.handle_action_with_resources(route_resources, Action::ToggleToolbar); assert!(!state.toolbar_visible()); state.presenter_mode_config_mut_for_test().hide_toolbars = true; state.presenter_mode_config_mut_for_test().hide_status_bar = false; state.presenter_mode_config_mut_for_test().show_toast = false; state.toggle_presenter_mode_with_resources(route_resources); - state.handle_action(Action::ToggleStatusBar); + state.handle_action_with_resources(route_resources, Action::ToggleStatusBar); let toast = state.active_toast().expect("all-chrome warning"); assert!(toast.message.starts_with("All UI hidden")); @@ -712,6 +845,13 @@ fn presenter_owned_hidden_toolbar_falls_back_to_status_bar_recovery() { #[test] fn context_menu_offers_recovery_entries_only_while_chrome_hidden() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Canvas, None); let labels = |state: &InputState| -> Vec { @@ -728,8 +868,8 @@ fn context_menu_offers_recovery_entries_only_while_chrome_hidden() { .any(|label| label == "Show Status Bar") ); - state.handle_action(Action::ToggleToolbar); - state.handle_action(Action::ToggleStatusBar); + state.handle_action_with_resources(test_text_resources, Action::ToggleToolbar); + state.handle_action_with_resources(test_text_resources, Action::ToggleStatusBar); assert!(labels(&state).iter().any(|label| label == "Show Toolbar")); assert!( labels(&state) @@ -777,7 +917,7 @@ fn presenter_mode_gates_the_cycle_like_toggle_toolbar() { state.toggle_presenter_mode_with_resources(route_resources); assert!(!state.toolbar_top_visible()); - state.handle_action(Action::CycleToolbarDisplay); + state.handle_action_with_resources(route_resources, Action::CycleToolbarDisplay); assert!( !state.toolbar_top_visible(), "presenter mode owns toolbar visibility" @@ -815,7 +955,7 @@ fn presenter_mode_gates_the_micro_chip_event_like_the_cycle_action() { // After presenter exit the chip works again. state.toggle_presenter_mode_with_resources(route_resources); assert!(!state.presenter_mode_active()); - state.handle_action(Action::CycleToolbarDisplay); // micro + state.handle_action_with_resources(route_resources, Action::CycleToolbarDisplay); // micro assert_eq!(state.top_display_state(), TopDisplayMode::Micro); assert!(state.apply_toolbar_event(ToolbarEvent::SetTopDisplayMode(TopDisplayMode::Full))); assert_eq!(state.top_display_state(), TopDisplayMode::Full); @@ -832,6 +972,13 @@ fn presenter_mode_gates_the_micro_chip_event_like_the_cycle_action() { /// autosave clobber it. #[test] fn session_independent_chrome_actions_never_mark_the_session_dirty() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); // Chrome only: with the tool behavior left at its default, presenter mode // would also take the tool override, which *is* session content. @@ -860,7 +1007,7 @@ fn session_independent_chrome_actions_never_mark_the_session_dirty() { Action::TogglePresenterMode, Action::TogglePresenterMode, ] { - state.handle_action(action); + state.handle_action_with_resources(test_text_resources, action); assert!( !state.is_session_dirty(), "{action:?} moves chrome the session file does not carry" @@ -868,7 +1015,7 @@ fn session_independent_chrome_actions_never_mark_the_session_dirty() { } hide_all_chrome(&mut state); - state.handle_action(Action::ToggleFocusMode); // rescue arm + state.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); // rescue arm assert!(state.ui_visibility.show_status_bar); assert!( !state.is_session_dirty(), @@ -1023,8 +1170,15 @@ fn run_only_toolbar_preference_events_never_mark_the_session_dirty() { #[test] fn micro_chip_event_restores_the_full_strip() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); - state.handle_action(Action::CycleToolbarDisplay); // micro + state.handle_action_with_resources(test_text_resources, Action::CycleToolbarDisplay); // micro assert!( state.apply_toolbar_event(crate::ui::toolbar::ToolbarEvent::SetTopDisplayMode( TopDisplayMode::Full diff --git a/src/input/state/tests/transform.rs b/src/input/state/tests/transform.rs index 2cf8300a7..22f361c23 100644 --- a/src/input/state/tests/transform.rs +++ b/src/input/state/tests/transform.rs @@ -46,6 +46,8 @@ fn translate_selection_with_undo_moves_shape() { #[test] fn resizing_selection_marks_previous_live_bounds_dirty() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -63,7 +65,8 @@ fn resizing_selection_marks_previous_live_bounds_dirty() { .expect("selection should have bounds"); let snapshots = state.capture_resize_selection_snapshots(); - state.apply_selection_resize( + state.apply_selection_resize_with( + &test_text_measurer, SelectionHandle::BottomRight, &original_bounds, 80, @@ -73,7 +76,8 @@ fn resizing_selection_marks_previous_live_bounds_dirty() { let expanded_bounds = state.selection_bounds().expect("selection should resize"); let _ = state.take_dirty_regions(); - state.apply_selection_resize( + state.apply_selection_resize_with( + &test_text_measurer, SelectionHandle::BottomRight, &original_bounds, 10, @@ -101,6 +105,8 @@ fn resizing_selection_marks_previous_live_bounds_dirty() { #[test] fn resizing_selection_back_to_start_restores_original_geometry() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -118,7 +124,8 @@ fn resizing_selection_back_to_start_restores_original_geometry() { .expect("selection should have bounds"); let snapshots = state.capture_resize_selection_snapshots(); - state.apply_selection_resize( + state.apply_selection_resize_with( + &test_text_measurer, SelectionHandle::BottomRight, &original_bounds, 80, @@ -128,7 +135,8 @@ fn resizing_selection_back_to_start_restores_original_geometry() { let expanded_bounds = state.selection_bounds().expect("selection should resize"); let _ = state.take_dirty_regions(); - state.apply_selection_resize( + state.apply_selection_resize_with( + &test_text_measurer, SelectionHandle::BottomRight, &original_bounds, 0, @@ -161,6 +169,13 @@ fn resizing_selection_back_to_start_restores_original_geometry() { #[test] fn move_selection_to_horizontal_edges_uses_screen_bounds() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.update_screen_dimensions(200, 100); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { @@ -174,7 +189,7 @@ fn move_selection_to_horizontal_edges_uses_screen_bounds() { }); state.set_selection(vec![shape_id]); - state.handle_action(Action::MoveSelectionToStart); + state.handle_action_with_resources(test_text_resources, Action::MoveSelectionToStart); { let frame = state.boards.active_frame(); @@ -183,7 +198,7 @@ fn move_selection_to_horizontal_edges_uses_screen_bounds() { assert_eq!(bounds.x, 0); } - state.handle_action(Action::MoveSelectionToEnd); + state.handle_action_with_resources(test_text_resources, Action::MoveSelectionToEnd); { let frame = state.boards.active_frame(); @@ -195,6 +210,13 @@ fn move_selection_to_horizontal_edges_uses_screen_bounds() { #[test] fn move_selection_to_horizontal_edges_ignores_last_axis() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.update_screen_dimensions(200, 100); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { @@ -208,8 +230,8 @@ fn move_selection_to_horizontal_edges_ignores_last_axis() { }); state.set_selection(vec![shape_id]); - state.handle_action(Action::NudgeSelectionUp); - state.handle_action(Action::MoveSelectionToStart); + state.handle_action_with_resources(test_text_resources, Action::NudgeSelectionUp); + state.handle_action_with_resources(test_text_resources, Action::MoveSelectionToStart); { let frame = state.boards.active_frame(); @@ -218,7 +240,7 @@ fn move_selection_to_horizontal_edges_ignores_last_axis() { assert_eq!(bounds.x, 0); } - state.handle_action(Action::MoveSelectionToEnd); + state.handle_action_with_resources(test_text_resources, Action::MoveSelectionToEnd); { let frame = state.boards.active_frame(); @@ -230,6 +252,13 @@ fn move_selection_to_horizontal_edges_ignores_last_axis() { #[test] fn move_selection_to_vertical_edges_explicit_actions() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.update_screen_dimensions(200, 100); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { @@ -243,7 +272,7 @@ fn move_selection_to_vertical_edges_explicit_actions() { }); state.set_selection(vec![shape_id]); - state.handle_action(Action::MoveSelectionToTop); + state.handle_action_with_resources(test_text_resources, Action::MoveSelectionToTop); { let frame = state.boards.active_frame(); @@ -252,7 +281,7 @@ fn move_selection_to_vertical_edges_explicit_actions() { assert_eq!(bounds.y, 0); } - state.handle_action(Action::MoveSelectionToBottom); + state.handle_action_with_resources(test_text_resources, Action::MoveSelectionToBottom); { let frame = state.boards.active_frame(); @@ -264,6 +293,13 @@ fn move_selection_to_vertical_edges_explicit_actions() { #[test] fn nudge_selection_large_uses_large_step() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { x: 10, @@ -276,7 +312,7 @@ fn nudge_selection_large_uses_large_step() { }); state.set_selection(vec![shape_id]); - state.handle_action(Action::NudgeSelectionDownLarge); + state.handle_action_with_resources(test_text_resources, Action::NudgeSelectionDownLarge); let frame = state.boards.active_frame(); let shape = frame.shape(shape_id).unwrap(); @@ -288,6 +324,13 @@ fn nudge_selection_large_uses_large_step() { #[test] fn nudge_selection_clamps_left_and_top_edges() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.update_screen_dimensions(100, 100); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { @@ -301,8 +344,8 @@ fn nudge_selection_clamps_left_and_top_edges() { }); state.set_selection(vec![shape_id]); - state.handle_action(Action::NudgeSelectionLeft); - state.handle_action(Action::NudgeSelectionUp); + state.handle_action_with_resources(test_text_resources, Action::NudgeSelectionLeft); + state.handle_action_with_resources(test_text_resources, Action::NudgeSelectionUp); let frame = state.boards.active_frame(); let shape = frame.shape(shape_id).unwrap(); @@ -312,6 +355,13 @@ fn nudge_selection_clamps_left_and_top_edges() { #[test] fn nudge_selection_clamps_right_and_bottom_edges() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut state = create_test_input_state(); state.update_screen_dimensions(100, 100); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { @@ -325,8 +375,8 @@ fn nudge_selection_clamps_right_and_bottom_edges() { }); state.set_selection(vec![shape_id]); - state.handle_action(Action::NudgeSelectionRight); - state.handle_action(Action::NudgeSelectionDown); + state.handle_action_with_resources(test_text_resources, Action::NudgeSelectionRight); + state.handle_action_with_resources(test_text_resources, Action::NudgeSelectionDown); let frame = state.boards.active_frame(); let shape = frame.shape(shape_id).unwrap(); @@ -339,6 +389,8 @@ fn nudge_selection_clamps_right_and_bottom_edges() { #[test] fn restore_selection_snapshots_reverts_translation() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = create_test_input_state(); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Text { x: 100, @@ -355,7 +407,7 @@ fn restore_selection_snapshots_reverts_translation() { let snapshots = state.capture_movable_selection_snapshots(); assert_eq!(snapshots.len(), 1); - assert!(state.apply_translation_to_selection(20, 30)); + assert!(state.apply_translation_to_selection_with(&test_text_measurer, 20, 30)); state.restore_selection_from_snapshots_with(&crate::draw::TextMeasurer::default(), snapshots); let frame = state.boards.active_frame(); @@ -370,6 +422,8 @@ fn restore_selection_snapshots_reverts_translation() { #[test] fn resizing_a_curved_arrow_keeps_its_style_and_curvature() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + // `Shape::scaled` rebuilds `Shape::Arrow` field by field, so a field left out // there silently resets on every resize. `style` has to survive untouched; // `bend` has to survive as an *arc*, which a non-uniform scale means is not @@ -396,7 +450,8 @@ fn resizing_a_curved_arrow_keeps_its_style_and_curvature() { .expect("selection should have bounds"); let snapshots = state.capture_resize_selection_snapshots(); - state.apply_selection_resize( + state.apply_selection_resize_with( + &test_text_measurer, SelectionHandle::BottomRight, &original_bounds, 100, @@ -427,6 +482,8 @@ fn resizing_a_curved_arrow_keeps_its_style_and_curvature() { #[test] fn stretching_a_flat_curved_arrow_downward_grows_its_arc() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + // A horizontal curved arrow's height is almost entirely its arc. Dragging // the bottom handle does not lengthen the chord, so a bend copied through // unchanged keeps exactly the bulge it had and the selection refuses to @@ -454,7 +511,8 @@ fn stretching_a_flat_curved_arrow_downward_grows_its_arc() { .expect("selection should have bounds"); let snapshots = state.capture_resize_selection_snapshots(); - state.apply_selection_resize( + state.apply_selection_resize_with( + &test_text_measurer, SelectionHandle::Bottom, &original_bounds, 0, diff --git a/src/input/state/tests/zoom_chip.rs b/src/input/state/tests/zoom_chip.rs index 99c6aa3f0..f3cb59bf5 100644 --- a/src/input/state/tests/zoom_chip.rs +++ b/src/input/state/tests/zoom_chip.rs @@ -61,6 +61,13 @@ fn zoom_chip_layout_cleared_when_actions_hidden() { #[test] fn toggle_zoom_chip_action_hides_layout_and_hit_testing() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut input = create_test_input_state(); update_chip_layout(&mut input, 1280, 720); assert!(input.zoom_chip_layout().is_some()); @@ -68,7 +75,7 @@ fn toggle_zoom_chip_action_hides_layout_and_hit_testing() { // The palette/keybinding toggle hides the chip for this run without // touching the `show_zoom_actions` toolbar preference or queueing any // durable work. - input.handle_action(crate::config::Action::ToggleZoomChip); + input.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleZoomChip); assert!(!input.zoom_chip_enabled()); assert!( input.ui_visibility.show_zoom_actions, @@ -80,7 +87,7 @@ fn toggle_zoom_chip_action_hides_layout_and_hit_testing() { assert!(!input.zoom_chip_contains(1270, 710)); // Toggling again restores it. - input.handle_action(crate::config::Action::ToggleZoomChip); + input.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleZoomChip); assert!(input.zoom_chip_enabled()); update_chip_layout(&mut input, 1280, 720); assert!(input.zoom_chip_layout().is_some()); @@ -88,6 +95,13 @@ fn toggle_zoom_chip_action_hides_layout_and_hit_testing() { #[test] fn zoom_chip_hover_tracks_buttons_and_clears_when_hidden() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut input = create_test_input_state(); update_chip_layout(&mut input, 1280, 720); let (x, y) = button_center(&input, ZoomChipButtonKind::In); @@ -98,7 +112,7 @@ fn zoom_chip_hover_tracks_buttons_and_clears_when_hidden() { assert!(input.needs_redraw, "hover transition requests a redraw"); // Runtime-hiding the chip clears hover on the next layout pass. - input.handle_action(crate::config::Action::ToggleZoomChip); + input.handle_action_with_resources(test_text_resources, crate::config::Action::ToggleZoomChip); update_chip_layout(&mut input, 1280, 720); assert_eq!(input.zoom_chip.hover, None); } @@ -231,6 +245,13 @@ fn zoom_chip_click_lock_returns_toggle_lock_when_zoomed() { #[test] fn zoom_chip_activation_records_usage_and_coach_slow_path() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + // Activating a shortcut-bound zoom action from the chip feeds the shortcut // coach the same "you could have pressed the key" slow-path signal the // toolbar and command palette record. The chip dispatches through the @@ -256,7 +277,7 @@ fn zoom_chip_activation_records_usage_and_coach_slow_path() { ); assert_eq!(input.pending_onboarding_usage.shortcut_slow_path_repeats, 1); - input.handle_action(action.expect("zoom-chip action")); + input.handle_action_with_resources(test_text_resources, action.expect("zoom-chip action")); assert_eq!(input.take_pending_zoom_action(), Some(ZoomAction::In)); assert!(input.pending_onboarding_usage.used_zoom_control); } diff --git a/src/input/state/text_resources/tests.rs b/src/input/state/text_resources/tests.rs index f62c07866..26056b241 100644 --- a/src/input/state/text_resources/tests.rs +++ b/src/input/state/text_resources/tests.rs @@ -210,3 +210,63 @@ fn explicit_precision_enter_uses_toolbar_clamping_and_escape_leaves_value_alone( assert!(!state.is_precision_entry_open()); assert_eq!(state.thickness_for_active_tool(), before); } + +#[test] +fn explicit_pointer_text_drag_rejects_wrong_release_and_damages_both_positions() { + use crate::domain::Action; + use crate::input::MouseButton; + + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + let resources = InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let (mut state, id, original) = editing_state(&measurer); + state.cancel_active_interaction_with(&measurer); + state.set_tool_override_with(&measurer, Some(Tool::Select)); + state.set_selection(vec![id]); + let before = original.bounding_box_with(&measurer).unwrap(); + let x = before.x + before.width / 2; + let y = before.y + before.height / 2; + let _ = state.take_dirty_regions(); + + state.on_mouse_press_with_canvas_and_resources(resources, MouseButton::Left, x, y, x, y); + assert!(matches!(state.state, DrawingState::MovingSelection { .. })); + state.on_mouse_motion_with_canvas_and_resources(resources, x + 300, y + 40, x + 300, y + 40); + state.on_mouse_release_with_canvas_and_resources( + resources, + MouseButton::Right, + x + 300, + y + 40, + x + 300, + y + 40, + ); + assert!(matches!(state.state, DrawingState::MovingSelection { .. })); + assert_eq!(state.boards.active_frame().undo_stack_len(), 0); + + state.on_mouse_release_with_canvas_and_resources( + resources, + MouseButton::Left, + x + 300, + y + 40, + x + 300, + y + 40, + ); + assert!(matches!(state.state, DrawingState::Idle)); + assert_eq!(state.boards.active_frame().undo_stack_len(), 1); + let after = state + .boards + .active_frame() + .shape(id) + .unwrap() + .bounding_box_with(&measurer) + .unwrap(); + assert_eq!(after.x, before.x + 300); + assert_eq!(after.y, before.y + 40); + let damage = state.take_dirty_regions(); + assert!(damage.iter().any(|rect| rect.contains(x, y))); + assert!(damage.iter().any(|rect| rect.contains(x + 300, y + 40))); + state.handle_action_with_resources(resources, Action::Undo); + assert_restored(&state, id, &original, &measurer); +} diff --git a/src/input/tablet/mod.rs b/src/input/tablet/mod.rs index 873c5de1a..bcfa1ef1e 100644 --- a/src/input/tablet/mod.rs +++ b/src/input/tablet/mod.rs @@ -233,6 +233,8 @@ mod tests { #[test] fn first_pressure_sample_replaces_unpressured_stroke_samples() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let mut state = make_state(); state.set_tool_override(Some(Tool::Pen)); @@ -241,7 +243,7 @@ mod tests { state.on_mouse_motion(10, 0); state.on_mouse_motion(20, 0); - assert!(state.replace_active_drawing_pressure_samples(2.0)); + assert!(state.replace_active_drawing_pressure_samples_with(&test_text_measurer, 2.0)); let DrawingState::Drawing { point_thicknesses, .. diff --git a/src/session/tests/snapshot.rs b/src/session/tests/snapshot.rs index c77a5fee2..044e02754 100644 --- a/src/session/tests/snapshot.rs +++ b/src/session/tests/snapshot.rs @@ -158,6 +158,13 @@ fn legacy_snapshot_without_pen_smoothing_preserves_the_configured_level() { #[test] fn snapshot_uses_pre_light_mode_tool_state() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "display-light"); options.restore_tool_state = true; @@ -174,7 +181,7 @@ fn snapshot_uses_pre_light_mode_tool_state() { let _ = input.set_thickness(14.0); input.ui_visibility.show_status_bar = true; - input.handle_action(Action::ToggleLightMode); + input.handle_action_with_resources(test_text_resources, Action::ToggleLightMode); assert!(input.light_mode_active()); assert_eq!(input.tool_override(), Some(Tool::Pen)); assert!(!input.ui_visibility.show_status_bar); @@ -272,6 +279,13 @@ fn restoring_a_session_never_carries_a_rebound_shortcut() { /// just the live one. #[test] fn restoring_a_session_leaves_focus_modes_pending_status_bar_value_alone() { + let test_text_measurer = crate::draw::TextMeasurer::default(); + let test_ui_engine = crate::ui_text::UiTextEngine::default(); + let test_text_resources = crate::input::state::InputTextResources { + measurer: &test_text_measurer, + ui_engine: &test_ui_engine, + }; + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "display-focus-apply"); options.restore_tool_state = true; @@ -281,7 +295,7 @@ fn restoring_a_session_leaves_focus_modes_pending_status_bar_value_alone() { let mut input = dummy_input_state(); input.ui_visibility.show_status_bar = true; - input.handle_action(Action::ToggleFocusMode); + input.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!(input.focus_mode_active()); assert!(!input.ui_visibility.show_status_bar); @@ -292,7 +306,7 @@ fn restoring_a_session_leaves_focus_modes_pending_status_bar_value_alone() { "session restore must not reveal chrome through Focus Mode" ); - input.handle_action(Action::ToggleFocusMode); + input.handle_action_with_resources(test_text_resources, Action::ToggleFocusMode); assert!( input.ui_visibility.show_status_bar, "leaving Focus Mode returns this run's own value, not one from a session file" From 0ffb2c6c584e3ea2399c258cd12e0a4d1fd11592 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:07:18 +0200 Subject: [PATCH 35/42] refactor(input): remove retired measurement adapters --- src/backend/wayland/session/tests.rs | 2 +- src/input/state/core/index.rs | 15 ------ .../state/core/selection_actions/spotlight.rs | 6 +-- .../core/selection_actions/text/handles.rs | 6 +-- src/input/state/tests/erase.rs | 5 +- src/input/state/tests/spotlight.rs | 50 +++++++++++++------ src/input/state/tests/text_edit/resize.rs | 3 +- src/input/state/tests/text_input/editing.rs | 4 +- 8 files changed, 47 insertions(+), 44 deletions(-) diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index 7f20e07db..8fe74b886 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -1409,7 +1409,7 @@ fn runtime_open_current_save_failure_preserves_spatial_index_for_active_selectio color: input.style.current_color, thick: input.style.current_thickness, }); - input.ensure_spatial_index_for_active_frame(); + input.ensure_spatial_index_for_active_frame_with(&measurer); assert!(input.has_spatial_index()); input.set_selection(vec![shape_id]); diff --git a/src/input/state/core/index.rs b/src/input/state/core/index.rs index 7ee69fc23..f20a143ae 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -32,17 +32,6 @@ impl InputState { self.hit_test_all_for_points_cached_with(measurer, points, tolerance) } - /// Returns all shapes intersecting any of the provided points using cached spatial data. - pub(crate) fn hit_test_all_for_points_cached( - &self, - points: &[(i32, i32)], - tolerance: f64, - ) -> Vec { - with_legacy_measurer(|measurer| { - self.hit_test_all_for_points_cached_with(measurer, points, tolerance) - }) - } - pub(crate) fn hit_test_all_for_points_cached_with( &self, measurer: &TextMeasurer, @@ -119,10 +108,6 @@ impl InputState { self.canvas_index.has_spatial_index() } - pub(crate) fn ensure_spatial_index_for_active_frame(&mut self) { - with_legacy_measurer(|measurer| self.ensure_spatial_index_for_active_frame_with(measurer)) - } - pub(crate) fn ensure_spatial_index_for_active_frame_with(&mut self, measurer: &TextMeasurer) { let guard = self.active_frame_order_guard(); self.canvas_index diff --git a/src/input/state/core/selection_actions/spotlight.rs b/src/input/state/core/selection_actions/spotlight.rs index 811ae903c..435c80bca 100644 --- a/src/input/state/core/selection_actions/spotlight.rs +++ b/src/input/state/core/selection_actions/spotlight.rs @@ -1,5 +1,5 @@ +use crate::draw::TextMeasurer; use crate::draw::{Shape, ShapeId}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; use crate::util::Rect; @@ -156,10 +156,6 @@ impl InputState { /// walking back through boards, frame, and shape to re-read the value it /// was just derived from is a message chain waiting to disagree with the /// knob position. - pub(crate) fn selected_spotlight_control(&self) -> Option { - with_legacy_measurer(|measurer| self.selected_spotlight_control_with(measurer)) - } - pub(crate) fn selected_spotlight_control_with( &self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/text/handles.rs b/src/input/state/core/selection_actions/text/handles.rs index c2dd08ae2..968654e88 100644 --- a/src/input/state/core/selection_actions/text/handles.rs +++ b/src/input/state/core/selection_actions/text/handles.rs @@ -1,5 +1,5 @@ +use crate::draw::TextMeasurer; use crate::draw::{Shape, ShapeId}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::InputState; use crate::util::Rect; @@ -15,10 +15,6 @@ impl InputState { Rect::new(center_x - half, center_y - half, size, size) } - pub(crate) fn selected_text_resize_handle(&self) -> Option<(ShapeId, Rect)> { - with_legacy_measurer(|measurer| self.selected_text_resize_handle_with(measurer)) - } - pub(crate) fn selected_text_resize_handle_with( &self, measurer: &TextMeasurer, diff --git a/src/input/state/tests/erase.rs b/src/input/state/tests/erase.rs index 1fa48956e..b9deeac62 100644 --- a/src/input/state/tests/erase.rs +++ b/src/input/state/tests/erase.rs @@ -264,7 +264,7 @@ fn spatial_grid_eraser_hits_after_add_move_delete() { } // Force spatial index build - state.ensure_spatial_index_for_active_frame(); + state.ensure_spatial_index_for_active_frame_with(&test_text_measurer); assert!( state.has_spatial_index(), "spatial index should be built with {} shapes", @@ -322,6 +322,7 @@ fn spatial_grid_eraser_hits_after_add_move_delete() { /// Tests that tolerance larger than cell size still finds shapes. #[test] fn spatial_grid_large_tolerance_finds_distant_shapes() { + let test_text_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.set_hit_test_threshold(2); state.set_hit_test_tolerance(100.0); // Larger than cell size (64) @@ -344,7 +345,7 @@ fn spatial_grid_large_tolerance_finds_distant_shapes() { }); } - state.ensure_spatial_index_for_active_frame(); + state.ensure_spatial_index_for_active_frame_with(&test_text_measurer); assert!(state.has_spatial_index()); // Query point is 80 pixels away from shape, but tolerance is 100 diff --git a/src/input/state/tests/spotlight.rs b/src/input/state/tests/spotlight.rs index 081e2ba2f..790177ce9 100644 --- a/src/input/state/tests/spotlight.rs +++ b/src/input/state/tests/spotlight.rs @@ -260,10 +260,11 @@ fn assert_on_the_step_grid(value: f64) { #[test] fn every_pixel_of_the_track_snaps_to_the_same_grid_the_toolbar_uses() { + let test_text_measurer = crate::draw::TextMeasurer::default(); let (mut state, id) = spotlight_state_with_one_loupe(1.0); state.set_selection(vec![id]); let track = state - .selected_spotlight_control() + .selected_spotlight_control_with(&test_text_measurer) .expect("control") .track .track; @@ -272,7 +273,7 @@ fn every_pixel_of_the_track_snaps_to_the_same_grid_the_toolbar_uses() { // drag used to produce values like 2.19x that no other control could show. for offset in 0..=track.width { let value = state - .selected_spotlight_control() + .selected_spotlight_control_with(&test_text_measurer) .expect("control") .track .magnification_at(track.x + offset); @@ -421,15 +422,18 @@ fn a_locked_topmost_loupe_hides_an_unlocked_loupe_from_the_wheel() { #[test] fn the_on_canvas_knob_only_appears_for_one_unlocked_selected_loupe() { + let test_text_measurer = crate::draw::TextMeasurer::default(); let (mut state, id) = spotlight_state_with_one_loupe(2.0); assert!( - state.selected_spotlight_control().is_none(), + state + .selected_spotlight_control_with(&test_text_measurer) + .is_none(), "nothing selected, nothing to adjust" ); state.set_selection(vec![id]); let control = state - .selected_spotlight_control() + .selected_spotlight_control_with(&test_text_measurer) .expect("a selected loupe carries the control"); let track = control.track; assert_eq!(control.shape_id, id); @@ -443,13 +447,16 @@ fn the_on_canvas_knob_only_appears_for_one_unlocked_selected_loupe() { .expect("shape index"); state.boards.active_frame_mut().shapes[index].locked = true; assert!( - state.selected_spotlight_control().is_none(), + state + .selected_spotlight_control_with(&test_text_measurer) + .is_none(), "a locked loupe is not adjustable" ); } #[test] fn the_control_only_shows_on_a_page_that_already_forces_full_damage() { + let test_text_measurer = crate::draw::TextMeasurer::default(); // The control is drawn well outside the loupe's bounds, above it. It is // never clipped away because a page holding any Spotlight repaints in full // (`render_force_full_damage_reason`), and the control cannot appear @@ -458,14 +465,22 @@ fn the_control_only_shows_on_a_page_that_already_forces_full_damage() { let (mut state, id) = spotlight_state_with_one_loupe(2.0); state.set_selection(vec![id]); - assert!(state.selected_spotlight_control().is_some()); + assert!( + state + .selected_spotlight_control_with(&test_text_measurer) + .is_some() + ); assert!( state.has_spotlight(), "a visible control implies a spotlight, which implies full-frame damage" ); state.clear_selection(); - assert!(state.selected_spotlight_control().is_none()); + assert!( + state + .selected_spotlight_control_with(&test_text_measurer) + .is_none() + ); } #[test] @@ -627,6 +642,7 @@ fn a_toolbar_page_switch_closes_the_wheel_gesture_too() { #[test] fn a_panned_board_still_places_the_control_where_the_user_can_reach_it() { + let test_text_measurer = crate::draw::TextMeasurer::default(); // The clamp is in canvas coordinates, so it has to follow the pan. A loupe // at the top of the *visible* area needs the control flipped below it even // though its canvas y is far from zero. @@ -646,7 +662,7 @@ fn a_panned_board_still_places_the_control_where_the_user_can_reach_it() { state.set_selection(vec![loupe]); let track = state - .selected_spotlight_control() + .selected_spotlight_control_with(&test_text_measurer) .expect("a panned loupe still has a reachable control") .track .track; @@ -662,6 +678,7 @@ fn a_panned_board_still_places_the_control_where_the_user_can_reach_it() { #[test] fn maximum_persisted_view_offsets_do_not_overflow_the_selected_control_path() { + let test_text_measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.switch_board(crate::input::BOARD_ID_WHITEBOARD); state.update_screen_dimensions(1920, 1080); @@ -683,11 +700,12 @@ fn maximum_persisted_view_offsets_do_not_overflow_the_selected_control_path() { let visible = state.visible_canvas_rect(); assert_eq!((visible.x, visible.y), (i32::MAX, i32::MAX)); assert_eq!((visible.width, visible.height), (1, 1)); - let _ = state.selected_spotlight_control(); + let _ = state.selected_spotlight_control_with(&test_text_measurer); } #[test] fn an_edge_loupe_keeps_its_control_on_screen() { + let test_text_measurer = crate::draw::TextMeasurer::default(); let (mut state, _) = spotlight_state_with_one_loupe(2.0); state.boards.active_frame_mut().shapes.clear(); @@ -702,7 +720,7 @@ fn an_edge_loupe_keeps_its_control_on_screen() { }); state.set_selection(vec![corner]); let track = state - .selected_spotlight_control() + .selected_spotlight_control_with(&test_text_measurer) .expect("an edge loupe still has a reachable control") .track; assert!(track.track.x >= 0, "the left end must stay grabbable"); @@ -726,7 +744,10 @@ fn an_edge_loupe_keeps_its_control_on_screen() { magnification: 4.0, }); state.set_selection(vec![right]); - let track = state.selected_spotlight_control().expect("control").track; + let track = state + .selected_spotlight_control_with(&test_text_measurer) + .expect("control") + .track; assert!( track.track.x + track.track.width <= 1920, "the right end must stay grabbable, got x={}", @@ -752,7 +773,7 @@ fn extreme_loupe_coordinates_do_not_overflow_the_hit_test_or_the_track() { // wrap in release; failing to place the control is the correct outcome. let _ = state.spotlight_at(i32::MAX, i32::MIN); state.set_selection(vec![id]); - let _ = state.selected_spotlight_control(); + let _ = state.selected_spotlight_control_with(&test_text_measurer); let _ = state.hit_spotlight_magnification_track_with(&test_text_measurer, 0, 0); } @@ -768,7 +789,7 @@ fn dragging_the_knob_magnifies_live_and_commits_one_undo_entry() { let (mut state, id) = spotlight_state_with_one_loupe(1.0); state.set_selection(vec![id]); let track = state - .selected_spotlight_control() + .selected_spotlight_control_with(&test_text_measurer) .expect("control") .track .track; @@ -806,10 +827,11 @@ fn dragging_the_knob_magnifies_live_and_commits_one_undo_entry() { #[test] fn cancelling_a_knob_drag_restores_the_factor_it_started_from() { + let test_text_measurer = crate::draw::TextMeasurer::default(); let (mut state, id) = spotlight_state_with_one_loupe(2.0); state.set_selection(vec![id]); let track = state - .selected_spotlight_control() + .selected_spotlight_control_with(&test_text_measurer) .expect("control") .track .track; diff --git a/src/input/state/tests/text_edit/resize.rs b/src/input/state/tests/text_edit/resize.rs index 87f3fe74f..f31a95529 100644 --- a/src/input/state/tests/text_edit/resize.rs +++ b/src/input/state/tests/text_edit/resize.rs @@ -2,6 +2,7 @@ use super::*; #[test] fn dragging_text_resize_handle_updates_wrap_width_within_screen() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.update_screen_dimensions(300, 200); let shape_id = state.boards.active_frame_mut().add_shape(Shape::Text { @@ -17,7 +18,7 @@ fn dragging_text_resize_handle_updates_wrap_width_within_screen() { state.set_selection(vec![shape_id]); let (_, handle) = state - .selected_text_resize_handle() + .selected_text_resize_handle_with(&measurer) .expect("expected resize handle"); let handle_x = handle.x + handle.width / 2; let handle_y = handle.y + handle.height / 2; diff --git a/src/input/state/tests/text_input/editing.rs b/src/input/state/tests/text_input/editing.rs index e020c2320..4b43399ef 100644 --- a/src/input/state/tests/text_input/editing.rs +++ b/src/input/state/tests/text_input/editing.rs @@ -633,7 +633,9 @@ fn click_after_visible_preedit_maps_back_to_the_committed_buffer() { .style .font_descriptor .to_pango_string(state.style.current_font_size); - let geometry = crate::draw::shape::caret_geometry_text(preview, &font, None, 11) + let measurer = crate::draw::TextMeasurer::default(); + let geometry = measurer + .caret_geometry_text(preview, &font, None, 11) .expect("preview caret geometry is measurable"); let click_x = geometry.x.round() as i32; state.on_mouse_press_with_canvas(MouseButton::Left, click_x, 0, click_x, 0); From 41d92870b8743cfc710ddc71b4b016844511d53e Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:08:25 +0200 Subject: [PATCH 36/42] refactor(render): retain shape measurement across canvas painting --- src/backend/wayland/state/canvas_layer.rs | 10 ++- .../wayland/state/render/canvas/mod.rs | 12 ++- .../wayland/state/render/canvas/overlays.rs | 50 ++++++++--- .../state/render/canvas/resource_tests.rs | 35 +++++--- .../wayland/state/render/canvas/text.rs | 16 ++-- src/backend/wayland/state/render/runtime.rs | 21 +++-- src/backend/wayland/state/render/ui.rs | 3 +- src/draw/mod.rs | 2 +- src/draw/render/mod.rs | 2 +- src/draw/render/text.rs | 78 +++++------------ src/draw/shape/mod.rs | 2 +- src/draw/shape/text_cache.rs | 16 ---- src/draw/shape/text_cache/cursor.rs | 5 +- src/draw/shape/text_cache/tests.rs | 23 +++-- src/input/state/render.rs | 79 +++++++++++++---- src/ui/board_picker.rs | 4 + src/ui/board_picker/page_panel.rs | 4 + .../page_panel/thumbnail/cards.rs | 4 + .../page_panel/thumbnail/content.rs | 87 +++++++++++++------ src/ui/board_picker/tests.rs | 33 ++++++- 20 files changed, 318 insertions(+), 168 deletions(-) diff --git a/src/backend/wayland/state/canvas_layer.rs b/src/backend/wayland/state/canvas_layer.rs index c59a3f58c..c7a99a45a 100644 --- a/src/backend/wayland/state/canvas_layer.rs +++ b/src/backend/wayland/state/canvas_layer.rs @@ -92,6 +92,7 @@ impl CanvasLayerCache { /// Renders one committed shape with the standard eraser/blur replay handling. /// Shared between the direct canvas render path and the layer-cache bake. pub(in crate::backend::wayland) fn render_committed_shape( + measurer: &crate::draw::TextMeasurer, render: &mut crate::draw::RenderCtx<'_, '_>, drawn_shape: &crate::draw::DrawnShape, replay_ctx: &crate::draw::EraserReplayContext<'_>, @@ -123,7 +124,7 @@ pub(in crate::backend::wayland) fn render_committed_shape( ); } other => { - render.render_shape_with_halo(other, text_halo_enabled); + render.render_shape_with_halo_with_measurer(measurer, other, text_halo_enabled); } } } @@ -168,8 +169,9 @@ impl WaylandState { ); let generation = self.input_state.canvas_content_generation(); let frame = self.input_state.boards.active_frame(); - let (cache, draw_caches) = self.render.canvas_draw_parts_mut(); + let (cache, draw_caches, measurer) = self.render.canvas_draw_parts_mut(); cache.ensure( + measurer, draw_caches, &frame.shapes, CanvasLayerInputs { @@ -201,6 +203,7 @@ pub(super) struct CanvasLayerInputs { impl CanvasLayerCache { pub(super) fn ensure( &mut self, + measurer: &crate::draw::TextMeasurer, draw_caches: &mut crate::draw::RenderCaches, shapes: &[crate::draw::DrawnShape], inputs: CanvasLayerInputs, @@ -318,10 +321,11 @@ impl CanvasLayerCache { caches: draw_caches, }; for drawn_shape in shapes { - if let Some(bbox) = drawn_shape.bounding_box() + if let Some(bbox) = drawn_shape.bounding_box_with(measurer) && rects_intersect(bbox, bake_bounds) { render_committed_shape( + measurer, &mut render, drawn_shape, &replay_ctx, diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index 23058abac..6db651a40 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -153,8 +153,9 @@ impl WaylandState { let replay_ctx = eraser_ctx.replay_context(); let completed_shapes_start = perf.as_ref().map(|_| Instant::now()); - let (layer_cache, draw_caches) = self.render.canvas_draw_parts_mut(); + let (layer_cache, draw_caches, measurer) = self.render.canvas_draw_parts_mut(); render_committed_canvas_shapes( + measurer, &self.input_state.boards.active_frame().shapes, layer_cache, draw_caches, @@ -287,13 +288,15 @@ impl WaylandState { let provisional = self.input_state.provisional_tool_stroke(mx, my); let provisional_points = provisional_point_count(&provisional); let provisional_start = perf.as_ref().map(|_| Instant::now()); - let mut render = crate::draw::RenderCtx::new(ctx, self.render.draw_caches_mut()); + let (caches, measurer) = self.render.draw_text_parts_mut(); + let mut render = crate::draw::RenderCtx::new(ctx, caches); let rendered_provisional = match provisional { crate::input::tool::ProvisionalToolStroke::BlurReplayPreview(params) => { render.render_blur_rect(params, &replay_ctx); true } _ => self.input_state.render_provisional_shape_for_damage( + measurer, &mut render, mx, my, @@ -330,7 +333,9 @@ impl WaylandState { } } +#[allow(clippy::too_many_arguments)] fn render_committed_canvas_shapes( + measurer: &crate::draw::TextMeasurer, shapes: &[crate::draw::DrawnShape], layer_cache: &super::super::canvas_layer::CanvasLayerCache, draw_caches: &mut crate::draw::RenderCaches, @@ -363,6 +368,7 @@ fn render_committed_canvas_shapes( }; let mut render_shape = |shape: &crate::draw::DrawnShape| { super::super::canvas_layer::render_committed_shape( + measurer, &mut render, shape, replay_ctx, @@ -387,7 +393,7 @@ fn render_committed_canvas_shapes( let mut shapes_rendered = 0usize; for shape in shapes { if shape - .bounding_box() + .bounding_box_with(measurer) .is_some_and(|bounds| rects_intersect(bounds, safe_bounds)) { render_shape(shape); diff --git a/src/backend/wayland/state/render/canvas/overlays.rs b/src/backend/wayland/state/render/canvas/overlays.rs index eaa8ee2de..740aaea34 100644 --- a/src/backend/wayland/state/render/canvas/overlays.rs +++ b/src/backend/wayland/state/render/canvas/overlays.rs @@ -8,7 +8,11 @@ impl WaylandState { let frame = self.input_state.boards.active_frame(); for drawn in &frame.shapes { if selected.contains(&drawn.id) { - crate::draw::render_selection_halo(ctx, drawn); + crate::draw::render_selection_halo_with_measurer( + self.render.text_measurer(), + ctx, + drawn, + ); } } !selected.is_empty() @@ -22,7 +26,9 @@ impl WaylandState { self.input_state.state, DrawingState::Idle | DrawingState::ResizingText { .. } ) - && let Some(bounds) = self.input_state.selection_bounds() + && let Some(bounds) = self + .input_state + .selection_bounds_with(self.render.text_measurer()) { crate::draw::render_selection_handles(ctx, &bounds); } @@ -33,7 +39,9 @@ impl WaylandState { if matches!( self.input_state.state, DrawingState::Idle | DrawingState::ResizingText { .. } - ) && let Some((_shape_id, handle)) = self.input_state.selected_text_resize_handle() + ) && let Some((_shape_id, handle)) = self + .input_state + .selected_text_resize_handle_with(self.render.text_measurer()) { let _ = ctx.save(); ctx.rectangle( @@ -82,7 +90,10 @@ impl WaylandState { ) { return; } - let Some(control) = self.input_state.selected_spotlight_control() else { + let Some(control) = self + .input_state + .selected_spotlight_control_with(self.render.text_measurer()) + else { return; }; // Gated on the factor the control displays, exactly as the toolbar's @@ -121,7 +132,8 @@ impl WaylandState { && self.has_cursor_focus() && !self.cursor_blocked_by_toolbar(); if eraser_stroke && (eraser_drawing || eraser_hover) { - self.input_state.ensure_spatial_index_for_active_frame(); + self.input_state + .ensure_spatial_index_for_active_frame_with(self.render.text_measurer()); let ids = if eraser_drawing { if let DrawingState::Drawing { tool: Tool::Eraser, @@ -130,7 +142,8 @@ impl WaylandState { } = &self.input_state.state { let sampled = self.input_state.sample_eraser_path_points(points); - self.input_state.hit_test_all_for_points_cached( + self.input_state.hit_test_all_for_points_cached_with( + self.render.text_measurer(), &sampled, self.input_state.eraser_hit_radius(), ) @@ -139,15 +152,22 @@ impl WaylandState { } } else { let point = [(mx, my)]; - self.input_state - .hit_test_all_for_points_cached(&point, self.input_state.eraser_hit_radius()) + self.input_state.hit_test_all_for_points_cached_with( + self.render.text_measurer(), + &point, + self.input_state.eraser_hit_radius(), + ) }; if !ids.is_empty() { let frame = self.input_state.boards.active_frame(); match ids.len() { 1 => { if let Some(drawn) = frame.shape(ids[0]) { - crate::draw::render_selection_halo(ctx, drawn); + crate::draw::render_selection_halo_with_measurer( + self.render.text_measurer(), + ctx, + drawn, + ); } } 2..=4 => { @@ -180,7 +200,11 @@ impl WaylandState { } for &index in &indices[..count] { if let Some(drawn) = frame.shapes.get(index) { - crate::draw::render_selection_halo(ctx, drawn); + crate::draw::render_selection_halo_with_measurer( + self.render.text_measurer(), + ctx, + drawn, + ); } } } @@ -188,7 +212,11 @@ impl WaylandState { let hover_ids: HashSet<_> = ids.into_iter().collect(); for drawn in &frame.shapes { if hover_ids.contains(&drawn.id) { - crate::draw::render_selection_halo(ctx, drawn); + crate::draw::render_selection_halo_with_measurer( + self.render.text_measurer(), + ctx, + drawn, + ); } } } diff --git a/src/backend/wayland/state/render/canvas/resource_tests.rs b/src/backend/wayland/state/render/canvas/resource_tests.rs index 90bf24e55..afd084588 100644 --- a/src/backend/wayland/state/render/canvas/resource_tests.rs +++ b/src/backend/wayland/state/render/canvas/resource_tests.rs @@ -71,6 +71,7 @@ fn shapes() -> Vec { } fn paint( + measurer: &crate::draw::TextMeasurer, shapes: &[DrawnShape], layer: &CanvasLayerCache, caches: &mut crate::draw::RenderCaches, @@ -118,7 +119,9 @@ fn paint( logical_image_origin_x: 0.0, logical_image_origin_y: 0.0, }; - render_committed_canvas_shapes(shapes, layer, caches, &canvas, cached, &replay, None); + render_committed_canvas_shapes( + measurer, shapes, layer, caches, &canvas, cached, &replay, None, + ); } surface.flush(); surface.data().unwrap().to_vec() @@ -137,14 +140,16 @@ fn assert_pixels_match(actual: &[u8], expected: &[u8], label: &str) { } fn fresh_baked(shapes: &[DrawnShape], request: CanvasLayerInputs) -> Vec { + let measurer = crate::draw::TextMeasurer::default(); let mut layer = CanvasLayerCache::new(); let mut caches = crate::draw::RenderCaches::default(); - assert!(layer.ensure(&mut caches, shapes, request)); - paint(shapes, &layer, &mut caches, request, true) + assert!(layer.ensure(&measurer, &mut caches, shapes, request)); + paint(&measurer, shapes, &layer, &mut caches, request, true) } #[test] fn baked_and_direct_passes_match_fresh_owners_across_reuse_and_invalidation() { + let measurer = crate::draw::TextMeasurer::default(); let mut layer = CanvasLayerCache::new(); let mut caches = crate::draw::RenderCaches::default(); let mut shapes = shapes(); @@ -192,9 +197,9 @@ fn baked_and_direct_passes_match_fresh_owners_across_reuse_and_invalidation() { fill: true, }); } - assert!(layer.ensure(&mut caches, &shapes, request)); - let baked = paint(&shapes, &layer, &mut caches, request, true); - let direct = paint(&shapes, &layer, &mut caches, request, false); + assert!(layer.ensure(&measurer, &mut caches, &shapes, request)); + let baked = paint(&measurer, &shapes, &layer, &mut caches, request, true); + let direct = paint(&measurer, &shapes, &layer, &mut caches, request, false); // Direct eraser edges retain partial alpha; a baked surface is later // composited over the background. Compare each established rendering // route to itself with fresh resources, not to the other route. @@ -205,6 +210,7 @@ fn baked_and_direct_passes_match_fresh_owners_across_reuse_and_invalidation() { ); let mut fresh = crate::draw::RenderCaches::default(); let expected_direct = paint( + &crate::draw::TextMeasurer::default(), &shapes, &CanvasLayerCache::new(), &mut fresh, @@ -221,12 +227,14 @@ fn baked_and_direct_passes_match_fresh_owners_across_reuse_and_invalidation() { #[test] fn rejected_bake_clears_previous_layer_and_direct_fallback_still_paints() { + let measurer = crate::draw::TextMeasurer::default(); let mut layer = CanvasLayerCache::new(); let mut caches = crate::draw::RenderCaches::default(); let shapes = shapes(); let request = inputs(); - assert!(layer.ensure(&mut caches, &shapes, request)); + assert!(layer.ensure(&measurer, &mut caches, &shapes, request)); assert!(!layer.ensure( + &measurer, &mut caches, &shapes, CanvasLayerInputs { @@ -237,14 +245,15 @@ fn rejected_bake_clears_previous_layer_and_direct_fallback_still_paints() { let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1, 1).unwrap(); assert!(!layer.blit(&cairo::Context::new(&surface).unwrap())); assert_pixels_match( - &paint(&shapes, &layer, &mut caches, request, true), - &paint(&shapes, &layer, &mut caches, request, false), + &paint(&measurer, &shapes, &layer, &mut caches, request, true), + &paint(&measurer, &shapes, &layer, &mut caches, request, false), "invalid layer falls back to direct rendering", ); } #[test] fn each_scene_key_rebakes_without_shape_identity_changes() { + let measurer = crate::draw::TextMeasurer::default(); let initial = inputs(); for (name, changed, replace_image) in [ ( @@ -296,8 +305,8 @@ fn each_scene_key_rebakes_without_shape_identity_changes() { let mut layer = CanvasLayerCache::new(); let mut caches = crate::draw::RenderCaches::default(); let mut scene = shapes(); - assert!(layer.ensure(&mut caches, &scene, initial)); - let before = paint(&scene, &layer, &mut caches, initial, true); + assert!(layer.ensure(&measurer, &mut caches, &scene, initial)); + let before = paint(&measurer, &scene, &layer, &mut caches, initial, true); if replace_image { // Shape count and IDs remain unchanged; only the scene key can // invalidate the already baked pixels for this different scene. @@ -316,8 +325,8 @@ fn each_scene_key_rebakes_without_shape_identity_changes() { fill: true, }); } - assert!(layer.ensure(&mut caches, &scene, changed)); - let actual = paint(&scene, &layer, &mut caches, changed, true); + assert!(layer.ensure(&measurer, &mut caches, &scene, changed)); + let actual = paint(&measurer, &scene, &layer, &mut caches, changed, true); let expected = fresh_baked(&scene, changed); assert!(before != expected, "fixture must change pixels for {name}"); assert_pixels_match( diff --git a/src/backend/wayland/state/render/canvas/text.rs b/src/backend/wayland/state/render/canvas/text.rs index de481f10a..996dd3f18 100644 --- a/src/backend/wayland/state/render/canvas/text.rs +++ b/src/backend/wayland/state/render/canvas/text.rs @@ -45,7 +45,8 @@ impl WaylandState { ); match self.input_state.text_editing.mode() { crate::input::TextInputMode::Plain => { - crate::draw::render_text_with_halo( + crate::draw::render_text_with_halo_with_measurer( + self.render.text_measurer(), ctx, *x, *y, @@ -59,7 +60,8 @@ impl WaylandState { ); } crate::input::TextInputMode::StickyNote => { - crate::draw::render_sticky_note_preview( + crate::draw::render_sticky_note_preview_with_measurer( + self.render.text_measurer(), ctx, *x, *y, @@ -109,7 +111,7 @@ impl WaylandState { }; let size = self.input_state.style.current_font_size; let font_desc = self.input_state.style.font_descriptor.to_pango_string(size); - let Some(geom) = crate::draw::shape::caret_geometry_text( + let Some(geom) = self.render.text_measurer().caret_geometry_text( preview_text, &font_desc, self.input_state.style.text_wrap_width, @@ -219,7 +221,8 @@ impl WaylandState { background_enabled, wrap_width, } if !text.is_empty() => { - crate::draw::render_text_with_halo( + crate::draw::render_text_with_halo_with_measurer( + self.render.text_measurer(), ctx, *x, *y, @@ -241,7 +244,8 @@ impl WaylandState { font_descriptor, wrap_width, } if !text.is_empty() => { - crate::draw::render_sticky_note( + crate::draw::render_sticky_note_with_measurer( + self.render.text_measurer(), ctx, *x, *y, @@ -260,7 +264,7 @@ impl WaylandState { let _ = ctx.restore(); // Render dashed border around ghost text bounds - if let Some(bounds) = original_shape.bounding_box() { + if let Some(bounds) = original_shape.bounding_box_with(self.render.text_measurer()) { self.render_ghost_border(ctx, bounds); } } diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index d1519cbb7..f7191edd8 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -173,10 +173,10 @@ impl RenderRuntime { &mut self.canvas_layer_cache } - pub(in crate::backend::wayland::state) fn draw_caches_mut( + pub(in crate::backend::wayland::state) fn draw_text_parts_mut( &mut self, - ) -> &mut crate::draw::RenderCaches { - &mut self.draw_caches + ) -> (&mut crate::draw::RenderCaches, &crate::draw::TextMeasurer) { + (&mut self.draw_caches, &self.text_measurer) } pub(in crate::backend::wayland::state) fn draw_ui_text_parts_mut( @@ -184,14 +184,23 @@ impl RenderRuntime { ) -> ( &mut crate::draw::RenderCaches, &crate::ui_text::UiTextEngine, + &crate::draw::TextMeasurer, ) { - (&mut self.draw_caches, &self.ui_text) + (&mut self.draw_caches, &self.ui_text, &self.text_measurer) } pub(in crate::backend::wayland::state) fn canvas_draw_parts_mut( &mut self, - ) -> (&mut CanvasLayerCache, &mut crate::draw::RenderCaches) { - (&mut self.canvas_layer_cache, &mut self.draw_caches) + ) -> ( + &mut CanvasLayerCache, + &mut crate::draw::RenderCaches, + &crate::draw::TextMeasurer, + ) { + ( + &mut self.canvas_layer_cache, + &mut self.draw_caches, + &self.text_measurer, + ) } pub(in crate::backend::wayland::state) fn ui_damage_mut(&mut self) -> &mut UiDamageHistory { diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index f25826303..8d3533aa9 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -236,10 +236,11 @@ impl WaylandState { if !capture_picker && self.input_state.is_board_picker_open() { self.input_state .update_board_picker_layout(ctx, width, height); - let (caches, engine) = self.render.draw_ui_text_parts_mut(); + let (caches, engine, measurer) = self.render.draw_ui_text_parts_mut(); let mut render = crate::draw::RenderCtx::new(ctx, caches); crate::ui::render_board_picker_with_halo( engine, + measurer, &mut render, &self.input_state, width, diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 0195751ae..802cd5dbe 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -31,7 +31,7 @@ pub(crate) use font::{ pub use frame::{DrawnShape, Frame, ShapeId}; #[allow(unused_imports)] pub(crate) use render::render_eraser_stroke; -pub(crate) use render::render_sticky_note_preview; +pub(crate) use render::render_sticky_note_preview_with_measurer; pub(crate) use render::with_saved_state; #[allow(unused_imports)] pub use render::{ diff --git a/src/draw/render/mod.rs b/src/draw/render/mod.rs index b9df8958d..abaff7bec 100644 --- a/src/draw/render/mod.rs +++ b/src/draw/render/mod.rs @@ -41,7 +41,7 @@ pub use spotlight::{ }; pub(crate) use strokes::render_eraser_stroke; pub use strokes::{render_freehand_borrowed, render_marker_stroke_borrowed}; -pub(crate) use text::render_sticky_note_preview; +pub(crate) use text::render_sticky_note_preview_with_measurer; pub use text::{ caret_line_width, caret_outline_width, render_sticky_note, render_sticky_note_with_measurer, render_text, render_text_over, render_text_over_with_halo, diff --git a/src/draw/render/text.rs b/src/draw/render/text.rs index 977f90adf..bbac70e87 100644 --- a/src/draw/render/text.rs +++ b/src/draw/render/text.rs @@ -459,32 +459,6 @@ pub fn render_sticky_note_with_measurer( /// Render the live sticky-note editor, using a measurement-only placeholder /// when its buffer is empty so the background remains visible behind the caret. -#[allow(clippy::too_many_arguments)] -pub(crate) fn render_sticky_note_preview( - ctx: &cairo::Context, - x: i32, - y: i32, - text: &str, - background: Color, - size: f64, - font_descriptor: &FontDescriptor, - wrap_width: Option, -) { - crate::draw::with_legacy_measurer(|measurer| { - render_sticky_note_preview_with_measurer( - measurer, - ctx, - x, - y, - text, - background, - size, - font_descriptor, - wrap_width, - ) - }) -} - #[allow(clippy::too_many_arguments)] pub(crate) fn render_sticky_note_preview_with_measurer( measurer: &crate::draw::TextMeasurer, @@ -616,8 +590,9 @@ fn draw_round_rect(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, r: f64) #[cfg(test)] mod tests { use super::{ - Color, FontDescriptor, caret_outline_width, render_sticky_note, render_sticky_note_preview, - render_text, render_text_with_halo, sticky_note_foreground, text_outline_color, + Color, FontDescriptor, caret_outline_width, render_sticky_note, + render_sticky_note_preview_with_measurer, render_text, render_text_with_halo, + sticky_note_foreground, text_outline_color, }; fn alpha_at(surface: &mut cairo::ImageSurface, x: i32, y: i32) -> u8 { @@ -732,17 +707,14 @@ mod tests { #[test] fn disabled_halo_keeps_the_optional_text_background() { + let measurer = crate::draw::TextMeasurer::default(); let text = "A "; let font = FontDescriptor::default(); let size = 20.0; let origin = (20, 60); - let caret = crate::draw::shape::caret_geometry_text( - text, - &font.to_pango_string(size), - None, - text.len(), - ) - .expect("trailing-space caret geometry"); + let caret = measurer + .caret_geometry_text(text, &font.to_pango_string(size), None, text.len()) + .expect("trailing-space caret geometry"); let sample_x = origin.0 + caret.x.round() as i32; let sample_y = origin.1 + (caret.y_from_baseline + caret.height / 2.0).round() as i32; let mut surface = @@ -810,10 +782,12 @@ mod tests { #[test] fn empty_sticky_note_still_draws_its_preview_background() { + let measurer = crate::draw::TextMeasurer::default(); let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 100, 100).unwrap(); { let ctx = cairo::Context::new(&surface).unwrap(); - render_sticky_note_preview( + render_sticky_note_preview_with_measurer( + &measurer, &ctx, 40, 50, @@ -856,17 +830,14 @@ mod tests { #[test] fn live_plain_text_background_covers_a_trailing_space_caret() { + let measurer = crate::draw::TextMeasurer::default(); let text = "A "; let font = FontDescriptor::default(); let size = 20.0; let origin = (20, 60); - let caret = crate::draw::shape::caret_geometry_text( - text, - &font.to_pango_string(size), - None, - text.len(), - ) - .unwrap(); + let caret = measurer + .caret_geometry_text(text, &font.to_pango_string(size), None, text.len()) + .unwrap(); let sample_x = origin.0 + caret.x.round() as i32; let sample_y = origin.1 + (caret.y_from_baseline + caret.height / 2.0).round() as i32; let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 400, 120).unwrap(); @@ -893,23 +864,21 @@ mod tests { #[test] fn live_sticky_note_background_covers_a_trailing_space_caret() { + let measurer = crate::draw::TextMeasurer::default(); let text = "A "; let font = FontDescriptor::default(); let size = 20.0; let origin = (20, 60); - let caret = crate::draw::shape::caret_geometry_text( - text, - &font.to_pango_string(size), - None, - text.len(), - ) - .unwrap(); + let caret = measurer + .caret_geometry_text(text, &font.to_pango_string(size), None, text.len()) + .unwrap(); let sample_x = origin.0 + caret.x.round() as i32; let sample_y = origin.1 + (caret.y_from_baseline + caret.height / 2.0).round() as i32; let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 400, 120).unwrap(); { let ctx = cairo::Context::new(&surface).unwrap(); - render_sticky_note_preview( + render_sticky_note_preview_with_measurer( + &measurer, &ctx, origin.0, origin.1, @@ -1004,14 +973,15 @@ mod tests { #[test] fn caret_stroke_stays_within_its_advertised_width() { + let measurer = crate::draw::TextMeasurer::default(); // The damage tracker sizes the caret's repaint from `caret_outline_width`; // the stroke is centred, so it must not reach further than half of it. let font = FontDescriptor::default(); for size in [16.0_f64, 20.0, 32.0, 48.0] { let color = Color::new(1.0, 0.5, 0.1, 1.0); - let geom = - crate::draw::shape::caret_geometry_text("hi", &font.to_pango_string(size), None, 1) - .unwrap(); + let geom = measurer + .caret_geometry_text("hi", &font.to_pango_string(size), None, 1) + .unwrap(); let origin = (150, 220); let caret_x = f64::from(origin.0) + geom.x; let top = f64::from(origin.1) + geom.y_from_baseline; diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs index 9d4b0921c..1a5c1d508 100644 --- a/src/draw/shape/mod.rs +++ b/src/draw/shape/mod.rs @@ -32,7 +32,7 @@ pub(crate) use text::{ }; pub(crate) use text_cache::{ CaretGeometry, LogicalBounds, TextMeasurement, VisualCaretDirection, VisualLineDirection, - VisualLineEdge, caret_geometry_text, configured_layout, + VisualLineEdge, configured_layout, }; #[cfg(test)] diff --git a/src/draw/shape/text_cache.rs b/src/draw/shape/text_cache.rs index c024fa8cb..d4305dcb9 100644 --- a/src/draw/shape/text_cache.rs +++ b/src/draw/shape/text_cache.rs @@ -135,22 +135,6 @@ pub(crate) struct CaretGeometry { pub height: f64, } -/// Compute the caret geometry for `byte_index` into `text` using Pango's strong -/// cursor position, so it lands correctly on wrapped and multiline text and at -/// the string end. Works for empty text (caret at the origin). `byte_index` is -/// snapped down to a char boundary. Returns `None` only when no measurement -/// context is available. -pub(crate) fn caret_geometry_text( - text: &str, - font_desc_str: &str, - wrap_width: Option, - byte_index: usize, -) -> Option { - with_legacy_measurer(|measurer| { - measurer.caret_geometry_text(text, font_desc_str, wrap_width, byte_index) - }) -} - /// Full logical bounds of a text run — the advance width and line-box height, /// including the leading/trailing whitespace and empty trailing advance that /// the ink box omits — in pixels, relative to the stored `(x, y)` baseline diff --git a/src/draw/shape/text_cache/cursor.rs b/src/draw/shape/text_cache/cursor.rs index 7b075d6ad..4274a37df 100644 --- a/src/draw/shape/text_cache/cursor.rs +++ b/src/draw/shape/text_cache/cursor.rs @@ -167,6 +167,9 @@ impl TextMeasurer { hit_position_to_byte(text, hit.index(), hit.trailing()) }) } + /// Compute the strong caret position on wrapped and multiline text, including + /// empty text and the string end. Off-boundary byte indices snap down to a + /// character boundary; missing measurement resources return `None`. pub(crate) fn caret_geometry_text( &self, text: &str, @@ -182,7 +185,7 @@ impl TextMeasurer { /// Resolve caret geometry and logical bounds together. The damage tracker needs /// both for the same text whenever a selection or composition is showing, so /// sharing one layout saves building a second one for those states. Text bounds - /// still go through `measure_text_cached`, which lays out again on a cache miss; + /// still go through `TextMeasurer::measure`, which lays out again on a cache miss; /// this only removes the duplicate pass, it does not make damage layout-free. pub(crate) fn text_preview_geometry( &self, diff --git a/src/draw/shape/text_cache/tests.rs b/src/draw/shape/text_cache/tests.rs index b3db9b137..806517a07 100644 --- a/src/draw/shape/text_cache/tests.rs +++ b/src/draw/shape/text_cache/tests.rs @@ -52,8 +52,13 @@ fn hit_test_result_is_always_a_char_boundary() { #[test] fn caret_geometry_advances_left_to_right_and_has_height() { - let start = caret_geometry_text("hello", "Sans 20", None, 0).unwrap(); - let end = caret_geometry_text("hello", "Sans 20", None, 5).unwrap(); + let measurer = crate::draw::TextMeasurer::default(); + let start = measurer + .caret_geometry_text("hello", "Sans 20", None, 0) + .unwrap(); + let end = measurer + .caret_geometry_text("hello", "Sans 20", None, 5) + .unwrap(); assert!(start.x >= 0.0); assert!( end.x > start.x, @@ -64,17 +69,25 @@ fn caret_geometry_advances_left_to_right_and_has_height() { #[test] fn caret_geometry_works_on_empty_text() { + let measurer = crate::draw::TextMeasurer::default(); // An empty buffer still needs a visible caret at the origin. - let geom = caret_geometry_text("", "Sans 20", None, 0).unwrap(); + let geom = measurer + .caret_geometry_text("", "Sans 20", None, 0) + .unwrap(); assert_eq!(geom.x, 0.0); assert!(geom.height > 0.0); } #[test] fn caret_geometry_snaps_off_boundary_indices_down() { + let measurer = crate::draw::TextMeasurer::default(); // Byte 2 is inside the 3-byte '你'; it must resolve like byte 0, not panic. - let at_zero = caret_geometry_text("你a", "Sans 20", None, 0).unwrap(); - let off_boundary = caret_geometry_text("你a", "Sans 20", None, 2).unwrap(); + let at_zero = measurer + .caret_geometry_text("你a", "Sans 20", None, 0) + .unwrap(); + let off_boundary = measurer + .caret_geometry_text("你a", "Sans 20", None, 2) + .unwrap(); assert_eq!(at_zero, off_boundary); } diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 667bb1b4c..cae55789c 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -99,6 +99,7 @@ impl InputState { pub(crate) fn render_provisional_tool_stroke( &self, + measurer: &crate::draw::TextMeasurer, render: &mut RenderCtx<'_, '_>, stroke: ProvisionalToolStroke<'_>, text_halo_enabled: bool, @@ -140,18 +141,21 @@ impl InputState { true } ProvisionalToolStroke::Shape(shape) => { - render.render_shape_with_halo(&shape, text_halo_enabled); + render.render_shape_with_halo_with_measurer(measurer, &shape, text_halo_enabled); true } ProvisionalToolStroke::BlurReplayPreview(params) => { - render.render_shape(&Shape::BlurRect { - x: params.x, - y: params.y, - w: params.w, - h: params.h, - strength: params.strength, - style: params.style, - }); + render.render_shape_with_measurer( + measurer, + &Shape::BlurRect { + x: params.x, + y: params.y, + w: params.w, + h: params.h, + strength: params.strength, + style: params.style, + }, + ); true } ProvisionalToolStroke::None => false, @@ -160,6 +164,7 @@ impl InputState { pub(crate) fn render_provisional_tool_stroke_for_damage( &self, + measurer: &crate::draw::TextMeasurer, render: &mut RenderCtx<'_, '_>, stroke: ProvisionalToolStroke<'_>, damage_regions: &[Rect], @@ -250,7 +255,9 @@ impl InputState { } true } - other => self.render_provisional_tool_stroke(render, other, text_halo_enabled), + other => { + self.render_provisional_tool_stroke(measurer, render, other, text_halo_enabled) + } } } @@ -273,7 +280,9 @@ impl InputState { current_y: i32, ) -> bool { let mut caches = RenderCaches::default(); + let measurer = crate::draw::TextMeasurer::default(); self.render_provisional_shape_with_halo( + &measurer, &mut RenderCtx::new(ctx, &mut caches), current_x, current_y, @@ -283,6 +292,7 @@ impl InputState { pub(crate) fn render_provisional_shape_with_halo( &self, + measurer: &crate::draw::TextMeasurer, render: &mut RenderCtx<'_, '_>, current_x: i32, current_y: i32, @@ -292,7 +302,7 @@ impl InputState { match &self.state { DrawingState::Drawing { .. } => { let stroke = self.provisional_tool_stroke(current_x, current_y); - self.render_provisional_tool_stroke(render, stroke, text_halo_enabled) + self.render_provisional_tool_stroke(measurer, render, stroke, text_halo_enabled) } DrawingState::Selecting { start_x, @@ -344,6 +354,7 @@ impl InputState { pub(crate) fn render_provisional_shape_for_damage( &self, + measurer: &crate::draw::TextMeasurer, render: &mut RenderCtx<'_, '_>, current_x: i32, current_y: i32, @@ -353,6 +364,7 @@ impl InputState { if matches!(self.state, DrawingState::Drawing { .. }) { let stroke = self.provisional_tool_stroke(current_x, current_y); return self.render_provisional_tool_stroke_for_damage( + measurer, render, stroke, damage_regions, @@ -360,7 +372,13 @@ impl InputState { ); } - self.render_provisional_shape_with_halo(render, current_x, current_y, text_halo_enabled) + self.render_provisional_shape_with_halo( + measurer, + render, + current_x, + current_y, + text_halo_enabled, + ) } } @@ -447,6 +465,7 @@ mod tests { #[test] fn persistent_preview_dispatch_matches_shape_rendering() { + let measurer = crate::draw::TextMeasurer::default(); let input = crate::input::state::test_support::TestInputStateBuilder::default().build(); let mut png = std::io::Cursor::new(Vec::new()); let source = cairo::ImageSurface::create(cairo::Format::ARgb32, 2, 2).unwrap(); @@ -477,16 +496,46 @@ mod tests { background_enabled: false, wrap_width: None, }, + Shape::Arrow { + x1: 110, + y1: 80, + x2: 210, + y2: 80, + color: crate::draw::RED, + thick: 3.0, + arrow_length: 12.0, + arrow_angle: 30.0, + head_at_end: true, + style: crate::draw::ArrowStyle::Standard, + bend: 0.0, + label: Some(crate::draw::ArrowLabel { + value: 72, + size: 18.0, + font_descriptor: Default::default(), + }), + }, + Shape::StepMarker { + x: 55, + y: 105, + color: crate::draw::RED, + label: crate::draw::StepMarkerLabel { + value: 108, + size: 18.0, + font_descriptor: Default::default(), + }, + }, ]; let mut caches = RenderCaches::default(); for halo in [true, false, true] { - let mut actual = cairo::ImageSurface::create(cairo::Format::ARgb32, 96, 64).unwrap(); - let mut expected = cairo::ImageSurface::create(cairo::Format::ARgb32, 96, 64).unwrap(); + let mut actual = cairo::ImageSurface::create(cairo::Format::ARgb32, 240, 160).unwrap(); + let mut expected = + cairo::ImageSurface::create(cairo::Format::ARgb32, 240, 160).unwrap(); { let cairo = cairo::Context::new(&actual).unwrap(); let mut render = RenderCtx::new(&cairo, &mut caches); for shape in &shapes { assert!(input.render_provisional_tool_stroke_for_damage( + &measurer, &mut render, ProvisionalToolStroke::Shape(shape.clone()), &[], @@ -497,7 +546,7 @@ mod tests { let mut fresh = RenderCaches::default(); let mut render = RenderCtx::new(&cairo, &mut fresh); for shape in &shapes { - render.render_shape_with_halo(shape, halo); + render.render_shape_with_halo_with_measurer(&measurer, shape, halo); } } actual.flush(); diff --git a/src/ui/board_picker.rs b/src/ui/board_picker.rs index 967ee1a3d..1e30eb3c9 100644 --- a/src/ui/board_picker.rs +++ b/src/ui/board_picker.rs @@ -25,9 +25,11 @@ pub fn render_board_picker( screen_height: u32, ) { let engine = UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); let mut caches = crate::draw::RenderCaches::default(); render_board_picker_with_halo( &engine, + &measurer, &mut crate::draw::RenderCtx::new(ctx, &mut caches), input_state, screen_width, @@ -38,6 +40,7 @@ pub fn render_board_picker( pub(crate) fn render_board_picker_with_halo( engine: &UiTextEngine, + measurer: &crate::draw::TextMeasurer, render: &mut crate::draw::RenderCtx<'_, '_>, input_state: &InputState, screen_width: u32, @@ -162,6 +165,7 @@ pub(crate) fn render_board_picker_with_halo( render_page_panel( engine, + measurer, render, input_state, layout, diff --git a/src/ui/board_picker/page_panel.rs b/src/ui/board_picker/page_panel.rs index fbd764ea2..11ad5c0d2 100644 --- a/src/ui/board_picker/page_panel.rs +++ b/src/ui/board_picker/page_panel.rs @@ -26,8 +26,10 @@ use thumbnail::{ render_page_thumbnail, }; +#[allow(clippy::too_many_arguments)] pub(super) fn render_page_panel( engine: &UiTextEngine, + measurer: &crate::draw::TextMeasurer, render: &mut crate::draw::RenderCtx<'_, '_>, input_state: &InputState, layout: &BoardPickerLayout, @@ -141,6 +143,7 @@ pub(super) fn render_page_panel( }); render_page_thumbnail( engine, + measurer, PageThumbnailArgs { render, frame: page, @@ -179,6 +182,7 @@ pub(super) fn render_page_panel( let page = &pages[hover_index]; render_page_preview( engine, + measurer, PagePreviewArgs { render, frame: page, diff --git a/src/ui/board_picker/page_panel/thumbnail/cards.rs b/src/ui/board_picker/page_panel/thumbnail/cards.rs index 2388493b0..207425250 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cards.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cards.rs @@ -24,6 +24,7 @@ use super::types::{PREVIEW_SCALE, PageContentArgs, PagePreviewArgs, PageThumbnai pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail( engine: &UiTextEngine, + measurer: &crate::draw::TextMeasurer, args: PageThumbnailArgs<'_, '_, '_>, ) { let PageThumbnailArgs { @@ -63,6 +64,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail( render_page_content( engine, + measurer, PageContentArgs { render, frame, @@ -304,6 +306,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_add_page_card( pub(in crate::ui::board_picker::page_panel) fn render_page_preview( engine: &UiTextEngine, + measurer: &crate::draw::TextMeasurer, args: PagePreviewArgs<'_, '_, '_>, ) { let PagePreviewArgs { @@ -355,6 +358,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview( render_page_content( engine, + measurer, PageContentArgs { render, frame, diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs index 14fba2773..16cd1d669 100644 --- a/src/ui/board_picker/page_panel/thumbnail/content.rs +++ b/src/ui/board_picker/page_panel/thumbnail/content.rs @@ -24,7 +24,11 @@ const THUMBNAIL_SPOTLIGHT_FEATHER: f64 = 0.35; const TRANSPARENT_TINT: Rgba = (1.0, 1.0, 1.0, 0.06); const TRANSPARENT_CROSS: Rgba = (1.0, 1.0, 1.0, 0.08); -pub(super) fn render_page_content(engine: &UiTextEngine, args: PageContentArgs<'_, '_, '_>) { +pub(super) fn render_page_content( + engine: &UiTextEngine, + measurer: &crate::draw::TextMeasurer, + args: PageContentArgs<'_, '_, '_>, +) { let PageContentArgs { render, frame, @@ -75,6 +79,7 @@ pub(super) fn render_page_content(engine: &UiTextEngine, args: PageContentArgs<' ctx.scale(scale, scale); render_frame_shapes( engine, + measurer, render, frame, background, @@ -86,8 +91,10 @@ pub(super) fn render_page_content(engine: &UiTextEngine, args: PageContentArgs<' let _ = ctx.restore(); } +#[allow(clippy::too_many_arguments)] fn render_frame_shapes( engine: &UiTextEngine, + measurer: &crate::draw::TextMeasurer, render: &mut crate::draw::RenderCtx<'_, '_>, frame: &crate::draw::Frame, background: &BoardBackground, @@ -116,7 +123,11 @@ fn render_frame_shapes( render_eraser_stroke(ctx, points, brush, &eraser_ctx); } _ => { - render.render_shape_with_halo(&drawn.shape, text_halo_enabled); + render.render_shape_with_halo_with_measurer( + measurer, + &drawn.shape, + text_halo_enabled, + ); } } } @@ -291,6 +302,7 @@ mod tests { }); render_page_content( &UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), PageContentArgs { render: &mut crate::draw::RenderCtx::new( &ctx, @@ -331,6 +343,7 @@ mod tests { }); render_page_content( &UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), PageContentArgs { render: &mut crate::draw::RenderCtx::new( &ctx, @@ -421,40 +434,58 @@ mod tests { background_enabled: false, wrap_width: None, }); - let paint = |caches: &mut RenderCaches| { - let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 124, 64).unwrap(); - { - let ctx = cairo::Context::new(&surface).unwrap(); - render_page_content( - &UiTextEngine::default(), - PageContentArgs { - render: &mut RenderCtx::new(&ctx, caches), - frame: &frame, - background: &BoardBackground::Solid(crate::draw::WHITE), - x: 0.0, - y: 0.0, - width: 124.0, - height: 64.0, - screen_width: 120, - screen_height: 60, - text_halo_enabled: false, - }, - ); - } - surface.flush(); - surface.data().unwrap().to_vec() - }; + let measurer = crate::draw::TextMeasurer::default(); + let engine = UiTextEngine::default(); + let paint = + |measurer: &crate::draw::TextMeasurer, caches: &mut RenderCaches, density: i32| { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 124 * density, 64 * density) + .unwrap(); + surface.set_device_scale(density as f64, density as f64); + { + let ctx = cairo::Context::new(&surface).unwrap(); + render_page_content( + &engine, + measurer, + PageContentArgs { + render: &mut RenderCtx::new(&ctx, caches), + frame: &frame, + background: &BoardBackground::Solid(crate::draw::WHITE), + x: 0.0, + y: 0.0, + width: 124.0, + height: 64.0, + screen_width: 120, + screen_height: 60, + text_halo_enabled: false, + }, + ); + } + surface.flush(); + surface.data().unwrap().to_vec() + }; let baseline = Arc::strong_count(&bytes); let mut caches = RenderCaches::default(); - let first = paint(&mut caches); + let first = paint(&measurer, &mut caches, 1); let retained = Arc::strong_count(&bytes); assert!( retained > baseline, "thumbnail must retain its decoded image in the supplied owner" ); - assert_eq!(paint(&mut caches), first); + assert_eq!(paint(&measurer, &mut caches, 1), first); assert_eq!(Arc::strong_count(&bytes), retained); - assert_eq!(paint(&mut RenderCaches::default()), first); + for density in [1, 2, 1] { + let actual = paint(&measurer, &mut caches, density); + let fresh = paint( + &crate::draw::TextMeasurer::default(), + &mut RenderCaches::default(), + density, + ); + assert!( + actual == fresh, + "thumbnail text/image parity at density {density}" + ); + } let offset = (20 * 124 + 20) * 4; assert_eq!( u32::from_ne_bytes(first[offset..offset + 4].try_into().unwrap()), diff --git a/src/ui/board_picker/tests.rs b/src/ui/board_picker/tests.rs index 05cc96cbe..5095bf454 100644 --- a/src/ui/board_picker/tests.rs +++ b/src/ui/board_picker/tests.rs @@ -2,6 +2,7 @@ use super::*; fn pixels( engine: &UiTextEngine, + measurer: &crate::draw::TextMeasurer, caches: &mut crate::draw::RenderCaches, state: &InputState, size: (i32, i32), @@ -15,6 +16,7 @@ fn pixels( ctx.scale(f64::from(density), f64::from(density)); render_board_picker_with_halo( engine, + measurer, &mut crate::draw::RenderCtx::new(&ctx, caches), state, size.0 as u32, @@ -28,6 +30,7 @@ fn pixels( #[test] fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layouts() { let engine = UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); let mut caches = crate::draw::RenderCaches::default(); let mut state = crate::input::state::test_support::make_test_input_state(); state.open_board_picker(); @@ -35,7 +38,14 @@ fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layou let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); state.update_board_picker_layout(&ctx, width as u32, height as u32); - let before = pixels(&engine, &mut caches, &state, (width, height), density); + let before = pixels( + &engine, + &measurer, + &mut caches, + &state, + (width, height), + density, + ); let board_index = state .board_picker_layout() .unwrap() @@ -45,9 +55,17 @@ fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layou for ch in "你好 Καλημέρα long page name".chars() { state.board_picker_page_edit_append(ch); } - let actual = pixels(&engine, &mut caches, &state, (width, height), density); + let actual = pixels( + &engine, + &measurer, + &mut caches, + &state, + (width, height), + density, + ); let expected = pixels( &UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut crate::draw::RenderCaches::default(), &state, (width, height), @@ -60,6 +78,15 @@ fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layou "rename overlay must paint the edited label" ); state.board_picker_cancel_page_edit(); - assert!(pixels(&engine, &mut caches, &state, (width, height), density) == before); + assert!( + pixels( + &engine, + &measurer, + &mut caches, + &state, + (width, height), + density + ) == before + ); } } From 9a219c9ebd1c94c734ce986b80ce20523b8aa169 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:17:27 +0200 Subject: [PATCH 37/42] refactor(input): route asynchronous text updates through runtime resources --- src/backend/wayland/handlers/seat.rs | 2 +- src/backend/wayland/handlers/text_input.rs | 33 ++++++++++++++----- src/backend/wayland/state/clipboard.rs | 32 +++++++++++------- src/backend/wayland/state/text_clipboard.rs | 10 ++++-- .../state/actions/key_press/text_input.rs | 14 +------- src/input/state/core/dirty.rs | 10 +----- src/input/state/core/ime.rs | 12 +++++-- .../clipboard/image_paste.rs | 12 +------ src/input/state/tests/text_input/editing.rs | 16 ++++++--- src/input/state/tests/text_input/ime.rs | 7 ++-- 10 files changed, 84 insertions(+), 64 deletions(-) diff --git a/src/backend/wayland/handlers/seat.rs b/src/backend/wayland/handlers/seat.rs index 70b0afb24..31fa79849 100644 --- a/src/backend/wayland/handlers/seat.rs +++ b/src/backend/wayland/handlers/seat.rs @@ -126,7 +126,7 @@ impl WaylandState { if !self.text_input.detach_if_owned(removed_seat) { return; } - self.input_state.ime_clear(); + self.input_state.ime_clear_with(self.render.text_measurer()); self.input_state.take_text_input_cursor_rect_dirty(); self.input_state.take_text_input_external_change_dirty(); diff --git a/src/backend/wayland/handlers/text_input.rs b/src/backend/wayland/handlers/text_input.rs index c2acdb7bf..1cd0891de 100644 --- a/src/backend/wayland/handlers/text_input.rs +++ b/src/backend/wayland/handlers/text_input.rs @@ -61,7 +61,9 @@ impl Dispatch for WaylandState { // state. Requests are ignored until the next Enter, so // clear only local state and preserve the commit serial. state.text_input.leave(); - state.input_state.ime_clear(); + state + .input_state + .ime_clear_with(state.render.text_measurer()); state.input_state.take_text_input_cursor_rect_dirty(); state.input_state.take_text_input_external_change_dirty(); } @@ -109,7 +111,9 @@ impl WaylandState { /// enable/disable/update still in flight, and committing more state against /// it would only pile on out-of-order updates. fn on_ime_done(&mut self, serial: u32) { - let editor_changed = self.input_state.ime_apply_done(); + let editor_changed = self + .input_state + .ime_apply_done_with(self.render.text_measurer()); let cursor_dirty = self.input_state.take_text_input_cursor_rect_dirty(); self.text_input.collect_editor_changes( cursor_dirty, @@ -124,7 +128,11 @@ impl WaylandState { let Some(ti) = self.text_input.protocol() else { return; }; - if !self.report_text_cursor_rectangle(&ti, self.text_input.external_change_pending()) { + if !self.report_text_cursor_rectangle( + &ti, + self.text_input.external_change_pending(), + self.render.text_measurer(), + ) { return; } ti.commit(); @@ -160,18 +168,22 @@ impl WaylandState { if desired != self.text_input.is_enabled() && desired { ti.enable(); ti.set_content_type(ContentHint::empty(), ContentPurpose::Normal); - self.report_text_cursor_rectangle(&ti, false); + self.report_text_cursor_rectangle(&ti, false, self.render.text_measurer()); ti.commit(); self.text_input.enabled_committed(); } else if desired != self.text_input.is_enabled() { ti.disable(); ti.commit(); self.text_input.disabled_committed(); - self.input_state.ime_clear(); + self.input_state.ime_clear_with(self.render.text_measurer()); self.input_state.take_text_input_cursor_rect_dirty(); self.input_state.take_text_input_external_change_dirty(); } else if self.text_input.cursor_update_ready() - && self.report_text_cursor_rectangle(&ti, self.text_input.external_change_pending()) + && self.report_text_cursor_rectangle( + &ti, + self.text_input.external_change_pending(), + self.render.text_measurer(), + ) { ti.commit(); self.text_input.cursor_update_committed(); @@ -182,7 +194,12 @@ impl WaylandState { /// candidate popup near the composition. `set_cursor_rectangle` takes /// surface-local coordinates, but the cached preview bounds are in canvas /// space, so convert them through the active zoom/pan transform first. - fn report_text_cursor_rectangle(&self, ti: &ZwpTextInputV3, external_change: bool) -> bool { + fn report_text_cursor_rectangle( + &self, + ti: &ZwpTextInputV3, + external_change: bool, + measurer: &crate::draw::TextMeasurer, + ) -> bool { if external_change { ti.set_text_change_cause(ChangeCause::Other); } @@ -194,7 +211,7 @@ impl WaylandState { // Prefer the exact caret position so the candidate popup sits at the // composition point — correct mid-buffer and in wrapped/multiline text. - if let Some(caret_canvas) = self.input_state.caret_cursor_rect_canvas() + if let Some(caret_canvas) = self.input_state.caret_cursor_rect_canvas_with(measurer) && let Some(rect) = self.input_state.screen_rect_for_canvas(caret_canvas) { ti.set_cursor_rectangle(rect.x, rect.y, rect.width.clamp(1, 4), rect.height.max(1)); diff --git a/src/backend/wayland/state/clipboard.rs b/src/backend/wayland/state/clipboard.rs index 175febfe6..a13254b54 100644 --- a/src/backend/wayland/state/clipboard.rs +++ b/src/backend/wayland/state/clipboard.rs @@ -241,9 +241,11 @@ impl WaylandState { shapes, warning, } => { - let pasted = self - .input_state - .paste_clipboard_shapes_from_request(&request, shapes); + let pasted = self.input_state.paste_clipboard_shapes_from_request_with( + self.render.text_measurer(), + &request, + shapes, + ); self.input_state.finish_clipboard_paste_request(request.id); if pasted == 0 { self.set_transfer_warning_toast( @@ -268,9 +270,11 @@ impl WaylandState { log::debug!("Ignoring stale clipboard paste completion {}", request_id); } PasteAction::ApplyPrivateSelection { request, shapes } => { - let pasted = self - .input_state - .paste_clipboard_shapes_from_request(&request, shapes); + let pasted = self.input_state.paste_clipboard_shapes_from_request_with( + self.render.text_measurer(), + &request, + shapes, + ); self.input_state.finish_clipboard_paste_request(request.id); if pasted == 0 { self.set_transfer_warning_toast(TransferWarning::NoShapesPasted); @@ -308,9 +312,11 @@ impl WaylandState { )) } }; - let pasted = self - .input_state - .paste_external_image_from_request(&request, image); + let pasted = self.input_state.paste_external_image_from_request_with( + self.render.text_measurer(), + &request, + image, + ); log::info!( "Applied external image paste request {} to board '{}' page {}: success={}, mime={}, dimensions={}x{}, bytes={}", request.id, @@ -450,9 +456,11 @@ impl WaylandState { .selection_clipboard_snapshot() .shapes_for_fallback(generation) { - let pasted = self - .input_state - .paste_clipboard_shapes_from_request(request, shapes); + let pasted = self.input_state.paste_clipboard_shapes_from_request_with( + self.render.text_measurer(), + request, + shapes, + ); if pasted > 0 { self.input_state.push_toast( ToastPriority::Info, diff --git a/src/backend/wayland/state/text_clipboard.rs b/src/backend/wayland/state/text_clipboard.rs index 2699fb099..2fe1c28e9 100644 --- a/src/backend/wayland/state/text_clipboard.rs +++ b/src/backend/wayland/state/text_clipboard.rs @@ -40,7 +40,9 @@ impl WaylandState { context: request, outcome: TextCopyOutcome::Copied, .. - } => self.input_state.complete_text_copy(request), + } => self + .input_state + .complete_text_copy_with(self.render.text_measurer(), request), RuntimeOperationPoll::Ready { outcome: TextCopyOutcome::Failed, .. @@ -94,7 +96,11 @@ impl WaylandState { if self.input_state.text_paste_target_is_current(target) { match outcome { TextPasteOutcome::Text(text) => { - if let Some(edit) = self.input_state.apply_text_paste(target, &text) { + if let Some(edit) = self.input_state.apply_text_paste_with( + self.render.text_measurer(), + target, + &text, + ) { self.clipboard.rebase_pending_text_pastes(&edit); } } diff --git a/src/input/state/actions/key_press/text_input.rs b/src/input/state/actions/key_press/text_input.rs index a866bdf71..84ab0683f 100644 --- a/src/input/state/actions/key_press/text_input.rs +++ b/src/input/state/actions/key_press/text_input.rs @@ -1,4 +1,4 @@ -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::TextMeasurer; use log::warn; use crate::draw::Shape; @@ -282,14 +282,6 @@ impl InputState { .paste_target_is_current(&self.state, target) } - pub(crate) fn apply_text_paste( - &mut self, - target: TextPasteTarget, - text: &str, - ) -> Option { - with_legacy_measurer(|measurer| self.apply_text_paste_with(measurer, target, text)) - } - pub(crate) fn apply_text_paste_with( &mut self, measurer: &TextMeasurer, @@ -304,10 +296,6 @@ impl InputState { Some(edit) } - pub(crate) fn complete_text_copy(&mut self, request: TextClipboardRequest) { - with_legacy_measurer(|measurer| self.complete_text_copy_with(measurer, request)) - } - pub(crate) fn complete_text_copy_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs index 347197d04..c837a7ffd 100644 --- a/src/input/state/core/dirty.rs +++ b/src/input/state/core/dirty.rs @@ -3,7 +3,7 @@ use crate::draw::shape::{ CaretGeometry, LogicalBounds, bounding_box_for_points, bounding_box_for_sticky_note_preview_with, bounding_box_for_text_with, }; -use crate::draw::{Shape, TextMeasurer, with_legacy_measurer}; +use crate::draw::{Shape, TextMeasurer}; use crate::input::tool::{ PROVISIONAL_POLYGON_DAMAGE_PADDING, ToolMotionBehavior, ToolMotionSizeSource, }; @@ -150,10 +150,6 @@ impl InputState { } /// Updates dirty tracking for the live text preview/caret overlay. - pub(crate) fn update_text_preview_dirty(&mut self) { - with_legacy_measurer(|measurer| self.update_text_preview_dirty_with(measurer)) - } - pub(crate) fn update_text_preview_dirty_with(&mut self, measurer: &TextMeasurer) { self.text_editing.mark_cursor_rect_dirty(); let new_bounds = self.compute_text_preview_bounds(measurer); @@ -338,10 +334,6 @@ impl InputState { /// so it is correct mid-buffer and in wrapped/multiline text, unlike the old /// append-only "right edge of the preview" assumption. `None` outside text /// input or when no measurement context exists. - pub(crate) fn caret_cursor_rect_canvas(&self) -> Option { - with_legacy_measurer(|measurer| self.caret_cursor_rect_canvas_with(measurer)) - } - pub(crate) fn caret_cursor_rect_canvas_with(&self, measurer: &TextMeasurer) -> Option { let DrawingState::TextInput { x, y, .. } = &self.state else { return None; diff --git a/src/input/state/core/ime.rs b/src/input/state/core/ime.rs index 66bcc7778..2b03ea486 100644 --- a/src/input/state/core/ime.rs +++ b/src/input/state/core/ime.rs @@ -383,19 +383,27 @@ impl InputState { } pub fn ime_apply_done(&mut self) -> bool { + crate::draw::with_legacy_measurer(|measurer| self.ime_apply_done_with(measurer)) + } + + pub(crate) fn ime_apply_done_with(&mut self, measurer: &crate::draw::TextMeasurer) -> bool { let changed = self.text_editing.apply_ime_done(&mut self.state); if changed { self.needs_redraw = true; - self.update_text_preview_dirty(); + self.update_text_preview_dirty_with(measurer); } changed } pub fn ime_clear(&mut self) -> bool { + crate::draw::with_legacy_measurer(|measurer| self.ime_clear_with(measurer)) + } + + pub(crate) fn ime_clear_with(&mut self, measurer: &crate::draw::TextMeasurer) -> bool { let had_preedit = self.text_editing.clear_ime(); if had_preedit { self.needs_redraw = true; - self.update_text_preview_dirty(); + self.update_text_preview_dirty_with(measurer); } had_preedit } diff --git a/src/input/state/core/selection_actions/clipboard/image_paste.rs b/src/input/state/core/selection_actions/clipboard/image_paste.rs index 0524dc618..210420328 100644 --- a/src/input/state/core/selection_actions/clipboard/image_paste.rs +++ b/src/input/state/core/selection_actions/clipboard/image_paste.rs @@ -1,20 +1,10 @@ use super::super::super::base::{ClipboardPasteRequest, InputState}; +use crate::draw::TextMeasurer; use crate::draw::frame::UndoAction; use crate::draw::{EmbeddedImage, Shape}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::input::state::{Toast, ToastPriority}; impl InputState { - pub(crate) fn paste_external_image_from_request( - &mut self, - request: &ClipboardPasteRequest, - image: EmbeddedImage, - ) -> bool { - with_legacy_measurer(|measurer| { - self.paste_external_image_from_request_with(measurer, request, image) - }) - } - pub(crate) fn paste_external_image_from_request_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/tests/text_input/editing.rs b/src/input/state/tests/text_input/editing.rs index 4b43399ef..ffeb06112 100644 --- a/src/input/state/tests/text_input/editing.rs +++ b/src/input/state/tests/text_input/editing.rs @@ -272,6 +272,7 @@ fn ctrl_x_without_a_selection_falls_through_without_editing() { #[test] fn ctrl_x_deletes_the_selection_only_after_clipboard_publication() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = text_state("hello"); state.modifiers.shift = true; state.on_key_press(Key::Home); @@ -290,7 +291,7 @@ fn ctrl_x_deletes_the_selection_only_after_clipboard_publication() { "a failed clipboard publication must leave the selection intact" ); - state.complete_text_copy(request); + state.complete_text_copy_with(&measurer, request); assert_eq!( buffer(&state), "", @@ -316,6 +317,7 @@ fn repeated_ctrl_x_requests_are_retained_before_backend_draining() { #[test] fn stale_cut_completion_never_deletes_later_edits() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = text_state("hello"); state.modifiers.shift = true; state.on_key_press(Key::Home); @@ -329,12 +331,13 @@ fn stale_cut_completion_never_deletes_later_edits() { state.on_key_press(Key::Char('X')); assert_eq!(buffer(&state), "X"); - state.complete_text_copy(request); + state.complete_text_copy_with(&measurer, request); assert_eq!(buffer(&state), "X"); } #[test] fn cut_completion_is_invalid_after_intervening_edits_restore_the_same_selection() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = text_state("hello"); state.modifiers.ctrl = true; state.on_key_press(Key::Char('a')); @@ -360,7 +363,7 @@ fn cut_completion_is_invalid_after_intervening_edits_restore_the_same_selection( state.modifiers.ctrl = false; assert_eq!(buffer(&state), "hello"); - state.complete_text_copy(request); + state.complete_text_copy_with(&measurer, request); assert_eq!( buffer(&state), "hello", @@ -407,6 +410,7 @@ fn repeated_ctrl_v_requests_are_retained_before_backend_draining() { #[test] fn delayed_paste_replaces_the_selection_captured_at_invocation() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = text_state("hello"); state.modifiers.ctrl = true; state.on_key_press(Key::Char('a')); @@ -419,7 +423,11 @@ fn delayed_paste_replaces_the_selection_captured_at_invocation() { // Clipboard reads are asynchronous. Moving the caret while the read is in // flight must not retarget the completion away from the invoked selection. state.on_key_press(Key::Home); - assert!(state.apply_text_paste(target, "X").is_some()); + assert!( + state + .apply_text_paste_with(&measurer, target, "X") + .is_some() + ); assert_eq!(buffer(&state), "X"); } diff --git a/src/input/state/tests/text_input/ime.rs b/src/input/state/tests/text_input/ime.rs index a8d5a19de..69ac68e35 100644 --- a/src/input/state/tests/text_input/ime.rs +++ b/src/input/state/tests/text_input/ime.rs @@ -378,6 +378,7 @@ fn preedit_replacing_a_forward_selection_has_one_effective_preview_and_cursor() #[test] fn preedit_start_removes_selection_and_invalidates_pending_clipboard_edit() { + let measurer = crate::draw::TextMeasurer::default(); let mut state = create_test_input_state(); state.state = DrawingState::text_input(0, 0, "hello world".to_string()); if let DrawingState::TextInput { @@ -422,14 +423,16 @@ fn preedit_start_removes_selection_and_invalidates_pending_clipboard_edit() { ); state.text_editing.ime_queue_preedit(None, 0, 0); - assert!(state.ime_apply_done()); + assert!(state.ime_apply_done_with(&measurer)); assert_eq!( buffer(&state), " world", "canceling the preedit must not restore the removed selection" ); assert!( - state.apply_text_paste(stale_paste, "stale").is_none(), + state + .apply_text_paste_with(&measurer, stale_paste, "stale") + .is_none(), "the paste captured before composition must remain invalid" ); } From e1e240b0e5fb5eb32878e71b689f32565fdf6c67 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:25:07 +0200 Subject: [PATCH 38/42] refactor(input): keep runtime text resources in backend paths --- .../wayland/backend/event_loop/capture.rs | 8 ++-- src/backend/wayland/backend/tray.rs | 4 +- src/backend/wayland/handlers/pointer/axis.rs | 2 +- src/backend/wayland/handlers/tablet/frame.rs | 11 +++-- src/backend/wayland/handlers/tablet/tool.rs | 8 +++- src/backend/wayland/state/color_picker.rs | 4 +- src/backend/wayland/state/eyedropper.rs | 8 ++-- src/backend/wayland/state/gtk_toolbar.rs | 12 +++++- src/backend/wayland/state/ocr.rs | 3 +- .../wayland/state/region_capture/picker.rs | 3 +- .../wayland/state/region_capture/runtime.rs | 9 ++-- .../region_capture/tests/capture_selection.rs | 12 +++++- .../tests/event_characterization.rs | 4 +- .../tests/lifecycle_and_measure.rs | 6 +-- .../wayland/state/region_capture/tests/ocr.rs | 18 ++++++-- .../state/region_capture/tests/review.rs | 42 +++++++++++++++---- .../state/actions/action_capture_zoom.rs | 2 +- src/input/state/actions/action_colors.rs | 6 --- src/input/state/core/captured_image.rs | 34 +++++++++------ src/input/state/core/eyedropper.rs | 14 ++++--- src/input/state/core/modal.rs | 10 ++--- src/input/state/core/region_select.rs | 25 ++++++++--- src/input/state/core/style.rs | 1 + .../state/core/tool_controls/settings.rs | 10 ++--- src/input/state/tests/drawing.rs | 8 ++-- src/input/state/tests/radial_menu.rs | 15 ++++--- src/input/state/tests/tool_controls.rs | 23 ++++++---- src/input/tablet/mod.rs | 12 +++++- src/ui/radial_menu/cache.rs | 15 ++++--- 29 files changed, 221 insertions(+), 108 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index cd817ede1..83d422978 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -375,9 +375,11 @@ fn resolve_board_capture_outcome( height: image.height, bytes: image.bytes.into(), }; - state - .input_state - .insert_captured_image(embedded, &pending.target); + state.input_state.insert_captured_image_with( + state.render.text_measurer(), + embedded, + &pending.target, + ); None } (CaptureOutcome::RenderedImageReady(_), None) => { diff --git a/src/backend/wayland/backend/tray.rs b/src/backend/wayland/backend/tray.rs index 431da972a..b46bb19de 100644 --- a/src/backend/wayland/backend/tray.rs +++ b/src/backend/wayland/backend/tray.rs @@ -111,7 +111,9 @@ fn apply_tray_action(state: &mut WaylandState, action: TrayAction) { state.input_state.toggle_help_overlay(); } TrayAction::ToggleBoardPicker => { - state.input_state.toggle_board_picker(); + state + .input_state + .toggle_board_picker_with_measurer(state.render.text_measurer()); state.input_state.needs_redraw = true; } TrayAction::ToggleLightMode => { diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index 86eb01364..495aee2bc 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -406,7 +406,7 @@ mod tests { #[test] fn an_active_screen_modal_prevents_the_toolbar_scroll_route() { let mut input_state = make_test_input_state(); - input_state.activate_eyedropper(None); + input_state.activate_eyedropper_with(&crate::draw::TextMeasurer::default(), None); assert_eq!( axis_surface_route(&input_state, true, true, 1), diff --git a/src/backend/wayland/handlers/tablet/frame.rs b/src/backend/wayland/handlers/tablet/frame.rs index 92608ce16..be86d648d 100644 --- a/src/backend/wayland/handlers/tablet/frame.rs +++ b/src/backend/wayland/handlers/tablet/frame.rs @@ -118,7 +118,8 @@ impl WaylandState { let first_pressure_sample = self.tablet.tip_down && self.tablet.pressure_thickness.is_none(); let p01 = (pressure as f64) / 65535.0; - if !crate::input::tablet::try_apply_pressure_to_state( + if !crate::input::tablet::try_apply_pressure_to_state_with( + self.render.text_measurer(), p01, &mut self.input_state, self.tablet.settings, @@ -365,14 +366,18 @@ mod tests { state.set_region_pending_capture(RegionPurposeTag::Ocr, 1, ScreenCaptureSource::Frozen); assert!(modal_blocks_stylus_barrel_actions(&state)); - state.activate_region(RegionPurposeTag::Ocr, 1); + state.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::Ocr, + 1, + ); assert!(modal_blocks_stylus_barrel_actions(&state)); state.cancel_region_ui_only(); assert!(!modal_blocks_stylus_barrel_actions(&state)); state.set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); assert!(modal_blocks_stylus_barrel_actions(&state)); - state.activate_eyedropper(Some(1)); + state.activate_eyedropper_with(&crate::draw::TextMeasurer::default(), Some(1)); assert!(modal_blocks_stylus_barrel_actions(&state)); state.cancel_eyedropper(); assert!(!modal_blocks_stylus_barrel_actions(&state)); diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs index daec46670..6641709d4 100644 --- a/src/backend/wayland/handlers/tablet/tool.rs +++ b/src/backend/wayland/handlers/tablet/tool.rs @@ -530,7 +530,11 @@ mod tests { !fresh_contact(&state), "a stroke drawn while the capture is pending is still a real stroke" ); - state.activate_region(RegionPurposeTag::Ocr, 1); + state.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::Ocr, + 1, + ); assert!(fresh_contact(&state)); state.start_region_selection(RegionInputSource::Stylus, (10.0, 10.0)); assert!(fresh_contact(&state)); @@ -539,7 +543,7 @@ mod tests { state.set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); assert!(!fresh_contact(&state)); - state.activate_eyedropper(Some(1)); + state.activate_eyedropper_with(&crate::draw::TextMeasurer::default(), Some(1)); assert!(fresh_contact(&state)); state.cancel_eyedropper(); assert!(!fresh_contact(&state)); diff --git a/src/backend/wayland/state/color_picker.rs b/src/backend/wayland/state/color_picker.rs index 17f735852..0d0447365 100644 --- a/src/backend/wayland/state/color_picker.rs +++ b/src/backend/wayland/state/color_picker.rs @@ -129,7 +129,9 @@ impl WaylandState { if let Some(color) = parse_hex_color(clipboard.trim()) { match target { HexPasteTarget::ActiveTool => { - let _ = self.input_state.apply_color_from_ui(color); + let _ = self + .input_state + .apply_color_from_ui_with_measurer(self.render.text_measurer(), color); } HexPasteTarget::ColorPickerPopup { generation } => { if !self diff --git a/src/backend/wayland/state/eyedropper.rs b/src/backend/wayland/state/eyedropper.rs index 6e978fc4d..80a057f27 100644 --- a/src/backend/wayland/state/eyedropper.rs +++ b/src/backend/wayland/state/eyedropper.rs @@ -56,7 +56,8 @@ impl WaylandState { // The two screen modals are mutually exclusive; entering one ends the // other, including any temporary freeze that one owned. self.cancel_ocr(); - self.input_state.prepare_for_screen_modal(); + self.input_state + .prepare_for_screen_modal_with_measurer(self.render.text_measurer()); self.zoom.stop_pan(); self.pointer.stop_board_pan(); self.pointer.set_board_pan_key_held(false); @@ -181,7 +182,7 @@ impl WaylandState { self.retire_stylus_contact(); self.acquisition.set_eyedropper_source(token); self.input_state - .activate_eyedropper(owned_frozen_generation); + .activate_eyedropper_with(self.render.text_measurer(), owned_frozen_generation); true } @@ -207,7 +208,8 @@ impl WaylandState { let (image_x, image_y) = self.eyedropper_image_coords(&source, x, y); let color = sample_at(source.image, image_x, image_y); if let Some(color) = color { - self.input_state.apply_color_from_ui(color); + self.input_state + .apply_color_from_ui_with_measurer(self.render.text_measurer(), color); } else { self.input_state.push_toast( ToastPriority::Critical, diff --git a/src/backend/wayland/state/gtk_toolbar.rs b/src/backend/wayland/state/gtk_toolbar.rs index 072f09aa3..c3af51606 100644 --- a/src/backend/wayland/state/gtk_toolbar.rs +++ b/src/backend/wayland/state/gtk_toolbar.rs @@ -249,11 +249,19 @@ mod modal_tests { use crate::input::state::RegionPurposeTag; let mut input_state = make_test_input_state(); - input_state.activate_region(RegionPurposeTag::Ocr, 1); + input_state.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::Ocr, + 1, + ); assert!(!gtk_toolbar_feedback_blocked(&input_state)); input_state.cancel_region_ui_only(); - input_state.activate_region(RegionPurposeTag::CaptureDeliver, 2); + input_state.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::CaptureDeliver, + 2, + ); assert!(gtk_toolbar_feedback_blocked(&input_state)); } diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs index 0cfa6d278..d6db77568 100644 --- a/src/backend/wayland/state/ocr.rs +++ b/src/backend/wayland/state/ocr.rs @@ -79,7 +79,8 @@ impl WaylandState { // The two screen modals are mutually exclusive; entering one ends the // other, including any temporary freeze that one owned. self.cancel_eyedropper(); - self.input_state.prepare_for_screen_modal(); + self.input_state + .prepare_for_screen_modal_with_measurer(self.render.text_measurer()); self.zoom.stop_pan(); self.pointer.stop_board_pan(); self.pointer.set_board_pan_key_held(false); diff --git a/src/backend/wayland/state/region_capture/picker.rs b/src/backend/wayland/state/region_capture/picker.rs index 014f68316..e5d7246cf 100644 --- a/src/backend/wayland/state/region_capture/picker.rs +++ b/src/backend/wayland/state/region_capture/picker.rs @@ -162,7 +162,8 @@ impl WaylandState { return; } - self.input_state.prepare_for_screen_modal(); + self.input_state + .prepare_for_screen_modal_with_measurer(self.render.text_measurer()); self.zoom.stop_pan(); self.pointer.stop_board_pan(); self.pointer.set_board_pan_key_held(false); diff --git a/src/backend/wayland/state/region_capture/runtime.rs b/src/backend/wayland/state/region_capture/runtime.rs index 3581d6e35..c31a763f3 100644 --- a/src/backend/wayland/state/region_capture/runtime.rs +++ b/src/backend/wayland/state/region_capture/runtime.rs @@ -230,7 +230,8 @@ impl WaylandState { MeasureModeTransition::Start => {} } - self.input_state.prepare_for_screen_modal(); + self.input_state + .prepare_for_screen_modal_with_measurer(self.render.text_measurer()); self.zoom.stop_pan(); self.pointer.stop_board_pan(); self.pointer.set_board_pan_key_held(false); @@ -241,7 +242,8 @@ impl WaylandState { let generation = self .region_capture .begin_measure((self.surface.width(), self.surface.height())); - self.input_state.activate_measure_mode(generation); + self.input_state + .activate_measure_mode_with(self.render.text_measurer(), generation); self.debug_assert_screen_region_invariant(); } @@ -344,7 +346,8 @@ impl WaylandState { initial_square_modifier(purpose, self.input_state.modifiers.shift), include_drawings, ); - self.input_state.activate_region(purpose, generation); + self.input_state + .activate_region_with(self.render.text_measurer(), purpose, generation); self.start_region_window_query(purpose, generation, token, freeze_ownership); self.debug_assert_screen_region_invariant(); true diff --git a/src/backend/wayland/state/region_capture/tests/capture_selection.rs b/src/backend/wayland/state/region_capture/tests/capture_selection.rs index 61ee5d4f3..5b55390c2 100644 --- a/src/backend/wayland/state/region_capture/tests/capture_selection.rs +++ b/src/backend/wayland/state/region_capture/tests/capture_selection.rs @@ -91,7 +91,11 @@ fn interactive_release_enters_review_ready_for_the_first_cut_drag() { let mut backend = Some(interactive_region()); let mut input = make_test_input_state(); let mut review_edits = None; - input.activate_region(RegionPurposeTag::CaptureInteractive, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::CaptureInteractive, + 1, + ); assert!(begin_region_selection_event( &mut backend, @@ -156,7 +160,11 @@ fn reselecting_before_a_cut_replaces_the_review_edit_geometry() { let mut backend = Some(interactive_region()); let mut input = make_test_input_state(); let mut review_edits = None; - input.activate_region(RegionPurposeTag::CaptureInteractive, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::CaptureInteractive, + 1, + ); assert!(begin_region_selection_event( &mut backend, diff --git a/src/backend/wayland/state/region_capture/tests/event_characterization.rs b/src/backend/wayland/state/region_capture/tests/event_characterization.rs index ee108b4fc..f3dd6f693 100644 --- a/src/backend/wayland/state/region_capture/tests/event_characterization.rs +++ b/src/backend/wayland/state/region_capture/tests/event_characterization.rs @@ -27,9 +27,9 @@ fn each_purpose_keeps_its_event_geometry_and_terminal_ownership_contract() { let mut backend = Some(active_region_for(purpose)); let mut input = make_test_input_state(); if purpose == RegionPurposeTag::Measure { - input.activate_measure_mode(1); + input.activate_measure_mode_with(&crate::draw::TextMeasurer::default(), 1); } else { - input.activate_region(purpose, 1); + input.activate_region_with(&crate::draw::TextMeasurer::default(), purpose, 1); } assert_eq!( input.region_state(), diff --git a/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs b/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs index 06aa14010..8d2437429 100644 --- a/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs +++ b/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs @@ -53,7 +53,7 @@ fn measure_mode_owns_logical_geometry_without_a_screen_image() { edge: None, }); let mut input = make_test_input_state(); - input.activate_measure_mode(7); + input.activate_measure_mode_with(&crate::draw::TextMeasurer::default(), 7); assert!(begin_region_selection_event( &mut backend, @@ -129,7 +129,7 @@ fn lost_measure_drag_rearms_for_another_device() { edge: None, }); let mut input = make_test_input_state(); - input.activate_measure_mode(8); + input.activate_measure_mode_with(&crate::draw::TextMeasurer::default(), 8); assert!(begin_region_selection_event( &mut backend, &mut input, @@ -161,7 +161,7 @@ fn reversed_measure_drag_uses_outward_integer_edges() { edge: None, }); let mut input = make_test_input_state(); - input.activate_measure_mode(9); + input.activate_measure_mode_with(&crate::draw::TextMeasurer::default(), 9); assert!(begin_region_selection_event( &mut backend, &mut input, diff --git a/src/backend/wayland/state/region_capture/tests/ocr.rs b/src/backend/wayland/state/region_capture/tests/ocr.rs index aa2344e9b..26a3e417c 100644 --- a/src/backend/wayland/state/region_capture/tests/ocr.rs +++ b/src/backend/wayland/state/region_capture/tests/ocr.rs @@ -115,7 +115,11 @@ fn production_ocr_event_adapter_uses_release_endpoint_at_every_scale() { }; let mut backend = Some(ocr_region(scale)); let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::Ocr, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::Ocr, + 1, + ); assert!(begin_region_selection_event( &mut backend, @@ -169,7 +173,11 @@ fn production_ocr_event_adapter_uses_release_endpoint_at_every_scale() { fn production_ocr_event_adapter_rearms_small_drag_and_ignores_shift_square_policy() { let mut backend = Some(ocr_region(1.0)); let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::Ocr, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::Ocr, + 1, + ); input.sync_modifiers(true, false, false, false); assert!(begin_region_selection_event( @@ -195,7 +203,11 @@ fn production_ocr_event_adapter_rearms_small_drag_and_ignores_shift_square_polic let mut backend = Some(ocr_region(1.0)); let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::Ocr, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::Ocr, + 1, + ); assert!(begin_region_selection_event( &mut backend, &mut input, diff --git a/src/backend/wayland/state/region_capture/tests/review.rs b/src/backend/wayland/state/region_capture/tests/review.rs index 3f5244ed0..ef7fcdf97 100644 --- a/src/backend/wayland/state/region_capture/tests/review.rs +++ b/src/backend/wayland/state/region_capture/tests/review.rs @@ -6,7 +6,11 @@ use super::*; fn capture_hover_motion_requests_a_repaint_while_armed_and_in_review() { let mut armed_backend = Some(capture_region()); let mut armed_input = make_test_input_state(); - armed_input.activate_region(RegionPurposeTag::CaptureDeliver, 1); + armed_input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::CaptureDeliver, + 1, + ); let _ = armed_input.dirty_tracker.take_region_report(100, 80); armed_input.needs_redraw = false; @@ -179,7 +183,11 @@ fn capture_pixel_span_reports_one_axis_empty_without_submitting_it() { fn capture_finalize_is_purpose_aware_and_one_axis_empty_rearms() { let mut backend = Some(capture_region()); let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::CaptureDeliver, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::CaptureDeliver, + 1, + ); assert!(begin_region_selection_event( &mut backend, &mut input, @@ -202,7 +210,11 @@ fn capture_finalize_is_purpose_aware_and_one_axis_empty_rearms() { let mut backend = Some(capture_region()); let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::CaptureDeliver, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::CaptureDeliver, + 1, + ); assert!(begin_region_selection_event( &mut backend, &mut input, @@ -336,7 +348,11 @@ fn capture_measurement_maps_armed_pointer_and_reports_exact_selecting_span() { fn compositor_shift_sync_recomputes_capture_preview_without_changing_ownership() { let mut backend = Some(capture_region()); let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::CaptureDeliver, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::CaptureDeliver, + 1, + ); assert!(sync_region_square_modifier_event( &mut backend, @@ -381,7 +397,11 @@ fn compositor_shift_sync_recomputes_capture_preview_without_changing_ownership() fn capture_owner_loss_rearms_without_releasing_backend_ownership() { let mut backend = Some(capture_region()); let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::CaptureDeliver, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::CaptureDeliver, + 1, + ); assert!(begin_region_selection_event( &mut backend, &mut input, @@ -429,7 +449,11 @@ fn capture_owner_loss_rearms_without_releasing_backend_ownership() { fn ocr_owner_loss_requests_its_existing_terminal_cancel_path() { let mut backend = Some(ocr_region(1.0)); let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::Ocr, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::Ocr, + 1, + ); assert!(begin_region_selection_event( &mut backend, &mut input, @@ -952,7 +976,11 @@ fn rejected_review_rectangle_preserves_the_active_resize() { fn mismatched_generation_release_preserves_selection_and_owner() { let mut backend = Some(interactive_region()); let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::CaptureInteractive, 1); + input.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::CaptureInteractive, + 1, + ); assert!(begin_region_selection_event( &mut backend, &mut input, diff --git a/src/input/state/actions/action_capture_zoom.rs b/src/input/state/actions/action_capture_zoom.rs index 4db260e99..4c80abdea 100644 --- a/src/input/state/actions/action_capture_zoom.rs +++ b/src/input/state/actions/action_capture_zoom.rs @@ -192,7 +192,7 @@ mod tests { ); } else { state.set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); - state.activate_eyedropper(None); + state.activate_eyedropper_with(&crate::draw::TextMeasurer::default(), None); } assert!(state.handle_capture_zoom_action(action)); diff --git a/src/input/state/actions/action_colors.rs b/src/input/state/actions/action_colors.rs index 2148d8484..bba38901c 100644 --- a/src/input/state/actions/action_colors.rs +++ b/src/input/state/actions/action_colors.rs @@ -26,12 +26,6 @@ impl InputState { true } - pub(crate) fn apply_color_from_ui(&mut self, color: Color) -> bool { - crate::draw::with_legacy_measurer(|measurer| { - self.apply_color_from_ui_with_measurer(measurer, color) - }) - } - pub(crate) fn apply_color_from_ui_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, diff --git a/src/input/state/core/captured_image.rs b/src/input/state/core/captured_image.rs index 1289aab35..d800e3619 100644 --- a/src/input/state/core/captured_image.rs +++ b/src/input/state/core/captured_image.rs @@ -1,7 +1,7 @@ use super::InputState; +use crate::draw::TextMeasurer; use crate::draw::frame::UndoAction; use crate::draw::{EmbeddedImage, Shape}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; use crate::screen_pixels::EmbeddedImageLimits; use crate::util::Rect; @@ -14,14 +14,6 @@ pub(crate) struct BoardPasteTarget { } impl InputState { - pub(crate) fn insert_captured_image( - &mut self, - image: EmbeddedImage, - target: &BoardPasteTarget, - ) -> bool { - with_legacy_measurer(|measurer| self.insert_captured_image_with(measurer, image, target)) - } - pub(crate) fn insert_captured_image_with( &mut self, measurer: &TextMeasurer, @@ -137,7 +129,11 @@ mod tests { let mut state = make_test_input_state(); let target = target(&state); - assert!(state.insert_captured_image(image(16), &target)); + assert!(state.insert_captured_image_with( + &crate::draw::TextMeasurer::default(), + image(16), + &target + )); let frame = state.boards.active_frame(); assert_eq!(frame.shapes.len(), 1); @@ -161,7 +157,11 @@ mod tests { let mut target = target(&state); target.page_generation = target.page_generation.wrapping_add(1); - assert!(!state.insert_captured_image(image(16), &target)); + assert!(!state.insert_captured_image_with( + &crate::draw::TextMeasurer::default(), + image(16), + &target + )); assert!(state.boards.active_frame().shapes.is_empty()); assert_eq!( state.test_active_toast_message(), @@ -175,7 +175,11 @@ mod tests { let target = target(&state); assert!(state.boards.next_board(), "test config has another board"); - assert!(!state.insert_captured_image(image(16), &target)); + assert!(!state.insert_captured_image_with( + &crate::draw::TextMeasurer::default(), + image(16), + &target + )); assert!(state.boards.board_states().iter().all(|board| { board .pages @@ -195,7 +199,11 @@ mod tests { let target = target(&state); let too_large = EmbeddedImageLimits::default().max_bytes() + 1; - assert!(!state.insert_captured_image(image(too_large), &target)); + assert!(!state.insert_captured_image_with( + &crate::draw::TextMeasurer::default(), + image(too_large), + &target + )); assert!(state.boards.active_frame().shapes.is_empty()); assert_eq!( state.test_active_toast_message(), diff --git a/src/input/state/core/eyedropper.rs b/src/input/state/core/eyedropper.rs index de725c817..bb713ae5d 100644 --- a/src/input/state/core/eyedropper.rs +++ b/src/input/state/core/eyedropper.rs @@ -97,11 +97,15 @@ impl InputState { self.needs_redraw = true; } - pub(crate) fn activate_eyedropper(&mut self, owned_frozen_generation: Option) { + pub(crate) fn activate_eyedropper_with( + &mut self, + measurer: &crate::draw::TextMeasurer, + owned_frozen_generation: Option, + ) { // A capture can take long enough for another interaction to begin while // the eyedropper is pending. Entering the modal state must cancel it so // the eyedropper cannot swallow the matching release event. - self.prepare_for_screen_modal(); + self.prepare_for_screen_modal_with_measurer(measurer); self.eyedropper_ui_state = EyedropperUiState::Active { hover: None, owned_frozen_generation, @@ -142,7 +146,7 @@ mod tests { fn cancel_returns_the_exact_owned_frozen_generation() { let mut state = make_test_input_state(); state.set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); - state.activate_eyedropper(Some(17)); + state.activate_eyedropper_with(&crate::draw::TextMeasurer::default(), Some(17)); assert_eq!(state.cancel_eyedropper(), Some(17)); assert_eq!(state.eyedropper_state(), EyedropperUiState::Inactive); @@ -164,7 +168,7 @@ mod tests { state.on_mouse_press(MouseButton::Left, 10, 20); assert!(matches!(state.state, DrawingState::Drawing { .. })); - state.activate_eyedropper(Some(1)); + state.activate_eyedropper_with(&crate::draw::TextMeasurer::default(), Some(1)); assert!(matches!(state.state, DrawingState::Idle)); assert!(!state.pointer_drag_active()); @@ -181,7 +185,7 @@ mod tests { state.set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen); assert!(state.modal_blocks_canvas_key_repeat()); - state.activate_eyedropper(Some(1)); + state.activate_eyedropper_with(&crate::draw::TextMeasurer::default(), Some(1)); assert!(state.modal_blocks_canvas_key_repeat()); state.cancel_eyedropper(); diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index 7789bbe5b..40adcb284 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -9,7 +9,7 @@ //! this module says the pair deliberately coexists. use super::DrawingState; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::TextMeasurer; use crate::input::state::InputState; /// Every popup surface that participates in modal mutual exclusion, in the @@ -173,10 +173,6 @@ impl InputState { /// reappear when the selector closed. Going through the registry also means /// each surface is dismissed by its own closer — the tour used to be a bare /// flag clear here, which left the toolbar chrome it hides still hidden. - pub(crate) fn prepare_for_screen_modal(&mut self) { - with_legacy_measurer(|measurer| self.prepare_for_screen_modal_with_measurer(measurer)) - } - pub(crate) fn prepare_for_screen_modal_with_measurer(&mut self, measurer: &TextMeasurer) { self.cancel_active_interaction_with(measurer); for surface in ModalSurface::ALL { @@ -288,7 +284,7 @@ mod wheel_tests { // stop at them; the wheel used to carry on to zoom, Spotlight, and // stroke thickness behind them. let mut state = make_test_input_state(); - state.activate_eyedropper(None); + state.activate_eyedropper_with(&crate::draw::TextMeasurer::default(), None); assert!(state.eyedropper_is_active()); assert!(state.modal_owns_wheel()); @@ -302,7 +298,7 @@ mod wheel_tests { state.open_font_picker(); assert!(state.is_font_picker_open()); - state.prepare_for_screen_modal(); + state.prepare_for_screen_modal_with_measurer(&crate::draw::TextMeasurer::default()); assert!(!state.is_font_picker_open()); assert!( diff --git a/src/input/state/core/region_select.rs b/src/input/state/core/region_select.rs index 5b2d5ce79..a400d8121 100644 --- a/src/input/state/core/region_select.rs +++ b/src/input/state/core/region_select.rs @@ -220,8 +220,12 @@ impl RegionSelectUiState { } impl InputState { - pub(crate) fn activate_measure_mode(&mut self, generation: u64) { - self.activate_region(RegionPurposeTag::Measure, generation); + pub(crate) fn activate_measure_mode_with( + &mut self, + measurer: &crate::draw::TextMeasurer, + generation: u64, + ) { + self.activate_region_with(measurer, RegionPurposeTag::Measure, generation); } pub(crate) fn request_copy_text_from_screen(&mut self) { @@ -265,11 +269,16 @@ impl InputState { self.mark_region_dirty(); } - pub(crate) fn activate_region(&mut self, purpose: RegionPurposeTag, generation: u64) { + pub(crate) fn activate_region_with( + &mut self, + measurer: &crate::draw::TextMeasurer, + purpose: RegionPurposeTag, + generation: u64, + ) { // A capture can take long enough for another interaction to begin while // OCR is pending. Entering the modal state must cancel it so the // selector cannot swallow the matching release event. - self.prepare_for_screen_modal(); + self.prepare_for_screen_modal_with_measurer(measurer); self.region_select_ui_state = RegionSelectUiState::Armed { purpose, generation, @@ -473,7 +482,11 @@ mod tests { // interaction, tool, and history guarantees at their generalized seam. fn activate_ocr_region(state: &mut InputState, generation: u64) { - state.activate_region(RegionPurposeTag::Ocr, generation); + state.activate_region_with( + &crate::draw::TextMeasurer::default(), + RegionPurposeTag::Ocr, + generation, + ); } #[test] @@ -576,7 +589,7 @@ mod tests { #[test] fn measure_mode_keeps_the_completed_rectangle_without_capture_state() { let mut state = make_test_input_state(); - state.activate_measure_mode(7); + state.activate_measure_mode_with(&crate::draw::TextMeasurer::default(), 7); assert!(state.start_region_selection(RegionInputSource::Pointer, (12.0, 18.0))); state.update_region_selection(RegionInputSource::Pointer, (42.0, 63.0)); diff --git a/src/input/state/core/style.rs b/src/input/state/core/style.rs index 2a22207ab..ccf4868c5 100644 --- a/src/input/state/core/style.rs +++ b/src/input/state/core/style.rs @@ -152,6 +152,7 @@ impl DrawingStyle { true } + #[cfg(feature = "tablet-input")] pub(crate) fn set_pressure_thickness(&mut self, tool: Tool, thickness: f64) -> f64 { let clamped = thickness.clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); if tool.uses_drawing_thickness() { diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index 133b8f5eb..502c61516 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -58,13 +58,7 @@ impl InputState { /// Updates the active drawing thickness from tablet pressure without /// treating every pressure sample as a persisted user preference edit. - #[cfg_attr(not(feature = "tablet-input"), allow(dead_code))] - pub(crate) fn set_pressure_thickness_for_active_tool(&mut self, thickness: f64) -> f64 { - with_legacy_measurer(|measurer| { - self.set_pressure_thickness_for_active_tool_with(measurer, thickness) - }) - } - + #[cfg(feature = "tablet-input")] pub(crate) fn set_pressure_thickness_for_active_tool_with( &mut self, measurer: &TextMeasurer, @@ -117,6 +111,7 @@ impl InputState { true } + #[cfg(feature = "tablet-input")] fn update_initial_pressure_sample(&mut self, thickness: f64) { let DrawingState::Drawing { points, @@ -131,6 +126,7 @@ impl InputState { } } + #[cfg(feature = "tablet-input")] fn active_initial_pressure_sample_changes(&self, thickness: f32) -> bool { let DrawingState::Drawing { points, diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index bebe92d23..139dc4a59 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -343,6 +343,7 @@ fn first_stroke_onboarding_signal_keeps_release_damage_bounded() { ); } +#[cfg(feature = "tablet-input")] #[test] fn pressure_preview_release_cleans_wide_preview_when_final_freehand_narrows() { let mut state = create_test_input_state(); @@ -354,7 +355,7 @@ fn pressure_preview_release_cleans_wide_preview_when_final_freehand_narrows() { let _ = state.take_dirty_regions(); state.on_mouse_press(MouseButton::Left, 10, 100); - state.set_pressure_thickness_for_active_tool(32.0); + state.set_pressure_thickness_for_active_tool_with(&crate::draw::TextMeasurer::default(), 32.0); state.on_mouse_motion(900, 100); let wide_preview_bounds = state .provisional_bounds() @@ -368,7 +369,7 @@ fn pressure_preview_release_cleans_wide_preview_when_final_freehand_narrows() { ); let _ = state.take_dirty_regions(); - state.set_pressure_thickness_for_active_tool(2.0); + state.set_pressure_thickness_for_active_tool_with(&crate::draw::TextMeasurer::default(), 2.0); let _ = state.take_dirty_regions(); state.on_mouse_release(MouseButton::Left, 900, 100); let dirty = state.take_dirty_regions(); @@ -422,6 +423,7 @@ fn append_path_limit_rejection_clears_provisional_damage() { ); } +#[cfg(feature = "tablet-input")] #[test] fn pressure_sample_shrink_dirties_previous_full_provisional_bounds() { let mut state = create_test_input_state(); @@ -436,7 +438,7 @@ fn pressure_sample_shrink_dirties_previous_full_provisional_bounds() { let old_only_probe = crate::util::Rect::new(10, old_full_bounds.y, 1, 1).unwrap(); let _ = state.take_dirty_regions(); - state.set_pressure_thickness_for_active_tool(2.0); + state.set_pressure_thickness_for_active_tool_with(&crate::draw::TextMeasurer::default(), 2.0); state.on_mouse_motion(30, 10); let dirty = state.take_dirty_regions(); diff --git a/src/input/state/tests/radial_menu.rs b/src/input/state/tests/radial_menu.rs index f2c7f3d66..38de6e8ae 100644 --- a/src/input/state/tests/radial_menu.rs +++ b/src/input/state/tests/radial_menu.rs @@ -1014,7 +1014,10 @@ fn recent_colors_are_deduped_most_recent_first_and_capped() { }; // Steps of 0.125 are exact binary fractions, so equality is exact. for i in 0..8 { - state.apply_color_from_ui(color(i as f64 * 0.125)); + state.apply_color_from_ui_with_measurer( + &crate::draw::TextMeasurer::default(), + color(i as f64 * 0.125), + ); } assert_eq!(state.style.recent_colors.len(), 6, "recents are capped"); assert_eq!( @@ -1024,7 +1027,7 @@ fn recent_colors_are_deduped_most_recent_first_and_capped() { ); // Re-applying an existing color moves it to the front without growing. - state.apply_color_from_ui(color(0.5)); + state.apply_color_from_ui_with_measurer(&crate::draw::TextMeasurer::default(), color(0.5)); assert_eq!(state.style.recent_colors.len(), 6); assert_eq!(state.style.recent_colors[0], color(0.5)); assert_eq!( @@ -1048,14 +1051,14 @@ fn radial_ring_appends_recents_after_quick_palette_without_duplicates() { b: 0.33, a: 1.0, }; - state.apply_color_from_ui(unique); + state.apply_color_from_ui_with_measurer(&crate::draw::TextMeasurer::default(), unique); // A recent identical to a quick swatch is filtered from the ring. let quick0 = state .style .quick_colors .radial_color_for_index(0) .expect("quick color 0"); - state.apply_color_from_ui(quick0); + state.apply_color_from_ui_with_measurer(&crate::draw::TextMeasurer::default(), quick0); let swatches = state.radial_ring_swatches(); assert_eq!(swatches.len(), quick_len + 1); @@ -1073,14 +1076,14 @@ fn recent_color_segment_applies_through_the_color_path() { b: 0.33, a: 1.0, }; - state.apply_color_from_ui(unique); + state.apply_color_from_ui_with_measurer(&crate::draw::TextMeasurer::default(), unique); // Move the current color away so applying the recent is observable. let quick0 = state .style .quick_colors .radial_color_for_index(0) .expect("quick color 0"); - state.apply_color_from_ui(quick0); + state.apply_color_from_ui_with_measurer(&crate::draw::TextMeasurer::default(), quick0); let quick_len = state.style.quick_colors.radial_rendered_len(); let layout = open_with_layout(&mut state); diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index 9a7bcd04e..3efe34f53 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -678,7 +678,9 @@ fn accepting_a_recolor_keeps_the_swatch_and_queues_the_durable_write() { fn recoloring_the_swatch_in_use_moves_the_tool_color_with_it() { let mut state = create_test_input_state(); let slot_color = state.style.quick_colors.color_for_index(3).expect("slot 3"); - assert!(state.apply_color_from_ui(slot_color)); + assert!( + state.apply_color_from_ui_with_measurer(&crate::draw::TextMeasurer::default(), slot_color) + ); state.preset_slots.restore_active(Some(2)); state.clear_session_dirty(); @@ -1126,13 +1128,16 @@ fn recent_swatches_are_hit_testable_in_the_popup() { b: 0.875, a: 1.0, }; - state.apply_color_from_ui(stashed); - state.apply_color_from_ui(Color { - r: 1.0, - g: 1.0, - b: 0.0, - a: 1.0, - }); + state.apply_color_from_ui_with_measurer(&crate::draw::TextMeasurer::default(), stashed); + state.apply_color_from_ui_with_measurer( + &crate::draw::TextMeasurer::default(), + Color { + r: 1.0, + g: 1.0, + b: 0.0, + a: 1.0, + }, + ); state.open_color_picker_popup(); state.update_color_picker_popup_layout(1920, 1080); @@ -1260,7 +1265,7 @@ fn a_picker_drag_released_over_a_recent_swatch_does_not_adopt_it() { b: 0.875, a: 1.0, }; - state.apply_color_from_ui(stashed); + state.apply_color_from_ui_with_measurer(&crate::draw::TextMeasurer::default(), stashed); state.open_color_picker_popup(); state.color_picker_popup_set_hue(0.1); diff --git a/src/input/tablet/mod.rs b/src/input/tablet/mod.rs index bcfa1ef1e..a5fbeae41 100644 --- a/src/input/tablet/mod.rs +++ b/src/input/tablet/mod.rs @@ -39,6 +39,16 @@ pub(crate) fn try_apply_pressure_to_state( pressure01: f64, state: &mut InputState, settings: TabletSettings, +) -> bool { + let measurer = crate::draw::TextMeasurer::default(); + try_apply_pressure_to_state_with(&measurer, pressure01, state, settings) +} + +pub(crate) fn try_apply_pressure_to_state_with( + measurer: &crate::draw::TextMeasurer, + pressure01: f64, + state: &mut InputState, + settings: TabletSettings, ) -> bool { if !settings.enabled || !settings.pressure_enabled @@ -61,7 +71,7 @@ pub(crate) fn try_apply_pressure_to_state( ); } - state.set_pressure_thickness_for_active_tool(new_thickness); + state.set_pressure_thickness_for_active_tool_with(measurer, new_thickness); true } diff --git a/src/ui/radial_menu/cache.rs b/src/ui/radial_menu/cache.rs index fd8758429..2c611a0ab 100644 --- a/src/ui/radial_menu/cache.rs +++ b/src/ui/radial_menu/cache.rs @@ -294,12 +294,15 @@ mod tests { assert_ne!(base, scaled, "device scale must be part of the key"); // Recents arc - state.apply_color_from_ui(Color { - r: 0.123, - g: 0.456, - b: 0.789, - a: 1.0, - }); + state.apply_color_from_ui_with_measurer( + &crate::draw::TextMeasurer::default(), + Color { + r: 0.123, + g: 0.456, + b: 0.789, + a: 1.0, + }, + ); let with_recent = key_for(&state); assert_ne!(base, with_recent, "a new recent color must invalidate"); From e1a90bec6c95cf27acb3d9d0b0d1ed18976ad6c3 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:39:21 +0200 Subject: [PATCH 39/42] refactor(capture): centralize region selector ownership --- src/backend/wayland/state/ocr.rs | 65 +++--- src/backend/wayland/state/region_capture.rs | 1 + .../state/region_capture/active_state.rs | 99 +++++++++ .../state/region_capture/cut_preview/tests.rs | 1 + .../region_capture/cut_review/actions.rs | 75 +++---- .../state/region_capture/cut_review/tests.rs | 26 +-- .../wayland/state/region_capture/events.rs | 189 +++++++++-------- .../state/region_capture/review_state.rs | 20 +- .../wayland/state/region_capture/runtime.rs | 111 +++++++--- .../state/region_capture/source_guard.rs | 11 +- .../region_capture/tests/capture_selection.rs | 1 + .../tests/event_characterization.rs | 1 + .../tests/lifecycle_and_measure.rs | 8 + .../state/region_capture/tests/review.rs | 29 ++- src/input/state/core/region_select.rs | 193 +++++++++--------- 15 files changed, 500 insertions(+), 330 deletions(-) diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs index d6db77568..dd66c594c 100644 --- a/src/backend/wayland/state/ocr.rs +++ b/src/backend/wayland/state/ocr.rs @@ -17,7 +17,6 @@ use super::WaylandState; use super::acquisition::report_screen_source_activation_rejected_to; use super::region_capture::{ ActiveScreenRegion, FreezeOwnership, RegionOwnerLoss, RegionSelectionFinalize, - finalize_region_selection_with_review_edits, }; use super::screen_image::{ CropError, DisplayedScreenImage, ScreenSourceEntry, copy_image_rect, displayed_screen_image, @@ -225,7 +224,8 @@ impl WaylandState { pub(in crate::backend::wayland) fn cancel_ocr(&mut self) -> bool { let Some(region) = self.region_capture.active() else { self.acquisition.clear_zoom_waiter(ZoomWaiterOwner::Ocr); - self.input_state.cancel_region_ui_only(); + self.region_capture + .sync_input_projection(&mut self.input_state); return false; }; let pending_acquisition = region.pending_acquisition(); @@ -307,38 +307,35 @@ impl WaylandState { if self.finish_region_cut_drag(source, (x, y)) { return true; } - let (active, review_edits) = self.region_capture.selection_parts(); - let rect = match finalize_region_selection_with_review_edits( - active, - &mut self.input_state, - review_edits, - source, - (x, y), - ) { - RegionSelectionFinalize::NotOwned => return false, - RegionSelectionFinalize::Rearmed => return true, - RegionSelectionFinalize::Reviewed => return true, - RegionSelectionFinalize::Measured => return true, - RegionSelectionFinalize::Selected { - purpose: RegionPurposeTag::Ocr, - rect, - } => rect, - RegionSelectionFinalize::Selected { - purpose: RegionPurposeTag::CaptureDeliver, - rect, - } => { - self.submit_region_capture(rect); - return true; - } - RegionSelectionFinalize::Selected { - purpose: RegionPurposeTag::CaptureInteractive, - .. - } => return true, - RegionSelectionFinalize::Selected { - purpose: RegionPurposeTag::Measure, - .. - } => return true, - }; + let rect = + match self + .region_capture + .finalize_selection(&mut self.input_state, source, (x, y)) + { + RegionSelectionFinalize::NotOwned => return false, + RegionSelectionFinalize::Rearmed => return true, + RegionSelectionFinalize::Reviewed => return true, + RegionSelectionFinalize::Measured => return true, + RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::Ocr, + rect, + } => rect, + RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::CaptureDeliver, + rect, + } => { + self.submit_region_capture(rect); + return true; + } + RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::CaptureInteractive, + .. + } => return true, + RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::Measure, + .. + } => return true, + }; // The crop is taken while the capture is still held: releasing first // would leave the worker reading pixels that no longer exist. diff --git a/src/backend/wayland/state/region_capture.rs b/src/backend/wayland/state/region_capture.rs index 73e888cb9..d44694a44 100644 --- a/src/backend/wayland/state/region_capture.rs +++ b/src/backend/wayland/state/region_capture.rs @@ -23,6 +23,7 @@ mod selection_state; mod source_guard; mod window_snap; +use active_state::RegionInteractionPhase; pub(super) use active_state::{ActiveScreenRegion, FreezeOwnership}; pub(in crate::backend::wayland) use board::{ board_bounds_for_world_rect, world_rect_for_composed_region, world_rect_for_image_rect_exact, diff --git a/src/backend/wayland/state/region_capture/active_state.rs b/src/backend/wayland/state/region_capture/active_state.rs index df18f0c45..9f521c086 100644 --- a/src/backend/wayland/state/region_capture/active_state.rs +++ b/src/backend/wayland/state/region_capture/active_state.rs @@ -6,6 +6,14 @@ pub(in crate::backend::wayland::state) enum FreezeOwnership { PickerOwned { image_generation: u64 }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland::state) enum RegionInteractionPhase { + Armed, + Selecting { owner: RegionInputSource }, + Review { owner: Option }, + Measured, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub(in crate::backend::wayland::state) enum ActiveScreenRegion { Measure { @@ -13,6 +21,7 @@ pub(in crate::backend::wayland::state) enum ActiveScreenRegion { bounds: (u32, u32), anchor: Option<(f64, f64)>, edge: Option<(f64, f64)>, + phase: RegionInteractionPhase, }, PendingFrozen { purpose: RegionPurposeTag, @@ -38,10 +47,100 @@ pub(in crate::backend::wayland::state) enum ActiveScreenRegion { /// The grip a device is dragging in Review, if any. Mutually exclusive /// with `logical_anchor`, which owns a Review move-drag. review_resize: Option, + phase: RegionInteractionPhase, }, } impl ActiveScreenRegion { + pub(super) fn ui_state(self) -> RegionSelectUiState { + let purpose = self.purpose(); + let generation = self.generation(); + match self { + Self::PendingFrozen { .. } => RegionSelectUiState::PendingCapture { + purpose, + generation, + source: ScreenCaptureSource::Frozen, + }, + Self::PendingZoom { .. } => RegionSelectUiState::PendingCapture { + purpose, + generation, + source: ScreenCaptureSource::Zoom, + }, + Self::Measure { phase, .. } | Self::Ready { phase, .. } => match phase { + RegionInteractionPhase::Armed => RegionSelectUiState::Armed { + purpose, + generation, + }, + RegionInteractionPhase::Selecting { owner } => { + let selection = self + .display_selection() + .expect("selecting screen region must own geometry"); + RegionSelectUiState::Selecting { + purpose, + generation, + owner, + start: selection.start, + current: selection.end, + } + } + RegionInteractionPhase::Review { owner } => { + let display = self + .review_geometry() + .map(RegionSelectionGeometry::display_selection) + .expect("reviewing screen region must own geometry"); + RegionSelectUiState::Review { + purpose, + generation, + display, + move_owner: owner, + } + } + RegionInteractionPhase::Measured => { + let display = self + .measure_selection() + .expect("completed measurement must own geometry"); + RegionSelectUiState::Measured { + purpose, + generation, + display, + } + } + }, + } + } + + pub(super) const fn phase(self) -> Option { + match self { + Self::Measure { phase, .. } | Self::Ready { phase, .. } => Some(phase), + Self::PendingFrozen { .. } | Self::PendingZoom { .. } => None, + } + } + + pub(super) fn set_phase(&mut self, next: RegionInteractionPhase) -> bool { + let phase = match self { + Self::Measure { phase, .. } | Self::Ready { phase, .. } => phase, + Self::PendingFrozen { .. } | Self::PendingZoom { .. } => return false, + }; + if *phase == next { + return false; + } + *phase = next; + true + } + + pub(super) fn selection_owner(self) -> Option { + match self.phase() { + Some(RegionInteractionPhase::Selecting { owner }) + | Some(RegionInteractionPhase::Review { owner: Some(owner) }) => Some(owner), + Some( + RegionInteractionPhase::Armed + | RegionInteractionPhase::Review { owner: None } + | RegionInteractionPhase::Measured, + ) + | None => None, + } + } + pub const fn purpose(self) -> RegionPurposeTag { match self { Self::Measure { .. } => RegionPurposeTag::Measure, diff --git a/src/backend/wayland/state/region_capture/cut_preview/tests.rs b/src/backend/wayland/state/region_capture/cut_preview/tests.rs index 43cca13eb..df89d9e54 100644 --- a/src/backend/wayland/state/region_capture/cut_preview/tests.rs +++ b/src/backend/wayland/state/region_capture/cut_preview/tests.rs @@ -331,6 +331,7 @@ fn ready(purpose: crate::input::state::RegionPurposeTag) -> ActiveScreenRegion { legend_dismissed: false, include_drawings: false, review_resize: None, + phase: super::super::RegionInteractionPhase::Armed, } } diff --git a/src/backend/wayland/state/region_capture/cut_review/actions.rs b/src/backend/wayland/state/region_capture/cut_review/actions.rs index a7f307aae..afccef5a7 100644 --- a/src/backend/wayland/state/region_capture/cut_review/actions.rs +++ b/src/backend/wayland/state/region_capture/cut_review/actions.rs @@ -3,7 +3,6 @@ use super::model::{CutCommit, CutMode, RegionReviewCorrelation, RegionReviewEdit use crate::backend::wayland::state::WaylandState; use crate::backend::wayland::state::region_capture::ActiveScreenRegion; use crate::capture::CutAxis; -use crate::input::InputState; use crate::input::state::{RegionInputSource, RegionSelection}; use crate::screen_pixels::ImagePixelRect; use crate::ui::{RegionActionAvailability, RegionCutStatus}; @@ -27,28 +26,11 @@ pub(in crate::backend::wayland::state::region_capture) fn review_edits_for_activ )) } -fn retire_cut_drag_owner(input: &mut InputState, owner: Option) { - if let Some(owner) = owner { - let _ = input.finish_region_review_move(owner); - } -} - pub(super) fn apply_cut_history_change( edits: &mut Option, - input: &mut InputState, change: impl FnOnce(&mut RegionReviewEdits) -> bool, ) -> bool { - let owner = edits - .as_ref() - .and_then(|edits| edits.drag.map(|drag| drag.owner)); - let Some(edits) = edits.as_mut() else { - return false; - }; - if !change(edits) { - return false; - } - retire_cut_drag_owner(input, owner); - true + edits.as_mut().is_some_and(change) } impl WaylandState { @@ -119,7 +101,11 @@ impl WaylandState { } fn retire_region_cut_drag_owner(&mut self, owner: Option) { - retire_cut_drag_owner(&mut self.input_state, owner); + if let Some(owner) = owner { + let _ = self + .region_capture + .finish_review_aux_drag(&mut self.input_state, owner); + } } fn toggle_region_cut_mode(&mut self) -> bool { @@ -136,13 +122,16 @@ impl WaylandState { let Some(fingerprint) = self.current_region_fingerprint() else { return false; }; - if !apply_cut_history_change( - self.region_capture.review_edits_slot_mut(), - &mut self.input_state, - |edits| edits.undo(fingerprint), - ) { + let owner = self + .region_capture + .review_edits() + .and_then(|edits| edits.drag.map(|drag| drag.owner)); + if !apply_cut_history_change(self.region_capture.review_edits_slot_mut(), |edits| { + edits.undo(fingerprint) + }) { return false; } + self.retire_region_cut_drag_owner(owner); self.mark_region_cut_ui_dirty(); self.schedule_region_cut_preview(); true @@ -152,26 +141,33 @@ impl WaylandState { let Some(fingerprint) = self.current_region_fingerprint() else { return false; }; - if !apply_cut_history_change( - self.region_capture.review_edits_slot_mut(), - &mut self.input_state, - |edits| edits.redo(fingerprint), - ) { + let owner = self + .region_capture + .review_edits() + .and_then(|edits| edits.drag.map(|drag| drag.owner)); + if !apply_cut_history_change(self.region_capture.review_edits_slot_mut(), |edits| { + edits.redo(fingerprint) + }) { return false; } + self.retire_region_cut_drag_owner(owner); self.mark_region_cut_ui_dirty(); self.schedule_region_cut_preview(); true } fn reset_region_cuts(&mut self) -> bool { + let owner = self + .region_capture + .review_edits() + .and_then(|edits| edits.drag.map(|drag| drag.owner)); if !apply_cut_history_change( self.region_capture.review_edits_slot_mut(), - &mut self.input_state, RegionReviewEdits::reset, ) { return false; } + self.retire_region_cut_drag_owner(owner); self.mark_region_cut_ui_dirty(); true } @@ -193,7 +189,10 @@ impl WaylandState { if !edits.begin_drag(owner, point) { return false; } - if !self.input_state.begin_region_review_move(owner) { + if !self + .region_capture + .begin_review_aux_drag(&mut self.input_state, owner) + { if let Some(edits) = self.region_capture.review_edits_mut() { edits.drag = None; } @@ -243,7 +242,9 @@ impl WaylandState { return false; }; let commit = edits.finish_drag(owner, point, display, fingerprint); - let _ = self.input_state.finish_region_review_move(owner); + let _ = self + .region_capture + .finish_review_aux_drag(&mut self.input_state, owner); match commit { CutCommit::Applied => { self.mark_region_cut_ui_dirty(); @@ -278,7 +279,9 @@ impl WaylandState { return false; } edits.drag = None; - let _ = self.input_state.finish_region_review_move(owner); + let _ = self + .region_capture + .finish_review_aux_drag(&mut self.input_state, owner); self.mark_region_cut_ui_dirty(); true } @@ -295,7 +298,9 @@ impl WaylandState { return false; } if let Some(owner) = owner { - let _ = self.input_state.finish_region_review_move(owner); + let _ = self + .region_capture + .finish_review_aux_drag(&mut self.input_state, owner); } self.mark_region_cut_ui_dirty(); true diff --git a/src/backend/wayland/state/region_capture/cut_review/tests.rs b/src/backend/wayland/state/region_capture/cut_review/tests.rs index 56e3d2b89..802bae139 100644 --- a/src/backend/wayland/state/region_capture/cut_review/tests.rs +++ b/src/backend/wayland/state/region_capture/cut_review/tests.rs @@ -236,16 +236,6 @@ fn undo_with_nothing_to_undo_leaves_an_in_flight_drag() { assert!(edits.drag.is_some()); } -fn review_input() -> crate::input::InputState { - let mut input = crate::input::state::test_support::make_test_input_state(); - input.activate_region_review( - crate::input::state::RegionPurposeTag::CaptureInteractive, - 1, - display(), - ); - input -} - fn edits_with_current_preview() -> RegionReviewEdits { let mut edits = edits(); let fingerprint = fingerprint(edits.source_rect); @@ -266,28 +256,21 @@ fn edits_with_current_preview() -> RegionReviewEdits { } #[test] -fn undo_and_redo_retire_pointer_touch_and_tablet_owners_before_release() { +fn undo_and_redo_retire_pointer_touch_and_tablet_cut_drags_before_release() { for owner in [ RegionInputSource::Pointer, RegionInputSource::Touch, RegionInputSource::Stylus, ] { - let mut input = review_input(); let mut edits = Some(edits_with_current_preview()); edits.as_mut().unwrap().toggle_mode(); assert!(edits.as_mut().unwrap().begin_drag(owner, (1.0, 1.0))); - assert!(input.begin_region_review_move(owner)); - assert!(input.region_selection_is_owned_by(owner)); let fingerprint = fingerprint(edits.as_ref().unwrap().source_rect); - assert!(apply_cut_history_change(&mut edits, &mut input, |edits| { + assert!(apply_cut_history_change(&mut edits, |edits| { edits.undo(fingerprint.clone()) })); assert!(edits.as_ref().unwrap().drag.is_none()); - assert!( - !input.region_selection_is_owned_by(owner), - "{owner:?} must be retired before release" - ); assert_eq!( edits .as_mut() @@ -296,15 +279,12 @@ fn undo_and_redo_retire_pointer_touch_and_tablet_owners_before_release() { CutCommit::None, "{owner:?} release must not commit after undo" ); - assert!(!input.finish_region_review_move(owner)); assert!(edits.as_mut().unwrap().begin_drag(owner, (1.0, 1.0))); - assert!(input.begin_region_review_move(owner)); - assert!(apply_cut_history_change(&mut edits, &mut input, |edits| { + assert!(apply_cut_history_change(&mut edits, |edits| { edits.redo(fingerprint.clone()) })); assert!(edits.as_ref().unwrap().drag.is_none()); - assert!(!input.region_selection_is_owned_by(owner)); assert_eq!( edits .as_mut() diff --git a/src/backend/wayland/state/region_capture/events.rs b/src/backend/wayland/state/region_capture/events.rs index fee63910c..c5974c9dd 100644 --- a/src/backend/wayland/state/region_capture/events.rs +++ b/src/backend/wayland/state/region_capture/events.rs @@ -13,42 +13,53 @@ pub(super) fn review_resize_handle_at( crate::ui::RegionResizeHandles::place(selection).hit(logical) } +fn sync_projection( + backend: &Option, + input_state: &mut crate::input::InputState, +) { + input_state.sync_region_projection( + backend + .map(ActiveScreenRegion::ui_state) + .unwrap_or_default(), + ); +} + pub(super) fn begin_region_selection_event( backend: &mut Option, input_state: &mut crate::input::InputState, owner: RegionInputSource, logical: (f64, f64), ) -> bool { - if input_state.region_state().selection_owner().is_some() { - return false; - } let Some(region) = backend.as_mut() else { return false; }; - if input_state.region_state().is_review() { + if region.selection_owner().is_some() { + return false; + } + if matches!(region.phase(), Some(RegionInteractionPhase::Review { .. })) { // A grip is checked before the rectangle it decorates: corner chips sit // on the rectangle's own edge, so the interior test would otherwise // swallow every resize press. if let Some(handle) = review_resize_handle_at(region, logical) && region.begin_review_resize(handle, logical) { - return input_state.begin_region_review_move(owner); + region.set_phase(RegionInteractionPhase::Review { owner: Some(owner) }); + sync_projection(backend, input_state); + return true; } if region.begin_review_move(logical) { - return input_state.begin_region_review_move(owner); + region.set_phase(RegionInteractionPhase::Review { owner: Some(owner) }); + sync_projection(backend, input_state); + return true; } region.reset_review_for_selection(); + region.set_phase(RegionInteractionPhase::Armed); } if !region.begin_selection(logical) { return false; } - let Some(preview) = region.display_selection() else { - return false; - }; - if !input_state.start_region_selection(owner, preview.start) { - return false; - } - input_state.update_region_selection(owner, preview.end); + region.set_phase(RegionInteractionPhase::Selecting { owner }); + sync_projection(backend, input_state); true } @@ -58,10 +69,15 @@ pub(super) fn update_region_selection_event( owner: RegionInputSource, logical: (f64, f64), ) { + let state = backend + .as_ref() + .copied() + .map(ActiveScreenRegion::ui_state) + .unwrap_or_default(); if backend .as_ref() .is_some_and(|region| region.purpose().is_capture()) - && input_state.region_is_active() + && state.is_active() { // Capture chrome follows hover even when no device owns a drag: Armed // paints the crosshair/readout, while Review paints bar hover and the @@ -72,39 +88,39 @@ pub(super) fn update_region_selection_event( } else if backend .as_ref() .is_some_and(|region| region.purpose() == RegionPurposeTag::Measure) - && input_state.region_is_active() + && state.is_active() { // Measure has no full-screen scrim. Its old/current chrome strips are // added by collect_ui_effect_damage, so motion only schedules a frame. input_state.needs_redraw = true; } - if !input_state.region_selection_is_owned_by(owner) { + let Some(region) = backend.as_mut() else { return; - } - if input_state.region_state().is_review() { - if let Some(region) = backend.as_mut() - && (region.update_review_resize(logical) || region.update_review_move(logical)) - && let Some(preview) = region - .review_geometry() - .map(RegionSelectionGeometry::display_selection) - { - input_state.update_region_review_display(preview); - } + }; + if region.selection_owner() != Some(owner) { return; } - if let Some(region) = backend.as_mut() - && region.update_endpoint(logical) - && let Some(preview) = region.display_selection() - { - input_state.update_region_selection(owner, preview.end); + let changed = if matches!(region.phase(), Some(RegionInteractionPhase::Review { .. })) { + region.update_review_resize(logical) || region.update_review_move(logical) + } else { + region.update_endpoint(logical) + }; + if changed { + sync_projection(backend, input_state); } } /// End whichever Review drag a device owned. Exactly one of the two can be in -/// flight, so this reports a single "the drag ended" answer that stays in step -/// with the UI state's own move owner. -fn finish_review_drag(region: &mut ActiveScreenRegion) -> bool { - region.finish_review_resize() || region.finish_review_move() +/// flight, so this reports a single "the drag ended" answer. +fn finish_review_drag(region: &mut ActiveScreenRegion, owner: RegionInputSource) -> bool { + if region.selection_owner() != Some(owner) { + return false; + } + let finished = region.finish_review_resize() || region.finish_review_move(); + if finished { + region.set_phase(RegionInteractionPhase::Review { owner: None }); + } + finished } pub(super) fn sync_region_square_modifier_event( @@ -118,13 +134,7 @@ pub(super) fn sync_region_square_modifier_event( if !region.set_square_modifier(shift) { return false; } - if let Some(owner) = input_state.region_state().selection_owner() - && let Some(preview) = region - .selection_geometry() - .map(RegionSelectionGeometry::display_selection) - { - input_state.update_region_selection(owner, preview.end); - } + sync_projection(backend, input_state); true } @@ -142,6 +152,7 @@ pub(super) fn rearm_region_selection_event( logical_anchor, logical_edge, review_resize, + phase, .. }) = backend.as_mut() { @@ -150,12 +161,20 @@ pub(super) fn rearm_region_selection_event( *logical_anchor = None; *logical_edge = None; *review_resize = None; + *phase = RegionInteractionPhase::Armed; } - if let Some(ActiveScreenRegion::Measure { anchor, edge, .. }) = backend.as_mut() { + if let Some(ActiveScreenRegion::Measure { + anchor, + edge, + phase, + .. + }) = backend.as_mut() + { *anchor = None; *edge = None; + *phase = RegionInteractionPhase::Armed; } - input_state.rearm_region_selection(); + sync_projection(backend, input_state); } pub(super) fn region_owner_lost_event( @@ -163,23 +182,24 @@ pub(super) fn region_owner_lost_event( input_state: &mut crate::input::InputState, source: RegionInputSource, ) -> RegionOwnerLoss { - if !input_state.region_selection_is_owned_by(source) { + let Some(region) = backend.as_mut() else { + return RegionOwnerLoss::NotOwned; + }; + if region.selection_owner() != Some(source) { return RegionOwnerLoss::NotOwned; } - if input_state.region_state().purpose() == Some(RegionPurposeTag::Measure) { + let purpose = region.purpose(); + if purpose == RegionPurposeTag::Measure { rearm_region_selection_event(backend, input_state); return RegionOwnerLoss::Rearmed; } - let Some(purpose) = backend.as_ref().map(|region| region.purpose()) else { - return RegionOwnerLoss::NotOwned; - }; if !purpose.is_capture() { return RegionOwnerLoss::Cancel(purpose); } - if input_state.region_state().is_review() { - let finished_backend = backend.as_mut().is_some_and(finish_review_drag); - let finished_ui = input_state.finish_region_review_move(source); - debug_assert_eq!(finished_backend, finished_ui); + if matches!(region.phase(), Some(RegionInteractionPhase::Review { .. })) { + let finished = finish_review_drag(region, source); + debug_assert!(finished); + sync_projection(backend, input_state); return RegionOwnerLoss::Rearmed; } rearm_region_selection_event(backend, input_state); @@ -197,63 +217,56 @@ fn finalize_region_selection( owner: RegionInputSource, logical: (f64, f64), ) -> SelectionFinalization { - if !input_state.region_selection_is_owned_by(owner) { + let Some(region) = backend.as_mut() else { return SelectionFinalization::Complete(RegionSelectionFinalize::NotOwned); - } - if !backend.as_ref().is_some_and(|region| { - input_state.region_state().purpose() == Some(region.purpose()) - && input_state.region_state().generation() == Some(region.generation()) - }) { + }; + if region.selection_owner() != Some(owner) { return SelectionFinalization::Complete(RegionSelectionFinalize::NotOwned); } - if input_state.region_state().purpose() == Some(RegionPurposeTag::Measure) { - update_region_selection_event(backend, input_state, owner, logical); - return SelectionFinalization::Complete( - if backend - .as_ref() - .and_then(|region| region.measure_selection()) - .is_some() - && input_state.complete_measurement(owner) - { - RegionSelectionFinalize::Measured - } else { - RegionSelectionFinalize::NotOwned - }, - ); + if region.purpose() == RegionPurposeTag::Measure { + region.update_endpoint(logical); + if region.measure_selection().is_some() { + region.set_phase(RegionInteractionPhase::Measured); + sync_projection(backend, input_state); + return SelectionFinalization::Complete(RegionSelectionFinalize::Measured); + } + return SelectionFinalization::Complete(RegionSelectionFinalize::NotOwned); } - if input_state.region_state().is_review() { - update_region_selection_event(backend, input_state, owner, logical); - let finished_backend = backend.as_mut().is_some_and(finish_review_drag); - let finished_ui = input_state.finish_region_review_move(owner); - debug_assert_eq!(finished_backend, finished_ui); - return SelectionFinalization::Complete(if finished_backend { + if matches!(region.phase(), Some(RegionInteractionPhase::Review { .. })) { + let changed = region.update_review_resize(logical) || region.update_review_move(logical); + let finished = finish_review_drag(region, owner); + if changed || finished { + sync_projection(backend, input_state); + } + return SelectionFinalization::Complete(if finished { RegionSelectionFinalize::Reviewed } else { RegionSelectionFinalize::NotOwned }); } - let Some(region @ ActiveScreenRegion::Ready { .. }) = backend.as_mut() else { + let ActiveScreenRegion::Ready { .. } = region else { return SelectionFinalization::Complete(RegionSelectionFinalize::NotOwned); }; - if region.purpose().is_capture() && input_state.region_is_active() { + if region.purpose().is_capture() { input_state.dirty_tracker.mark_full(); input_state.needs_redraw = true; } - if region.update_endpoint(logical) - && let Some(preview) = region.display_selection() - { - input_state.update_region_selection(owner, preview.end); - } - let Some(rect) = region.selection_rect() else { + region.update_endpoint(logical); + sync_projection(backend, input_state); + let Some(rect) = backend.as_ref().and_then(|region| region.selection_rect()) else { rearm_region_selection_event(backend, input_state); return SelectionFinalization::Complete(RegionSelectionFinalize::Rearmed); }; + let region = backend + .as_mut() + .expect("active region retained while finalizing"); let purpose = region.purpose(); if purpose == RegionPurposeTag::CaptureInteractive { let Some(seed) = region.enter_review_seed(rect) else { return SelectionFinalization::Complete(RegionSelectionFinalize::NotOwned); }; - input_state.activate_region_review(purpose, seed.generation, seed.display); + region.set_phase(RegionInteractionPhase::Review { owner: None }); + sync_projection(backend, input_state); return SelectionFinalization::Review(seed); } SelectionFinalization::Complete(RegionSelectionFinalize::Selected { purpose, rect }) diff --git a/src/backend/wayland/state/region_capture/review_state.rs b/src/backend/wayland/state/region_capture/review_state.rs index 412fa5368..5fe3dcd6d 100644 --- a/src/backend/wayland/state/region_capture/review_state.rs +++ b/src/backend/wayland/state/region_capture/review_state.rs @@ -4,6 +4,7 @@ pub(super) struct InteractiveReviewSeed { pub(super) generation: u64, pub(super) source: ScreenSourceToken, pub(super) rect: ImagePixelRect, + #[cfg(test)] pub(super) display: RegionSelection, } @@ -33,6 +34,7 @@ impl ActiveScreenRegion { logical_anchor, logical_edge, review_resize, + phase, .. } = self else { @@ -48,23 +50,27 @@ impl ActiveScreenRegion { rect.height(), source.image_size, )?; - let display = super::super::screen_image::screen_rect_for_image_rect(source, rect); let seed = InteractiveReviewSeed { generation: *generation, source: *source, rect, - display: RegionSelection { - start: (f64::from(display.x), f64::from(display.y)), - end: ( - f64::from(display.x.saturating_add(display.width)), - f64::from(display.y.saturating_add(display.height)), - ), + #[cfg(test)] + display: { + let display = super::super::screen_image::screen_rect_for_image_rect(source, rect); + RegionSelection { + start: (f64::from(display.x), f64::from(display.y)), + end: ( + f64::from(display.x.saturating_add(display.width)), + f64::from(display.y.saturating_add(display.height)), + ), + } }, }; // Re-entering Review replaces the rectangle wholesale — `Ctrl+A` can // do that while a grip is still held — so the old grip must not // survive to block the next move, resize or nudge. *review_resize = None; + *phase = RegionInteractionPhase::Review { owner: None }; *anchor = None; *raw_edge = None; *logical_anchor = None; diff --git a/src/backend/wayland/state/region_capture/runtime.rs b/src/backend/wayland/state/region_capture/runtime.rs index c31a763f3..47650092d 100644 --- a/src/backend/wayland/state/region_capture/runtime.rs +++ b/src/backend/wayland/state/region_capture/runtime.rs @@ -63,6 +63,7 @@ impl RegionCaptureRuntime { bounds, anchor: None, edge: None, + phase: RegionInteractionPhase::Armed, }); generation } @@ -113,6 +114,7 @@ impl RegionCaptureRuntime { legend_dismissed: false, include_drawings, review_resize: None, + phase: RegionInteractionPhase::Armed, }); } @@ -138,13 +140,64 @@ impl RegionCaptureRuntime { &mut self.review_edits } - pub(in crate::backend::wayland::state) fn selection_parts( + pub(in crate::backend::wayland::state) fn finalize_selection( &mut self, - ) -> ( - &mut Option, - &mut Option, + input_state: &mut crate::input::InputState, + owner: RegionInputSource, + logical: (f64, f64), + ) -> RegionSelectionFinalize { + finalize_region_selection_with_review_edits( + &mut self.active, + input_state, + &mut self.review_edits, + owner, + logical, + ) + } + + fn ui_state(&self) -> RegionSelectUiState { + self.active + .map(ActiveScreenRegion::ui_state) + .unwrap_or_default() + } + + pub(in crate::backend::wayland::state) fn sync_input_projection( + &self, + input_state: &mut crate::input::InputState, ) { - (&mut self.active, &mut self.review_edits) + input_state.sync_region_projection(self.ui_state()); + } + + pub(in crate::backend::wayland::state) fn begin_review_aux_drag( + &mut self, + input_state: &mut crate::input::InputState, + owner: RegionInputSource, + ) -> bool { + let Some(region) = self.active.as_mut() else { + return false; + }; + if region.phase() != Some(RegionInteractionPhase::Review { owner: None }) { + return false; + } + region.set_phase(RegionInteractionPhase::Review { owner: Some(owner) }); + self.sync_input_projection(input_state); + true + } + + pub(in crate::backend::wayland::state) fn finish_review_aux_drag( + &mut self, + input_state: &mut crate::input::InputState, + owner: RegionInputSource, + ) -> bool { + let Some(region) = self.active.as_mut() else { + return false; + }; + if region.phase() != Some(RegionInteractionPhase::Review { owner: Some(owner) }) { + return false; + } + region.set_phase(RegionInteractionPhase::Review { owner: None }); + self.sync_input_projection(input_state); + true } pub(in crate::backend::wayland::state) fn window_snap(&self) -> Option<&WindowSnapSession> { @@ -239,11 +292,10 @@ impl WaylandState { self.unlock_pointer(); self.retire_stylus_contact(); - let generation = self - .region_capture + self.region_capture .begin_measure((self.surface.width(), self.surface.height())); - self.input_state - .activate_measure_mode_with(self.render.text_measurer(), generation); + self.region_capture + .sync_input_projection(&mut self.input_state); self.debug_assert_screen_region_invariant(); } @@ -297,11 +349,8 @@ impl WaylandState { ) { self.region_capture .set_pending_frozen(purpose, generation, acquisition); - self.input_state.set_region_pending_capture( - purpose, - generation, - ScreenCaptureSource::Frozen, - ); + self.region_capture + .sync_input_projection(&mut self.input_state); self.debug_assert_screen_region_invariant(); } @@ -311,8 +360,8 @@ impl WaylandState { generation: u64, ) { self.region_capture.set_pending_zoom(purpose, generation); - self.input_state - .set_region_pending_capture(purpose, generation, ScreenCaptureSource::Zoom); + self.region_capture + .sync_input_projection(&mut self.input_state); self.debug_assert_screen_region_invariant(); } @@ -338,6 +387,11 @@ impl WaylandState { ) else { return false; }; + // Activation can follow asynchronous capture completion. Cancel any + // interaction that began while the selector was pending before the + // backend publishes the armed projection. + self.input_state + .prepare_for_screen_modal_with_measurer(self.render.text_measurer()); self.region_capture.set_ready( purpose, generation, @@ -346,8 +400,8 @@ impl WaylandState { initial_square_modifier(purpose, self.input_state.modifiers.shift), include_drawings, ); - self.input_state - .activate_region_with(self.render.text_measurer(), purpose, generation); + self.region_capture + .sync_input_projection(&mut self.input_state); self.start_region_window_query(purpose, generation, token, freeze_ownership); self.debug_assert_screen_region_invariant(); true @@ -355,7 +409,8 @@ impl WaylandState { pub(in crate::backend::wayland::state) fn clear_screen_region_ui_only(&mut self) { self.region_capture.clear(); - self.input_state.cancel_region_ui_only(); + self.region_capture + .sync_input_projection(&mut self.input_state); self.debug_assert_screen_region_invariant(); } @@ -480,12 +535,10 @@ impl WaylandState { let Some(seed) = region.enter_review_seed(rect) else { return false; }; - self.input_state.activate_region_review( - RegionPurposeTag::CaptureInteractive, - seed.generation, - seed.display, - ); + region.set_phase(RegionInteractionPhase::Review { owner: None }); *self.region_capture.review_edits_slot_mut() = Some(seed.into_edits()); + self.region_capture + .sync_input_projection(&mut self.input_state); self.debug_assert_screen_region_invariant(); true } @@ -502,14 +555,16 @@ impl WaylandState { if self.region_review_crop_locked() { return true; } - let Some(display) = self + if self .region_capture .active_mut() .and_then(|region| region.nudge_review(delta_x, delta_y)) - else { + .is_none() + { return false; - }; - self.input_state.update_region_review_display(display); + } + self.region_capture + .sync_input_projection(&mut self.input_state); self.sync_region_review_source_rect(); true } diff --git a/src/backend/wayland/state/region_capture/source_guard.rs b/src/backend/wayland/state/region_capture/source_guard.rs index 39ae070fb..d8276d44d 100644 --- a/src/backend/wayland/state/region_capture/source_guard.rs +++ b/src/backend/wayland/state/region_capture/source_guard.rs @@ -4,13 +4,10 @@ pub(super) fn screen_region_invariant( backend: Option, ui: RegionSelectUiState, ) -> bool { - match (backend, ui) { - (None, RegionSelectUiState::Inactive) => true, - (None, _) | (Some(_), RegionSelectUiState::Inactive) => false, - (Some(region), ui) => { - ui.generation() == Some(region.generation()) && ui.purpose() == Some(region.purpose()) - } - } + backend + .map(ActiveScreenRegion::ui_state) + .unwrap_or_default() + == ui } pub(super) fn active_region_source_changed( diff --git a/src/backend/wayland/state/region_capture/tests/capture_selection.rs b/src/backend/wayland/state/region_capture/tests/capture_selection.rs index 5b55390c2..e6a9f890c 100644 --- a/src/backend/wayland/state/region_capture/tests/capture_selection.rs +++ b/src/backend/wayland/state/region_capture/tests/capture_selection.rs @@ -51,6 +51,7 @@ pub(super) fn capture_region_at_scale(scale: f64) -> ActiveScreenRegion { legend_dismissed: false, include_drawings: false, review_resize: None, + phase: RegionInteractionPhase::Armed, } } diff --git a/src/backend/wayland/state/region_capture/tests/event_characterization.rs b/src/backend/wayland/state/region_capture/tests/event_characterization.rs index f3dd6f693..c3832e7d2 100644 --- a/src/backend/wayland/state/region_capture/tests/event_characterization.rs +++ b/src/backend/wayland/state/region_capture/tests/event_characterization.rs @@ -12,6 +12,7 @@ fn active_region_for(purpose: RegionPurposeTag) -> ActiveScreenRegion { bounds: (100, 80), anchor: None, edge: None, + phase: RegionInteractionPhase::Armed, }, } } diff --git a/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs b/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs index 8d2437429..1a5d84d64 100644 --- a/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs +++ b/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs @@ -39,6 +39,7 @@ fn pending_and_ready_region_state_preserve_generation_and_freeze_ownership() { legend_dismissed: false, include_drawings: false, review_resize: None, + phase: RegionInteractionPhase::Armed, }; assert_eq!(ready.generation(), pending.generation()); assert_eq!(ready.owned_frozen_generation(), Some(44)); @@ -51,6 +52,7 @@ fn measure_mode_owns_logical_geometry_without_a_screen_image() { bounds: (800, 600), anchor: None, edge: None, + phase: RegionInteractionPhase::Armed, }); let mut input = make_test_input_state(); input.activate_measure_mode_with(&crate::draw::TextMeasurer::default(), 7); @@ -127,6 +129,7 @@ fn lost_measure_drag_rearms_for_another_device() { bounds: (800, 600), anchor: None, edge: None, + phase: RegionInteractionPhase::Armed, }); let mut input = make_test_input_state(); input.activate_measure_mode_with(&crate::draw::TextMeasurer::default(), 8); @@ -159,6 +162,7 @@ fn reversed_measure_drag_uses_outward_integer_edges() { bounds: (800, 600), anchor: None, edge: None, + phase: RegionInteractionPhase::Armed, }); let mut input = make_test_input_state(); input.activate_measure_mode_with(&crate::draw::TextMeasurer::default(), 9); @@ -288,6 +292,7 @@ pub(super) fn ocr_region(scale: f64) -> ActiveScreenRegion { legend_dismissed: false, include_drawings: false, review_resize: None, + phase: RegionInteractionPhase::Armed, } } @@ -339,6 +344,9 @@ fn measure_detects_a_real_surface_resize_without_needing_a_screen_source() { bounds: (100, 80), anchor: Some((10.0, 20.0)), edge: Some((30.0, 40.0)), + phase: RegionInteractionPhase::Selecting { + owner: RegionInputSource::Pointer, + }, }; assert!(!active_region_source_changed( diff --git a/src/backend/wayland/state/region_capture/tests/review.rs b/src/backend/wayland/state/region_capture/tests/review.rs index ef7fcdf97..5d76fc3c9 100644 --- a/src/backend/wayland/state/region_capture/tests/review.rs +++ b/src/backend/wayland/state/region_capture/tests/review.rs @@ -500,6 +500,7 @@ fn whole_image_is_available_to_every_purpose_that_can_submit_one() { bounds: (100, 80), anchor: None, edge: None, + phase: RegionInteractionPhase::Armed, }; assert_eq!(measure.whole_image_selection(), None); } @@ -973,25 +974,23 @@ fn rejected_review_rectangle_preserves_the_active_resize() { } #[test] -fn mismatched_generation_release_preserves_selection_and_owner() { +fn stale_ui_projection_cannot_override_the_controller_generation() { let mut backend = Some(interactive_region()); let mut input = make_test_input_state(); - input.activate_region_with( - &crate::draw::TextMeasurer::default(), - RegionPurposeTag::CaptureInteractive, - 1, - ); assert!(begin_region_selection_event( &mut backend, &mut input, RegionInputSource::Pointer, (20.0, 20.0), )); - if let Some(ActiveScreenRegion::Ready { generation, .. }) = backend.as_mut() { - *generation = 2; - } - let before_backend = backend; - let before_ui = input.region_state(); + let selection = input.region_state().selection().unwrap(); + input.sync_region_projection(RegionSelectUiState::Selecting { + purpose: RegionPurposeTag::CaptureInteractive, + generation: 99, + owner: RegionInputSource::Pointer, + start: selection.start, + current: selection.end, + }); let mut edits = None; assert_eq!( @@ -1002,9 +1001,9 @@ fn mismatched_generation_release_preserves_selection_and_owner() { RegionInputSource::Pointer, (60.0, 50.0), ), - RegionSelectionFinalize::NotOwned + RegionSelectionFinalize::Reviewed ); - assert_eq!(backend, before_backend); - assert_eq!(input.region_state(), before_ui); - assert!(edits.is_none()); + assert_eq!(input.region_state().generation(), Some(1)); + assert!(input.region_state().is_review()); + assert!(edits.is_some()); } diff --git a/src/input/state/core/region_select.rs b/src/input/state/core/region_select.rs index a400d8121..6ac98ce94 100644 --- a/src/input/state/core/region_select.rs +++ b/src/input/state/core/region_select.rs @@ -83,12 +83,12 @@ pub struct RegionSelection { pub end: (f64, f64), } -/// UI-facing lifecycle shared by OCR, native screen-region capture, and the -/// capture-free logical screen ruler. +/// Read-only UI projection of the backend-owned selector shared by OCR, +/// native screen-region capture, and the capture-free logical screen ruler. /// -/// It owns transient input and render state only. The selected pixels and the -/// recognition work belong outside `InputState`, so cancelling here can never -/// discard a request that is already running. +/// The Wayland region-capture controller owns lifecycle, device ownership, and +/// geometry. `InputState` retains only this synchronized value for input gating +/// and painting. #[derive(Debug, Default, Clone, Copy, PartialEq)] pub enum RegionSelectUiState { #[default] @@ -220,14 +220,6 @@ impl RegionSelectUiState { } impl InputState { - pub(crate) fn activate_measure_mode_with( - &mut self, - measurer: &crate::draw::TextMeasurer, - generation: u64, - ) { - self.activate_region_with(measurer, RegionPurposeTag::Measure, generation); - } - pub(crate) fn request_copy_text_from_screen(&mut self) { self.emit_input_effect(super::base::InputEffect::OcrPass { requested: true, @@ -255,18 +247,45 @@ impl InputState { self.region_select_ui_state.is_engaged() } + /// Replace the backend-owned selector projection used for input gating and + /// painting. Lifecycle and geometry mutations remain in the Wayland + /// region-capture controller. + pub(crate) fn sync_region_projection(&mut self, next: RegionSelectUiState) { + if self.region_select_ui_state == next { + return; + } + let targeted_measure_damage = self.region_select_ui_state.purpose() + == Some(RegionPurposeTag::Measure) + || next.purpose() == Some(RegionPurposeTag::Measure); + self.region_select_ui_state = next; + if !targeted_measure_damage { + self.dirty_tracker.mark_full(); + } + self.needs_redraw = true; + } +} + +#[cfg(test)] +impl InputState { + pub(crate) fn activate_measure_mode_with( + &mut self, + measurer: &crate::draw::TextMeasurer, + generation: u64, + ) { + self.activate_region_with(measurer, RegionPurposeTag::Measure, generation); + } + pub(crate) fn set_region_pending_capture( &mut self, purpose: RegionPurposeTag, generation: u64, source: ScreenCaptureSource, ) { - self.region_select_ui_state = RegionSelectUiState::PendingCapture { + self.sync_region_projection(RegionSelectUiState::PendingCapture { purpose, generation, source, - }; - self.mark_region_dirty(); + }); } pub(crate) fn activate_region_with( @@ -275,22 +294,13 @@ impl InputState { purpose: RegionPurposeTag, generation: u64, ) { - // A capture can take long enough for another interaction to begin while - // OCR is pending. Entering the modal state must cancel it so the - // selector cannot swallow the matching release event. self.prepare_for_screen_modal_with_measurer(measurer); - self.region_select_ui_state = RegionSelectUiState::Armed { + self.sync_region_projection(RegionSelectUiState::Armed { purpose, generation, - }; - self.mark_region_dirty(); + }); } - /// Begin a region drag owned by `owner`. - /// - /// Ignored unless the selector is armed, so a stray press during capture - /// cannot start a region against a stale image — and, because a drag in - /// progress is no longer armed, a second device cannot take one over. pub(crate) fn start_region_selection( &mut self, owner: RegionInputSource, @@ -313,14 +323,13 @@ impl InputState { } => (purpose, generation), _ => return false, }; - self.region_select_ui_state = RegionSelectUiState::Selecting { + self.sync_region_projection(RegionSelectUiState::Selecting { purpose, generation, owner, start: point, current: point, - }; - self.mark_region_dirty(); + }); true } @@ -330,65 +339,92 @@ impl InputState { generation: u64, display: RegionSelection, ) { - debug_assert_eq!(purpose, RegionPurposeTag::CaptureInteractive); - self.region_select_ui_state = RegionSelectUiState::Review { + self.sync_region_projection(RegionSelectUiState::Review { purpose, generation, display, move_owner: None, - }; - self.mark_region_dirty(); + }); } pub(crate) fn begin_region_review_move(&mut self, owner: RegionInputSource) -> bool { - let RegionSelectUiState::Review { move_owner, .. } = &mut self.region_select_ui_state + let RegionSelectUiState::Review { + purpose, + generation, + display, + move_owner: None, + } = self.region_select_ui_state else { return false; }; - if move_owner.is_some() { - return false; - } - *move_owner = Some(owner); - self.mark_region_dirty(); + self.sync_region_projection(RegionSelectUiState::Review { + purpose, + generation, + display, + move_owner: Some(owner), + }); true } pub(crate) fn update_region_review_display(&mut self, display: RegionSelection) { let RegionSelectUiState::Review { - display: current, .. - } = &mut self.region_select_ui_state + purpose, + generation, + move_owner, + .. + } = self.region_select_ui_state else { return; }; - if *current != display { - *current = display; - self.mark_region_dirty(); - } + self.sync_region_projection(RegionSelectUiState::Review { + purpose, + generation, + display, + move_owner, + }); } pub(crate) fn finish_region_review_move(&mut self, owner: RegionInputSource) -> bool { - let RegionSelectUiState::Review { move_owner, .. } = &mut self.region_select_ui_state + let RegionSelectUiState::Review { + purpose, + generation, + display, + move_owner: Some(current), + } = self.region_select_ui_state else { return false; }; - if *move_owner != Some(owner) { + if current != owner { return false; } - *move_owner = None; - self.mark_region_dirty(); + self.sync_region_projection(RegionSelectUiState::Review { + purpose, + generation, + display, + move_owner: None, + }); true } - /// Move the region, if `source` is the device dragging it. Motion from any - /// other device is somebody else's and is dropped. pub(crate) fn update_region_selection(&mut self, source: RegionInputSource, point: (f64, f64)) { - if let RegionSelectUiState::Selecting { owner, current, .. } = - &mut self.region_select_ui_state - && *owner == source - && *current != point - { - *current = point; - self.mark_region_dirty(); + let RegionSelectUiState::Selecting { + purpose, + generation, + owner, + start, + .. + } = self.region_select_ui_state + else { + return; + }; + if owner == source { + self.sync_region_projection(RegionSelectUiState::Selecting { + purpose, + generation, + owner, + start, + current: point, + }); } } @@ -406,28 +442,21 @@ impl InputState { if owner != source { return false; } - self.region_select_ui_state = RegionSelectUiState::Measured { + self.sync_region_projection(RegionSelectUiState::Measured { purpose: RegionPurposeTag::Measure, generation, display: RegionSelection { start, end: current, }, - }; - self.mark_region_dirty(); + }); true } - /// Whether `source` is the device dragging the region right now. The - /// release and per-device cancellation paths check this before acting, so - /// one device cannot submit or discard another's region. pub(crate) fn region_selection_is_owned_by(&self, source: RegionInputSource) -> bool { self.region_select_ui_state.selection_owner() == Some(source) } - /// Abandon a drag that was too small to be a region and wait for the next - /// one. A mis-click should not drop the user out of the selector, and it - /// must not release a capture the selector is still using. pub(crate) fn rearm_region_selection(&mut self) { if let RegionSelectUiState::Selecting { purpose, @@ -435,37 +464,15 @@ impl InputState { .. } = self.region_select_ui_state { - self.region_select_ui_state = RegionSelectUiState::Armed { + self.sync_region_projection(RegionSelectUiState::Armed { purpose, generation, - }; - self.mark_region_dirty(); + }); } } pub(crate) fn cancel_region_ui_only(&mut self) { - if !matches!(self.region_select_ui_state, RegionSelectUiState::Inactive) { - let was_measure = - self.region_select_ui_state.purpose() == Some(RegionPurposeTag::Measure); - self.region_select_ui_state = RegionSelectUiState::Inactive; - self.mark_region_dirty_for(was_measure); - } - } - - /// The selector paints a full-surface scrim with the selected region cut - /// out of it, so every change repaints the whole surface: an incremental - /// buffer would otherwise keep the scrim from an earlier frame. - fn mark_region_dirty(&mut self) { - self.mark_region_dirty_for( - self.region_select_ui_state.purpose() == Some(RegionPurposeTag::Measure), - ); - } - - fn mark_region_dirty_for(&mut self, targeted_measure_damage: bool) { - if !targeted_measure_damage { - self.dirty_tracker.mark_full(); - } - self.needs_redraw = true; + self.sync_region_projection(RegionSelectUiState::Inactive); } } From 875b17282d0f848105a46c4afc4921b2963c62aa Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:47:09 +0200 Subject: [PATCH 40/42] perf(ui): cache help content by runtime inputs --- src/backend/wayland/state/render/runtime.rs | 18 ++ src/backend/wayland/state/render/ui.rs | 18 +- src/input/state/core/utility/actions.rs | 4 + src/ui.rs | 2 +- src/ui/help_overlay/content.rs | 185 ++++++++++++++++++++ src/ui/help_overlay/mod.rs | 4 +- src/ui/help_overlay/render/cache.rs | 26 +-- src/ui/help_overlay/render/mod.rs | 42 ++++- src/ui/help_overlay/render/state.rs | 25 +-- src/ui/help_overlay/render/tests/cache.rs | 13 +- src/ui/help_overlay/sections/bindings.rs | 1 + src/ui/help_overlay/sections/builder.rs | 1 + src/ui/help_overlay/sections/mod.rs | 2 +- 13 files changed, 282 insertions(+), 59 deletions(-) create mode 100644 src/ui/help_overlay/content.rs diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index f7191edd8..b80a4009d 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -122,6 +122,7 @@ pub(in crate::backend::wayland) struct RenderRuntime { draw_caches: crate::draw::RenderCaches, theme: crate::ui::theme::Theme, ui_caches: crate::ui::UiRenderCaches, + help_content: crate::ui::HelpContentCache, ui_text: crate::ui_text::UiTextEngine, text_measurer: crate::draw::TextMeasurer, ui_damage: UiDamageHistory, @@ -138,6 +139,7 @@ impl RenderRuntime { draw_caches: crate::draw::RenderCaches::default(), theme, ui_caches: crate::ui::UiRenderCaches::default(), + help_content: crate::ui::HelpContentCache::default(), ui_text, text_measurer: crate::draw::TextMeasurer::default(), ui_damage: UiDamageHistory::default(), @@ -167,6 +169,22 @@ impl RenderRuntime { (&self.theme, &mut self.ui_caches, &self.ui_text) } + pub(in crate::backend::wayland::state) fn help_parts_mut( + &mut self, + ) -> ( + &crate::ui::theme::Theme, + &mut crate::ui::UiRenderCaches, + &crate::ui_text::UiTextEngine, + &mut crate::ui::HelpContentCache, + ) { + ( + &self.theme, + &mut self.ui_caches, + &self.ui_text, + &mut self.help_content, + ) + } + pub(in crate::backend::wayland::state) fn canvas_layer_cache_mut( &mut self, ) -> &mut CanvasLayerCache { diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 8d3533aa9..6713f8429 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -206,27 +206,29 @@ impl WaylandState { capture_picker: bool, ) { if !capture_picker && self.input_state.help_overlay.is_visible() { - let bindings = crate::ui::HelpOverlayBindings::from_input_state(&self.input_state); let result = { - let (theme, caches, engine) = self.render.ui_parts_with_text_mut(); + let (theme, caches, engine, content_cache) = self.render.help_parts_mut(); + let content = content_cache.get_or_build( + &self.input_state, + self.frozen.enabled(), + self.config.ui.help_overlay_context_filter, + self.input_state.boards.board_count() > 1, + self.config.capture.enabled, + ); let mut render = crate::ui::UiRenderCtx { cairo: ctx, theme, caches, }; - crate::ui::render_help_overlay_result_with_context( + crate::ui::render_help_overlay_result_with_content( engine, &mut render, &self.config.ui.help_overlay_style, width, height, - self.frozen.enabled(), self.input_state.help_overlay.page(), - &bindings, + content, self.input_state.help_overlay.query(), - self.config.ui.help_overlay_context_filter, - self.input_state.boards.board_count() > 1, - self.config.capture.enabled, self.input_state.help_overlay.scroll(), self.input_state.help_overlay.is_quick_mode(), ) diff --git a/src/input/state/core/utility/actions.rs b/src/input/state/core/utility/actions.rs index dbdfef7a0..9bbca6d22 100644 --- a/src/input/state/core/utility/actions.rs +++ b/src/input/state/core/utility/actions.rs @@ -51,6 +51,10 @@ impl InputState { self.keymap.set_action_bindings(action_bindings); } + pub(crate) fn keymap_revision(&self) -> u64 { + self.keymap.revision() + } + /// Install a keymap rebuilt after a shortcut edit. /// /// Both halves move together because they are two views of one binding diff --git a/src/ui.rs b/src/ui.rs index 0540ad767..e8233ce36 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -47,7 +47,7 @@ pub(crate) use eyedropper_loupe::{compute_eyedropper_loupe_layout, render_eyedro pub use font_picker::render_font_picker; #[allow(unused_imports)] pub use help_overlay::HelpOverlayBindings; -pub(crate) use help_overlay::render_help_overlay_result_with_context; +pub(crate) use help_overlay::{HelpContentCache, render_help_overlay_result_with_content}; #[allow(unused_imports)] pub use help_overlay::{ HelpHitMap, HelpOverlayRegion, HelpRenderResult, render_help_overlay, diff --git a/src/ui/help_overlay/content.rs b/src/ui/help_overlay/content.rs new file mode 100644 index 000000000..0d30c68ff --- /dev/null +++ b/src/ui/help_overlay/content.rs @@ -0,0 +1,185 @@ +use crate::config::{QuickColorPalette, RadialMenuMouseBinding}; +use crate::input::InputState; +use std::hash::{Hash, Hasher}; + +use super::sections::{HelpOverlayBindings, SectionSets, build_section_sets}; + +#[derive(Clone, PartialEq)] +struct HelpContentKey { + shortcut_revision: u64, + radial_menu_mouse_binding: RadialMenuMouseBinding, + quick_colors: QuickColorPalette, + frozen_enabled: bool, + context_filter: bool, + board_enabled: bool, + capture_enabled: bool, +} + +impl HelpContentKey { + fn from_input( + input: &InputState, + frozen_enabled: bool, + context_filter: bool, + board_enabled: bool, + capture_enabled: bool, + ) -> Self { + Self { + shortcut_revision: input.keymap_revision(), + radial_menu_mouse_binding: input.radial_menu.mouse_binding(), + quick_colors: input.style.quick_colors.clone(), + frozen_enabled, + context_filter, + board_enabled, + capture_enabled, + } + } +} + +pub(crate) struct HelpContentSnapshot { + pub(super) revision: u64, + pub(super) bindings: HelpOverlayBindings, + pub(super) sections: SectionSets, +} + +impl HelpContentSnapshot { + pub(super) fn from_bindings( + bindings: &HelpOverlayBindings, + frozen_enabled: bool, + context_filter: bool, + board_enabled: bool, + capture_enabled: bool, + ) -> Self { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + bindings.cache_key().hash(&mut hasher); + frozen_enabled.hash(&mut hasher); + context_filter.hash(&mut hasher); + board_enabled.hash(&mut hasher); + capture_enabled.hash(&mut hasher); + Self { + revision: hasher.finish(), + bindings: bindings.clone(), + sections: build_section_sets( + bindings, + frozen_enabled, + context_filter, + board_enabled, + capture_enabled, + ), + } + } +} + +struct CachedHelpContent { + key: HelpContentKey, + snapshot: HelpContentSnapshot, +} + +/// Owner-scoped help content assembled from the canonical runtime shortcut +/// snapshot and the runtime capabilities that decide which rows exist. +#[derive(Default)] +pub(crate) struct HelpContentCache { + entry: Option, + next_revision: u64, + #[cfg(test)] + builds: usize, +} + +impl HelpContentCache { + pub(crate) fn get_or_build( + &mut self, + input: &InputState, + frozen_enabled: bool, + context_filter: bool, + board_enabled: bool, + capture_enabled: bool, + ) -> &HelpContentSnapshot { + let key = HelpContentKey::from_input( + input, + frozen_enabled, + context_filter, + board_enabled, + capture_enabled, + ); + let rebuild = self.entry.as_ref().is_none_or(|entry| entry.key != key); + if rebuild { + let bindings = HelpOverlayBindings::from_input_state(input); + let sections = build_section_sets( + &bindings, + frozen_enabled, + context_filter, + board_enabled, + capture_enabled, + ); + self.next_revision = self.next_revision.wrapping_add(1); + self.entry = Some(CachedHelpContent { + key, + snapshot: HelpContentSnapshot { + revision: self.next_revision, + bindings, + sections, + }, + }); + #[cfg(test)] + { + self.builds += 1; + } + } + &self + .entry + .as_ref() + .expect("help content was built") + .snapshot + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Action, Shortcut}; + use crate::input::state::test_support::make_test_input_state; + use std::collections::HashMap; + + #[test] + fn content_rebuilds_only_when_its_canonical_inputs_change() { + let mut cache = HelpContentCache::default(); + let mut input = make_test_input_state(); + + let first = cache.get_or_build(&input, true, true, false, true).revision; + assert_eq!( + cache.get_or_build(&input, true, true, false, true).revision, + first + ); + assert_eq!(cache.builds, 1); + + input.help_overlay.open(false); + input.help_overlay.next_page(); + input.help_overlay.insert_search("zoom"); + input.help_overlay.scroll_by(12.0); + assert_eq!( + cache.get_or_build(&input, true, true, false, true).revision, + first, + "page, search, and scroll are display state, not content inputs" + ); + assert_eq!(cache.builds, 1); + + input.set_action_bindings(HashMap::from([( + Action::ToggleHelp, + vec![Shortcut::parse("F10").unwrap()], + )])); + let rebound = cache.get_or_build(&input, true, true, false, true).revision; + assert_ne!(rebound, first); + assert_eq!(cache.builds, 2); + + let capability_content = cache.get_or_build(&input, true, true, true, true); + let capability_change = capability_content.revision; + assert!( + capability_content + .sections + .all + .iter() + .any(|section| section.title == "Boards") + ); + assert_ne!(capability_change, rebound); + assert_eq!(cache.builds, 3); + } +} diff --git a/src/ui/help_overlay/mod.rs b/src/ui/help_overlay/mod.rs index 09794e3bd..cb1fdace5 100644 --- a/src/ui/help_overlay/mod.rs +++ b/src/ui/help_overlay/mod.rs @@ -1,3 +1,4 @@ +mod content; mod fonts; mod grid; mod keycaps; @@ -14,5 +15,6 @@ pub use render::{ }; pub use sections::HelpOverlayBindings; +pub(crate) use content::HelpContentCache; pub(in crate::ui) use render::HelpLayoutCache; -pub(crate) use render::render_help_overlay_result_with_context; +pub(crate) use render::render_help_overlay_result_with_content; diff --git a/src/ui/help_overlay/render/cache.rs b/src/ui/help_overlay/render/cache.rs index 7fbeaf337..481ec4555 100644 --- a/src/ui/help_overlay/render/cache.rs +++ b/src/ui/help_overlay/render/cache.rs @@ -1,4 +1,4 @@ -use super::super::sections::HelpOverlayBindings; +use super::super::content::HelpContentSnapshot; use super::state::{OverlayLayout, build_overlay_layout}; /// Style fields converted to integers for stable comparison. @@ -49,13 +49,9 @@ struct LayoutCacheKey { style: StyleKey, screen_width: u32, screen_height: u32, - frozen_enabled: bool, page_index: usize, - bindings_key: String, + content_revision: u64, search_query: String, - context_filter: bool, - board_enabled: bool, - capture_enabled: bool, quick_mode: bool, } @@ -85,13 +81,9 @@ impl HelpLayoutCache { style: &crate::config::HelpOverlayStyle, screen_width: u32, screen_height: u32, - frozen_enabled: bool, page_index: usize, - bindings: &HelpOverlayBindings, + content: &HelpContentSnapshot, search_query: &str, - context_filter: bool, - board_enabled: bool, - capture_enabled: bool, scroll_offset: f64, title_text: &str, header: &super::header::HeaderContent<'_>, @@ -103,13 +95,9 @@ impl HelpLayoutCache { style: StyleKey::from_style(style), screen_width, screen_height, - frozen_enabled, page_index, - bindings_key: bindings.cache_key().to_string(), + content_revision: content.revision, search_query: search_query.to_string(), - context_filter, - board_enabled, - capture_enabled, quick_mode, }; @@ -132,13 +120,9 @@ impl HelpLayoutCache { style, screen_width, screen_height, - frozen_enabled, page_index, - bindings, + content, search_query, - context_filter, - board_enabled, - capture_enabled, scroll_offset, title_text, header, diff --git a/src/ui/help_overlay/render/mod.rs b/src/ui/help_overlay/render/mod.rs index 24ce45c17..2da53c398 100644 --- a/src/ui/help_overlay/render/mod.rs +++ b/src/ui/help_overlay/render/mod.rs @@ -1,3 +1,4 @@ +use super::content::HelpContentSnapshot; use super::grid::{GridColors, GridStyle, draw_sections_grid}; use super::keycaps::KeyComboStyle; use super::nav::{NavDrawStyle, draw_nav}; @@ -43,6 +44,41 @@ pub(crate) fn render_help_overlay_result_with_context( scroll_offset: f64, quick_mode: bool, ) -> HelpRenderResult { + let content = HelpContentSnapshot::from_bindings( + bindings, + frozen_enabled, + context_filter, + board_enabled, + capture_enabled, + ); + render_help_overlay_result_with_content( + engine, + render, + style, + screen_width, + screen_height, + page_index, + &content, + search_query, + scroll_offset, + quick_mode, + ) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn render_help_overlay_result_with_content( + engine: &crate::ui_text::UiTextEngine, + render: &mut crate::ui::UiRenderCtx<'_, '_, '_>, + style: &crate::config::HelpOverlayStyle, + screen_width: u32, + screen_height: u32, + page_index: usize, + content: &HelpContentSnapshot, + search_query: &str, + scroll_offset: f64, + quick_mode: bool, +) -> HelpRenderResult { + let bindings = &content.bindings; let ctx = render.cairo; let title_text = if quick_mode { "Quick Reference" @@ -116,13 +152,9 @@ pub(crate) fn render_help_overlay_result_with_context( style, screen_width, screen_height, - frozen_enabled, page_index, - bindings, + content, search_query, - context_filter, - board_enabled, - capture_enabled, scroll_offset, title_text, &header, diff --git a/src/ui/help_overlay/render/state.rs b/src/ui/help_overlay/render/state.rs index 895024207..97d1deca9 100644 --- a/src/ui/help_overlay/render/state.rs +++ b/src/ui/help_overlay/render/state.rs @@ -1,8 +1,9 @@ use super::super::super::primitives::text_extents_for_with_engine; +use super::super::content::HelpContentSnapshot; use super::super::fonts::resolve_help_font_family; use super::super::layout::{GridLayout, build_grid, measure_sections}; use super::super::nav::{NavState, build_nav_state}; -use super::super::sections::{HelpOverlayBindings, build_section_sets, filter_sections_for_search}; +use super::super::sections::filter_sections_for_search; use super::BULLET; use super::header::{HeaderContent, measure_hints, title_row_width}; use super::metrics::RenderMetrics; @@ -36,13 +37,9 @@ pub(super) fn build_overlay_layout( style: &crate::config::HelpOverlayStyle, screen_width: u32, screen_height: u32, - frozen_enabled: bool, page_index: usize, - bindings: &HelpOverlayBindings, + content: &HelpContentSnapshot, search_query: &str, - context_filter: bool, - board_enabled: bool, - capture_enabled: bool, scroll_offset: f64, title_text: &str, header: &HeaderContent<'_>, @@ -55,13 +52,7 @@ pub(super) fn build_overlay_layout( let search_lower = search_query.to_ascii_lowercase(); let help_font_family = resolve_help_font_family(&style.font_family); - let section_sets = build_section_sets( - bindings, - frozen_enabled, - context_filter, - board_enabled, - capture_enabled, - ); + let section_sets = &content.sections; let page_count = if quick_mode || section_sets.page2.is_empty() { 1 } else { @@ -71,13 +62,13 @@ pub(super) fn build_overlay_layout( let nav_title = if quick_mode { "Quick Ref" } else { "Controls" }; let sections = if quick_mode { - section_sets.quick + section_sets.quick.clone() } else if search_active { - filter_sections_for_search(section_sets.all, &search_lower) + filter_sections_for_search(section_sets.all.clone(), &search_lower) } else if page_index == 0 { - section_sets.page1 + section_sets.page1.clone() } else { - section_sets.page2 + section_sets.page2.clone() }; let metrics = RenderMetrics::from_style(style, screen_width, screen_height); diff --git a/src/ui/help_overlay/render/tests/cache.rs b/src/ui/help_overlay/render/tests/cache.rs index 47fec124b..e17eda2ae 100644 --- a/src/ui/help_overlay/render/tests/cache.rs +++ b/src/ui/help_overlay/render/tests/cache.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::HelpOverlayStyle; +use crate::ui::HelpOverlayBindings; struct Inputs { style: HelpOverlayStyle, @@ -42,13 +43,15 @@ fn layout(cache: &mut HelpLayoutCache, inputs: &Inputs, scroll: f64) -> OverlayL &inputs.style, inputs.width, inputs.height, - inputs.frozen, inputs.page, - &inputs.bindings, + &HelpContentSnapshot::from_bindings( + &inputs.bindings, + inputs.frozen, + inputs.context_filter, + inputs.board, + inputs.capture, + ), &inputs.query, - inputs.context_filter, - inputs.board, - inputs.capture, scroll, "Wayscriber Controls", &super::super::header::HeaderContent { diff --git a/src/ui/help_overlay/sections/bindings.rs b/src/ui/help_overlay/sections/bindings.rs index dba887d1d..ebd0cf53c 100644 --- a/src/ui/help_overlay/sections/bindings.rs +++ b/src/ui/help_overlay/sections/bindings.rs @@ -6,6 +6,7 @@ use crate::config::{ use crate::input::InputState; use crate::label_format::{format_binding_labels_or, join_binding_labels}; +#[derive(Clone)] pub struct HelpOverlayBindings { labels: HashMap>, cache_key: String, diff --git a/src/ui/help_overlay/sections/builder.rs b/src/ui/help_overlay/sections/builder.rs index 3b81d30ce..74d70e358 100644 --- a/src/ui/help_overlay/sections/builder.rs +++ b/src/ui/help_overlay/sections/builder.rs @@ -7,6 +7,7 @@ use super::bindings::HelpOverlayBindings; use quick::build_quick_sections; use sections::build_main_sections; +#[derive(Clone)] pub(crate) struct SectionSets { pub(crate) all: Vec
, pub(crate) page1: Vec
, diff --git a/src/ui/help_overlay/sections/mod.rs b/src/ui/help_overlay/sections/mod.rs index 08ef22473..e8e7d22bf 100644 --- a/src/ui/help_overlay/sections/mod.rs +++ b/src/ui/help_overlay/sections/mod.rs @@ -5,4 +5,4 @@ mod builder; mod tests; pub use bindings::HelpOverlayBindings; -pub(crate) use builder::{build_section_sets, filter_sections_for_search}; +pub(crate) use builder::{SectionSets, build_section_sets, filter_sections_for_search}; From 2bb2480a75a78dc5278cdd182d3e68cd8e2bcf65 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:54:26 +0200 Subject: [PATCH 41/42] refactor(text): remove thread-local resource bridges --- src/draw/dirty.rs | 4 +-- src/draw/frame/types.rs | 4 +-- src/draw/mod.rs | 2 +- src/draw/render/context.rs | 8 ++--- src/draw/render/selection.rs | 2 +- src/draw/render/text.rs | 10 +++--- src/draw/shape/mod.rs | 2 +- src/draw/shape/text_cache.rs | 31 ++++++++++++++++--- src/draw/shape/text_cache/owner.rs | 5 +++ src/draw/shape/types.rs | 4 +-- src/input/hit_test/mod.rs | 8 ++--- src/input/state/actions/key_press/mod.rs | 4 +-- src/input/state/core/board/delete_restore.rs | 4 +-- .../state/core/board/delete_restore/page.rs | 6 ++-- src/input/state/core/board/pages.rs | 12 +++---- src/input/state/core/board/switch.rs | 18 +++++------ .../state/core/board_picker/state/actions.rs | 14 ++++----- .../state/core/board_picker/state/drag.rs | 2 +- .../state/core/board_picker/state/edit.rs | 6 ++-- .../core/board_picker/state/lifecycle.rs | 10 +++--- .../state/core/color_picker_popup/state.rs | 6 ++-- src/input/state/core/command_palette/input.rs | 2 +- src/input/state/core/highlight_controls.rs | 6 ++-- src/input/state/core/history.rs | 4 +-- src/input/state/core/ime.rs | 4 +-- src/input/state/core/index.rs | 6 ++-- src/input/state/core/menus/commands.rs | 2 +- src/input/state/core/menus/lifecycle.rs | 4 +-- src/input/state/core/radial_menu/state.rs | 4 +-- .../state/core/selection_actions/clipboard.rs | 4 +-- .../selection_actions/clipboard/duplicate.rs | 4 +-- .../state/core/selection_actions/resize.rs | 4 +-- .../selection_actions/translation/bounds.rs | 4 +-- src/input/state/core/status_hud.rs | 2 +- src/input/state/core/tool_controls/presets.rs | 4 +-- .../state/core/tool_controls/settings.rs | 12 +++---- src/input/state/core/tool_controls/toolbar.rs | 16 +++++----- src/input/state/core/toolbar/apply/mod.rs | 2 +- src/input/state/core/tour.rs | 6 ++-- src/input/state/core/utility/interaction.rs | 4 +-- src/input/state/core/utility/light_mode.rs | 12 +++---- src/input/state/core/zoom_chip.rs | 2 +- src/input/state/mod.rs | 2 +- src/input/state/mouse/motion.rs | 2 +- src/input/state/mouse/press.rs | 2 +- src/input/state/mouse/release/mod.rs | 2 +- src/input/state/text_resources.rs | 17 +++++----- src/ui/status/bar.rs | 2 +- src/ui/status/bar/content.rs | 2 +- src/ui/status/zoom_chip.rs | 4 +-- src/ui_text.rs | 13 +++----- src/ui_text/tests.rs | 12 ++++--- 52 files changed, 176 insertions(+), 152 deletions(-) diff --git a/src/draw/dirty.rs b/src/draw/dirty.rs index 193d8962b..8a1e97976 100644 --- a/src/draw/dirty.rs +++ b/src/draw/dirty.rs @@ -3,7 +3,7 @@ //! Collects axis-aligned rectangles that need repainting between frames. use super::Shape; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::util::Rect; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -75,7 +75,7 @@ impl DirtyTracker { /// Adds the bounding box for the given shape, or full damage if none is available. pub fn mark_shape(&mut self, shape: &Shape) { - with_legacy_measurer(|measurer| self.mark_shape_with(shape, measurer)); + with_scoped_measurer(|measurer| self.mark_shape_with(shape, measurer)); } /// Adds bounds measured with the supplied owner, or full damage if unavailable. diff --git a/src/draw/frame/types.rs b/src/draw/frame/types.rs index c21390331..53246e783 100644 --- a/src/draw/frame/types.rs +++ b/src/draw/frame/types.rs @@ -1,5 +1,5 @@ use crate::draw::shape::Shape; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::util::Rect; use serde::{Deserialize, Serialize}; use std::cell::Cell; @@ -60,7 +60,7 @@ impl DrawnShape { /// [`Self::invalidate_bounds`]) trips an assertion, so the test suite /// catches invalidation bugs while release builds get the O(1) fast path. pub fn bounding_box(&self) -> Option { - with_legacy_measurer(|measurer| self.bounding_box_with(measurer)) + with_scoped_measurer(|measurer| self.bounding_box_with(measurer)) } /// Returns memoized bounds using the supplied owner for text measurements diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 802cd5dbe..993979f5c 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -48,7 +48,7 @@ pub use render::{ render_text_with_halo_with_measurer, render_text_with_measurer, selection_handle_rects, spotlight_regions_for_frame, sticky_note_foreground, text_outline_color, }; -pub(crate) use shape::with_legacy_measurer; +pub(crate) use shape::with_scoped_measurer; #[allow(unused_imports)] pub use shape::{ ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, MAX_PEN_SMOOTHING, diff --git a/src/draw/render/context.rs b/src/draw/render/context.rs index e4194f871..d6cbaa830 100644 --- a/src/draw/render/context.rs +++ b/src/draw/render/context.rs @@ -27,7 +27,7 @@ impl<'c, 'r> RenderCtx<'c, 'r> { } pub fn render_shape(&mut self, shape: &Shape) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.render_shape_with_measurer(measurer, shape) }) } @@ -41,7 +41,7 @@ impl<'c, 'r> RenderCtx<'c, 'r> { } pub fn render_shape_with_halo(&mut self, shape: &Shape, text_halo_enabled: bool) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.render_shape_with_halo_with_measurer(measurer, shape, text_halo_enabled) }) } @@ -56,7 +56,7 @@ impl<'c, 'r> RenderCtx<'c, 'r> { } pub fn render_shape_over(&mut self, shape: &Shape, known_background_luminance: Option) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.render_shape_over_with_measurer(measurer, shape, known_background_luminance) }) } @@ -81,7 +81,7 @@ impl<'c, 'r> RenderCtx<'c, 'r> { known_background_luminance: Option, text_halo_enabled: bool, ) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.render_shape_over_with_halo_with_measurer( measurer, shape, diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs index e21d2036d..17a3cf059 100644 --- a/src/draw/render/selection.rs +++ b/src/draw/render/selection.rs @@ -30,7 +30,7 @@ const SELECTION_GLOW: Color = Color { /// Renders a selection halo overlay for a drawn shape. pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { render_selection_halo_with_measurer(measurer, ctx, drawn) }) } diff --git a/src/draw/render/text.rs b/src/draw/render/text.rs index bbac70e87..5d6398848 100644 --- a/src/draw/render/text.rs +++ b/src/draw/render/text.rs @@ -37,7 +37,7 @@ pub fn render_text( background_enabled: bool, wrap_width: Option, ) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { render_text_with_measurer( measurer, ctx, @@ -95,7 +95,7 @@ pub fn render_text_with_halo( wrap_width: Option, halo_enabled: bool, ) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { render_text_with_halo_with_measurer( measurer, ctx, @@ -162,7 +162,7 @@ pub fn render_text_over( wrap_width: Option, known_background_luminance: Option, ) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { render_text_over_with_measurer( measurer, ctx, @@ -224,7 +224,7 @@ pub fn render_text_over_with_halo( known_background_luminance: Option, halo_enabled: bool, ) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { render_text_over_with_halo_with_measurer( measurer, ctx, @@ -413,7 +413,7 @@ pub fn render_sticky_note( font_descriptor: &FontDescriptor, wrap_width: Option, ) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { render_sticky_note_with_measurer( measurer, ctx, diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs index 1a5c1d508..fa604f2aa 100644 --- a/src/draw/shape/mod.rs +++ b/src/draw/shape/mod.rs @@ -16,7 +16,7 @@ pub use polygon::{ }; pub use smoothing::{MAX_PEN_SMOOTHING, clamp_pen_smoothing, smooth_path, smooth_pressure_path}; pub use text_cache::TextMeasurer; -pub(crate) use text_cache::with_legacy_measurer; +pub(crate) use text_cache::with_scoped_measurer; pub use types::{ ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, Shape, StepMarkerLabel, diff --git a/src/draw/shape/text_cache.rs b/src/draw/shape/text_cache.rs index d4305dcb9..5d61005f0 100644 --- a/src/draw/shape/text_cache.rs +++ b/src/draw/shape/text_cache.rs @@ -47,13 +47,34 @@ impl TextMeasurement { } } -thread_local! { - // Temporary bridge for callers being migrated to explicit ownership. - static LEGACY_TEXT_MEASURER: TextMeasurer = TextMeasurer::default(); +/// Run a public convenience operation with an isolated call-local owner. +/// Runtime paths should pass their persistent `TextMeasurer` explicitly. +pub(crate) fn with_scoped_measurer(f: impl FnOnce(&TextMeasurer) -> R) -> R { + let measurer = TextMeasurer::default(); + f(&measurer) } -pub(crate) fn with_legacy_measurer(f: impl FnOnce(&TextMeasurer) -> R) -> R { - LEGACY_TEXT_MEASURER.with(f) +#[cfg(test)] +#[test] +fn scoped_convenience_measurements_do_not_share_cache_entries() { + with_scoped_measurer(|measurer| { + assert_eq!(measurer.cache_len(), 0); + assert!( + measurer + .measure("first owner", "Sans", 14.0, None) + .is_some() + ); + assert_eq!(measurer.cache_len(), 1); + }); + with_scoped_measurer(|measurer| { + assert_eq!(measurer.cache_len(), 0); + assert!( + measurer + .measure("second owner", "Sans", 14.0, None) + .is_some() + ); + assert_eq!(measurer.cache_len(), 1); + }); } /// Build a Pango layout configured exactly like the measurement and render diff --git a/src/draw/shape/text_cache/owner.rs b/src/draw/shape/text_cache/owner.rs index adbca38e5..8f0d367a2 100644 --- a/src/draw/shape/text_cache/owner.rs +++ b/src/draw/shape/text_cache/owner.rs @@ -19,6 +19,11 @@ impl Default for TextMeasurer { } impl TextMeasurer { + #[cfg(test)] + pub(super) fn cache_len(&self) -> usize { + self.cache.borrow().entries.len() + } + pub(super) fn with_measurement_context( &self, f: impl FnOnce(&cairo::Context) -> R, diff --git a/src/draw/shape/types.rs b/src/draw/shape/types.rs index fcfbb1a8e..1bdd3b286 100644 --- a/src/draw/shape/types.rs +++ b/src/draw/shape/types.rs @@ -6,7 +6,7 @@ use super::bounds::{ use super::polygon::{PolygonKind, bounding_box_for_polygon}; use super::step_marker::step_marker_bounds_with; use super::text::{bounding_box_for_sticky_note_with, bounding_box_for_text_with}; -use super::text_cache::{TextMeasurer, with_legacy_measurer}; +use super::text_cache::{TextMeasurer, with_scoped_measurer}; use crate::draw::color::Color; use crate::draw::font::FontDescriptor; use crate::util::Rect; @@ -435,7 +435,7 @@ impl Shape { /// Returns `None` when the shape has no drawable area or its full bounds cannot be /// represented safely by [`Rect`]. pub fn bounding_box(&self) -> Option { - with_legacy_measurer(|measurer| self.bounding_box_with(measurer)) + with_scoped_measurer(|measurer| self.bounding_box_with(measurer)) } /// Computes bounds using the caller's canonical text measurement owner. diff --git a/src/input/hit_test/mod.rs b/src/input/hit_test/mod.rs index a2d94f964..7cace960f 100644 --- a/src/input/hit_test/mod.rs +++ b/src/input/hit_test/mod.rs @@ -10,7 +10,7 @@ use crate::draw::shape::{ arrow_label_ends, arrow_label_layout_with, step_marker_outline_thickness, step_marker_radius_with, }; -use crate::draw::{DrawnShape, Shape, TextMeasurer, with_legacy_measurer}; +use crate::draw::{DrawnShape, Shape, TextMeasurer, with_scoped_measurer}; use crate::util::Rect; const MAX_HIT_TEST_TOLERANCE: f64 = i32::MAX as f64; @@ -44,7 +44,7 @@ impl HitTestTolerance { pub(crate) use shapes::ellipse_fill_hit; pub fn compute_hit_bounds(shape: &DrawnShape, tolerance: f64) -> Option { - with_legacy_measurer(|measurer| compute_hit_bounds_with(measurer, shape, tolerance)) + with_scoped_measurer(|measurer| compute_hit_bounds_with(measurer, shape, tolerance)) } /// Computes tolerance-inflated bounds with the supplied text measurement owner. @@ -74,7 +74,7 @@ pub(crate) fn compute_hit_bounds_with_tolerance( /// Returns `true` if the point intersects the provided shape within tolerance. pub fn hit_test(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool { - with_legacy_measurer(|measurer| hit_test_with(measurer, shape, point, tolerance)) + with_scoped_measurer(|measurer| hit_test_with(measurer, shape, point, tolerance)) } /// Tests stroke geometry with the supplied text measurement owner. @@ -242,7 +242,7 @@ pub(crate) fn hit_test_with_tolerance( /// Stroke erasing intentionally keeps using `hit_test`, while direct point /// targeting includes filled interiors for closed fill-capable shapes. pub fn hit_test_for_point_targeting(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool { - with_legacy_measurer(|measurer| { + with_scoped_measurer(|measurer| { hit_test_for_point_targeting_with(measurer, shape, point, tolerance) }) } diff --git a/src/input/state/actions/key_press/mod.rs b/src/input/state/actions/key_press/mod.rs index 4bffb5bdf..40b046f5d 100644 --- a/src/input/state/actions/key_press/mod.rs +++ b/src/input/state/actions/key_press/mod.rs @@ -34,7 +34,7 @@ impl InputState { /// - Help toggle (configurable) /// - Modifier key tracking pub fn on_key_press(&mut self, key: Key) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.on_key_press_with_resources(resources, key) }); } @@ -48,7 +48,7 @@ impl InputState { } pub fn on_key_repeat(&mut self, key: Key) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.on_key_repeat_with_resources(resources, key) }); } diff --git a/src/input/state/core/board/delete_restore.rs b/src/input/state/core/board/delete_restore.rs index 2a7bc0058..65543136d 100644 --- a/src/input/state/core/board/delete_restore.rs +++ b/src/input/state/core/board/delete_restore.rs @@ -1,6 +1,6 @@ use super::super::base::{BOARD_DELETE_CONFIRM_MS, InputState}; use crate::domain::Action; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::boards::{ BoardDeleteOutcome, BoardDeleteRejection, BoardDeleteRequest, BoardDeleteTarget, BoardIdentityGeneration, BoardRestoreOutcome, BoardRestoreRejection, BoardRestoreRequest, @@ -112,7 +112,7 @@ impl InputState { } pub fn delete_active_board(&mut self) { - with_legacy_measurer(|measurer| self.delete_active_board_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.delete_active_board_with_measurer(measurer)) } pub fn delete_active_board_with_measurer(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/board/delete_restore/page.rs b/src/input/state/core/board/delete_restore/page.rs index dda3c024c..1e57c6cf5 100644 --- a/src/input/state/core/board/delete_restore/page.rs +++ b/src/input/state/core/board/delete_restore/page.rs @@ -1,7 +1,7 @@ use super::super::super::base::{InputState, PAGE_DELETE_CONFIRM_MS}; use crate::domain::Action; use crate::draw::PageDeleteOutcome as CanvasPageDeleteOutcome; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::boards::{ PageDeleteBoardTarget, PageDeleteOutcome, PageDeleteRequest, PageDeleteTarget, PageOperationRejection, PageRestoreOutcome, PageRestorePlacement, PageRestoreRejection, @@ -108,7 +108,7 @@ impl InputState { } pub fn page_delete(&mut self) -> CanvasPageDeleteOutcome { - with_legacy_measurer(|measurer| self.page_delete_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.page_delete_with_measurer(measurer)) } pub fn page_delete_with_measurer( @@ -254,7 +254,7 @@ impl InputState { /// Restore the most recently deleted page. pub fn restore_deleted_page(&mut self) { - with_legacy_measurer(|measurer| self.restore_deleted_page_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.restore_deleted_page_with_measurer(measurer)) } pub fn restore_deleted_page_with_measurer(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/board/pages.rs b/src/input/state/core/board/pages.rs index df6c3be0a..85e727db2 100644 --- a/src/input/state/core/board/pages.rs +++ b/src/input/state/core/board/pages.rs @@ -1,6 +1,6 @@ use super::super::base::InputState; use crate::draw::Color; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::boards::PendingBoardRuntimeUiAction; use crate::input::state::{Toast, ToastPriority}; use crate::input::{BoardBackground, runtime_contrast_pen_color}; @@ -337,7 +337,7 @@ impl InputState { } pub fn page_prev(&mut self) -> bool { - with_legacy_measurer(|measurer| self.page_prev_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.page_prev_with_measurer(measurer)) } pub fn page_prev_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { @@ -352,7 +352,7 @@ impl InputState { } pub fn page_next(&mut self) -> bool { - with_legacy_measurer(|measurer| self.page_next_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.page_next_with_measurer(measurer)) } pub fn page_next_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { @@ -367,7 +367,7 @@ impl InputState { } pub fn switch_to_page(&mut self, index: usize) -> bool { - with_legacy_measurer(|measurer| self.switch_to_page_with_measurer(measurer, index)) + with_scoped_measurer(|measurer| self.switch_to_page_with_measurer(measurer, index)) } pub fn switch_to_page_with_measurer(&mut self, measurer: &TextMeasurer, index: usize) -> bool { @@ -382,7 +382,7 @@ impl InputState { } pub fn page_new(&mut self) { - with_legacy_measurer(|measurer| self.page_new_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.page_new_with_measurer(measurer)) } pub fn page_new_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -399,7 +399,7 @@ impl InputState { } pub fn page_duplicate(&mut self) { - with_legacy_measurer(|measurer| self.page_duplicate_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.page_duplicate_with_measurer(measurer)) } pub fn page_duplicate_with_measurer(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/board/switch.rs b/src/input/state/core/board/switch.rs index 8e35b8a81..333b6fc53 100644 --- a/src/input/state/core/board/switch.rs +++ b/src/input/state/core/board/switch.rs @@ -1,5 +1,5 @@ use super::super::base::InputState; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::state::{Toast, ToastPriority}; use crate::input::{BOARD_ID_TRANSPARENT, BoardSpec}; @@ -45,7 +45,7 @@ impl InputState { /// /// Also resets drawing state to prevent partial shapes crossing modes. pub fn switch_board(&mut self, target_id: &str) { - with_legacy_measurer(|measurer| self.switch_board_with_measurer(measurer, target_id)) + with_scoped_measurer(|measurer| self.switch_board_with_measurer(measurer, target_id)) } pub fn switch_board_with_measurer(&mut self, measurer: &TextMeasurer, target_id: &str) { @@ -54,7 +54,7 @@ impl InputState { /// Switches to a different board without toggle semantics. pub fn switch_board_force(&mut self, target_id: &str) { - with_legacy_measurer(|measurer| self.switch_board_force_with_measurer(measurer, target_id)) + with_scoped_measurer(|measurer| self.switch_board_force_with_measurer(measurer, target_id)) } pub fn switch_board_force_with_measurer(&mut self, measurer: &TextMeasurer, target_id: &str) { @@ -88,7 +88,7 @@ impl InputState { } pub fn create_board(&mut self) -> bool { - with_legacy_measurer(|measurer| self.create_board_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.create_board_with_measurer(measurer)) } pub fn create_board_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { @@ -111,7 +111,7 @@ impl InputState { } pub fn switch_board_slot(&mut self, slot: usize) { - with_legacy_measurer(|measurer| self.switch_board_slot_with_measurer(measurer, slot)) + with_scoped_measurer(|measurer| self.switch_board_slot_with_measurer(measurer, slot)) } pub fn switch_board_slot_with_measurer(&mut self, measurer: &TextMeasurer, slot: usize) { @@ -125,7 +125,7 @@ impl InputState { } pub fn switch_board_next(&mut self) { - with_legacy_measurer(|measurer| self.switch_board_next_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.switch_board_next_with_measurer(measurer)) } pub fn switch_board_next_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -139,7 +139,7 @@ impl InputState { } pub fn switch_board_prev(&mut self) { - with_legacy_measurer(|measurer| self.switch_board_prev_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.switch_board_prev_with_measurer(measurer)) } pub fn switch_board_prev_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -154,7 +154,7 @@ impl InputState { /// Duplicate the active board. pub fn duplicate_board(&mut self) { - with_legacy_measurer(|measurer| self.duplicate_board_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.duplicate_board_with_measurer(measurer)) } pub fn duplicate_board_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -215,7 +215,7 @@ impl InputState { /// Switch to the most recently used board (other than the current one). pub fn switch_board_recent(&mut self) { - with_legacy_measurer(|measurer| self.switch_board_recent_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.switch_board_recent_with_measurer(measurer)) } pub fn switch_board_recent_with_measurer(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/board_picker/state/actions.rs b/src/input/state/core/board_picker/state/actions.rs index d8f01a5ba..2ccefd200 100644 --- a/src/input/state/core/board_picker/state/actions.rs +++ b/src/input/state/core/board_picker/state/actions.rs @@ -18,7 +18,7 @@ impl InputState { } pub(crate) fn board_picker_activate_row(&mut self, index: usize) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_activate_row_with_measurer(measurer, index) }) } @@ -40,7 +40,7 @@ impl InputState { } pub(crate) fn board_picker_activate_page(&mut self, page_index: usize) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_activate_page_with_measurer(measurer, page_index) }) } @@ -69,7 +69,7 @@ impl InputState { } pub(crate) fn board_picker_add_page(&mut self) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_add_page_with_measurer(measurer) }) } @@ -95,7 +95,7 @@ impl InputState { } pub(crate) fn board_picker_delete_page(&mut self, page_index: usize) -> PageDeleteOutcome { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_delete_page_with_measurer(measurer, page_index) }) } @@ -116,7 +116,7 @@ impl InputState { } pub(crate) fn board_picker_duplicate_page(&mut self, page_index: usize) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_duplicate_page_with_measurer(measurer, page_index) }) } @@ -143,7 +143,7 @@ impl InputState { } pub(crate) fn board_picker_create_new(&mut self) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_create_new_with_measurer(measurer) }) } @@ -173,7 +173,7 @@ impl InputState { } pub(crate) fn board_picker_delete_selected(&mut self) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_delete_selected_with_measurer(measurer) }) } diff --git a/src/input/state/core/board_picker/state/drag.rs b/src/input/state/core/board_picker/state/drag.rs index 24a00a93a..6b6fac85e 100644 --- a/src/input/state/core/board_picker/state/drag.rs +++ b/src/input/state/core/board_picker/state/drag.rs @@ -160,7 +160,7 @@ impl InputState { } pub(crate) fn board_picker_finish_page_drag(&mut self) -> bool { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_finish_page_drag_with_measurer(measurer) }) } diff --git a/src/input/state/core/board_picker/state/edit.rs b/src/input/state/core/board_picker/state/edit.rs index 37fd66e61..44cab41de 100644 --- a/src/input/state/core/board_picker/state/edit.rs +++ b/src/input/state/core/board_picker/state/edit.rs @@ -69,7 +69,7 @@ impl InputState { } pub(crate) fn board_picker_commit_page_edit(&mut self) -> bool { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_commit_page_edit_with_measurer(measurer) }) } @@ -268,7 +268,7 @@ impl InputState { } pub(crate) fn board_picker_rename_selected(&mut self) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_rename_selected_with_measurer(measurer) }) } @@ -296,7 +296,7 @@ impl InputState { } pub(crate) fn board_picker_edit_color_selected(&mut self) { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.board_picker_edit_color_selected_with_measurer(measurer) }) } diff --git a/src/input/state/core/board_picker/state/lifecycle.rs b/src/input/state/core/board_picker/state/lifecycle.rs index 33476c17c..cf314f424 100644 --- a/src/input/state/core/board_picker/state/lifecycle.rs +++ b/src/input/state/core/board_picker/state/lifecycle.rs @@ -1,6 +1,6 @@ use super::super::super::base::InputState; use super::super::{BoardPickerFocus, BoardPickerMode, BoardPickerPageNavMode, BoardPickerState}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; impl InputState { pub(crate) fn is_board_picker_open(&self) -> bool { @@ -16,7 +16,7 @@ impl InputState { } pub(crate) fn open_board_picker(&mut self) { - with_legacy_measurer(|measurer| self.open_board_picker_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.open_board_picker_with_measurer(measurer)) } pub(crate) fn open_board_picker_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -24,7 +24,7 @@ impl InputState { } pub(crate) fn open_board_picker_quick(&mut self) { - with_legacy_measurer(|measurer| self.open_board_picker_quick_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.open_board_picker_quick_with_measurer(measurer)) } pub(crate) fn open_board_picker_quick_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -55,7 +55,7 @@ impl InputState { } pub(crate) fn toggle_board_picker(&mut self) { - with_legacy_measurer(|measurer| self.toggle_board_picker_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.toggle_board_picker_with_measurer(measurer)) } pub(crate) fn toggle_board_picker_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -67,7 +67,7 @@ impl InputState { } pub(crate) fn toggle_board_picker_quick(&mut self) { - with_legacy_measurer(|measurer| self.toggle_board_picker_quick_with(measurer)) + with_scoped_measurer(|measurer| self.toggle_board_picker_quick_with(measurer)) } pub(crate) fn toggle_board_picker_quick_with(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/color_picker_popup/state.rs b/src/input/state/core/color_picker_popup/state.rs index 68e803221..acbc34b0b 100644 --- a/src/input/state/core/color_picker_popup/state.rs +++ b/src/input/state/core/color_picker_popup/state.rs @@ -1,6 +1,6 @@ //! Color picker popup state methods for InputState. -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use std::borrow::Cow; use crate::draw::Color; @@ -47,7 +47,7 @@ impl InputState { /// Opens the color picker popup with the current color. pub fn open_color_picker_popup(&mut self) { - with_legacy_measurer(|measurer| self.open_color_picker_popup_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.open_color_picker_popup_with_measurer(measurer)) } pub fn open_color_picker_popup_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -61,7 +61,7 @@ impl InputState { /// index is past the palette — a stale click on a snapshot rendered before /// the palette shrank, which must open nothing. pub fn open_color_picker_popup_for_quick_color(&mut self, index: usize) -> bool { - with_legacy_measurer(|measurer| { + with_scoped_measurer(|measurer| { self.open_color_picker_popup_for_quick_color_with_measurer(measurer, index) }) } diff --git a/src/input/state/core/command_palette/input.rs b/src/input/state/core/command_palette/input.rs index 316d9cfa9..f2b7c2f79 100644 --- a/src/input/state/core/command_palette/input.rs +++ b/src/input/state/core/command_palette/input.rs @@ -485,7 +485,7 @@ impl InputState { screen_width: u32, screen_height: u32, ) -> bool { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.handle_command_palette_click_with_resources( resources, x, diff --git a/src/input/state/core/highlight_controls.rs b/src/input/state/core/highlight_controls.rs index 9c1b155ca..8c7ef5dd4 100644 --- a/src/input/state/core/highlight_controls.rs +++ b/src/input/state/core/highlight_controls.rs @@ -1,6 +1,6 @@ use super::base::{DrawingState, InputState}; use super::history_limits::HistoryMode; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::tool::Tool; use cairo::Context as CairoContext; use std::time::Instant; @@ -133,7 +133,7 @@ impl InputState { /// Sets highlight-only tool mode on/off and keeps click highlight in sync. pub fn set_highlight_tool(&mut self, enable: bool) { - with_legacy_measurer(|measurer| self.set_highlight_tool_with_measurer(measurer, enable)) + with_scoped_measurer(|measurer| self.set_highlight_tool_with_measurer(measurer, enable)) } pub fn set_highlight_tool_with_measurer(&mut self, measurer: &TextMeasurer, enable: bool) { @@ -162,7 +162,7 @@ impl InputState { /// Toggles the combined highlight tool and click highlight together. pub fn toggle_all_highlights(&mut self) -> bool { - with_legacy_measurer(|measurer| self.toggle_all_highlights_with_measurer(measurer)) + with_scoped_measurer(|measurer| self.toggle_all_highlights_with_measurer(measurer)) } pub fn toggle_all_highlights_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs index 73c95a1f0..0910b7a07 100644 --- a/src/input/state/core/history.rs +++ b/src/input/state/core/history.rs @@ -1,11 +1,11 @@ use super::base::InputState; use crate::draw::frame::UndoAction; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; impl InputState { /// Applies side effects after an undoable action mutates the frame. pub fn apply_action_side_effects(&mut self, action: &UndoAction) { - with_legacy_measurer(|measurer| self.apply_action_side_effects_with(measurer, action)); + with_scoped_measurer(|measurer| self.apply_action_side_effects_with(measurer, action)); } pub fn apply_action_side_effects_with(&mut self, measurer: &TextMeasurer, action: &UndoAction) { diff --git a/src/input/state/core/ime.rs b/src/input/state/core/ime.rs index 2b03ea486..3af7b9252 100644 --- a/src/input/state/core/ime.rs +++ b/src/input/state/core/ime.rs @@ -383,7 +383,7 @@ impl InputState { } pub fn ime_apply_done(&mut self) -> bool { - crate::draw::with_legacy_measurer(|measurer| self.ime_apply_done_with(measurer)) + crate::draw::with_scoped_measurer(|measurer| self.ime_apply_done_with(measurer)) } pub(crate) fn ime_apply_done_with(&mut self, measurer: &crate::draw::TextMeasurer) -> bool { @@ -396,7 +396,7 @@ impl InputState { } pub fn ime_clear(&mut self) -> bool { - crate::draw::with_legacy_measurer(|measurer| self.ime_clear_with(measurer)) + crate::draw::with_scoped_measurer(|measurer| self.ime_clear_with(measurer)) } pub(crate) fn ime_clear_with(&mut self, measurer: &crate::draw::TextMeasurer) -> bool { diff --git a/src/input/state/core/index.rs b/src/input/state/core/index.rs index f20a143ae..0200917ef 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -4,7 +4,7 @@ mod grid; mod owner; use super::base::InputState; -use crate::draw::{ShapeId, TextMeasurer, with_legacy_measurer}; +use crate::draw::{ShapeId, TextMeasurer, with_scoped_measurer}; use owner::ActiveFrameOrderGuard; pub(in crate::input::state) use owner::CanvasIndex; #[cfg(test)] @@ -63,7 +63,7 @@ impl InputState { /// Instead of invalidating the entire spatial index, this method updates /// only the affected cells, providing O(1) amortized updates instead of O(n). pub fn invalidate_hit_cache_for(&mut self, id: ShapeId) { - with_legacy_measurer(|measurer| self.invalidate_hit_cache_for_with(measurer, id)) + with_scoped_measurer(|measurer| self.invalidate_hit_cache_for_with(measurer, id)) } /// Refreshes one shape in the index using the supplied text measurements. @@ -127,7 +127,7 @@ impl InputState { /// Performs hit-testing against the active frame and returns the top-most shape id. pub fn hit_test_at(&mut self, x: i32, y: i32) -> Option { - with_legacy_measurer(|measurer| self.hit_test_at_with(measurer, x, y)) + with_scoped_measurer(|measurer| self.hit_test_at_with(measurer, x, y)) } /// Finds the topmost shape using the supplied canonical text measurements. diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index cd8e8cc75..884693d87 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -80,7 +80,7 @@ impl InputState { } pub fn execute_menu_command(&mut self, command: MenuCommand) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.execute_menu_command_with_resources(resources, command) }) } diff --git a/src/input/state/core/menus/lifecycle.rs b/src/input/state/core/menus/lifecycle.rs index 7a21138d1..57863236f 100644 --- a/src/input/state/core/menus/lifecycle.rs +++ b/src/input/state/core/menus/lifecycle.rs @@ -1,7 +1,7 @@ use super::super::base::InputState; use super::types::{ContextMenuKind, MenuCommand}; use crate::draw::ShapeId; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; impl InputState { /// Closes the currently open context menu. @@ -58,7 +58,7 @@ impl InputState { } pub fn toggle_context_menu_via_keyboard(&mut self) { - with_legacy_measurer(|measurer| self.toggle_context_menu_via_keyboard_with(measurer)) + with_scoped_measurer(|measurer| self.toggle_context_menu_via_keyboard_with(measurer)) } pub fn toggle_context_menu_via_keyboard_with(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/radial_menu/state.rs b/src/input/state/core/radial_menu/state.rs index 859647e74..3ee2d255a 100644 --- a/src/input/state/core/radial_menu/state.rs +++ b/src/input/state/core/radial_menu/state.rs @@ -139,7 +139,7 @@ impl InputState { /// Select the currently hovered segment and close the menu. pub fn radial_menu_select_hovered(&mut self) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.radial_menu_select_hovered_with_resources(resources) }) } @@ -182,7 +182,7 @@ impl InputState { /// Adjust thickness via scroll wheel while the menu is open. pub fn radial_menu_adjust_thickness(&mut self, delta: f64) -> bool { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.radial_menu_adjust_thickness_with_measurer(measurer, delta) }) } diff --git a/src/input/state/core/selection_actions/clipboard.rs b/src/input/state/core/selection_actions/clipboard.rs index 857d1f022..6a9406836 100644 --- a/src/input/state/core/selection_actions/clipboard.rs +++ b/src/input/state/core/selection_actions/clipboard.rs @@ -2,7 +2,7 @@ use super::super::base::{ClipboardFingerprint, ClipboardPasteRequest, InputState use super::super::selection::LocalSelectionContext; use crate::draw::Shape; use crate::draw::frame::UndoAction; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::state::{Toast, ToastPriority}; use crate::util::Rect; @@ -165,7 +165,7 @@ impl InputState { request: &ClipboardPasteRequest, shapes: Vec, ) -> usize { - with_legacy_measurer(|measurer| { + with_scoped_measurer(|measurer| { self.paste_clipboard_shapes_from_request_with(measurer, request, shapes) }) } diff --git a/src/input/state/core/selection_actions/clipboard/duplicate.rs b/src/input/state/core/selection_actions/clipboard/duplicate.rs index 420603acd..e4fe588b4 100644 --- a/src/input/state/core/selection_actions/clipboard/duplicate.rs +++ b/src/input/state/core/selection_actions/clipboard/duplicate.rs @@ -1,13 +1,13 @@ use super::super::super::base::InputState; use crate::draw::frame::UndoAction; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; const DUPLICATE_OFFSET: i32 = 12; #[allow(dead_code)] impl InputState { pub(crate) fn duplicate_selection(&mut self) -> bool { - with_legacy_measurer(|measurer| self.duplicate_selection_with(measurer)) + with_scoped_measurer(|measurer| self.duplicate_selection_with(measurer)) } pub(crate) fn duplicate_selection_with(&mut self, measurer: &TextMeasurer) -> bool { diff --git a/src/input/state/core/selection_actions/resize.rs b/src/input/state/core/selection_actions/resize.rs index 4e5217359..a7f56a60a 100644 --- a/src/input/state/core/selection_actions/resize.rs +++ b/src/input/state/core/selection_actions/resize.rs @@ -2,7 +2,7 @@ use crate::draw::ShapeId; use crate::draw::frame::ShapeSnapshot; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::InputState; use crate::input::state::core::base::SelectionHandle; use crate::util::Rect; @@ -15,7 +15,7 @@ const HANDLE_TOLERANCE: i32 = 4; impl InputState { /// Hit test for selection handles. Returns the handle if mouse is over one. pub fn hit_selection_handle(&self, x: i32, y: i32) -> Option { - with_legacy_measurer(|measurer| self.hit_selection_handle_with(measurer, x, y)) + with_scoped_measurer(|measurer| self.hit_selection_handle_with(measurer, x, y)) } /// Hit-tests selection handles using the supplied text measurement owner. diff --git a/src/input/state/core/selection_actions/translation/bounds.rs b/src/input/state/core/selection_actions/translation/bounds.rs index 7fd016ba6..3fe59030b 100644 --- a/src/input/state/core/selection_actions/translation/bounds.rs +++ b/src/input/state/core/selection_actions/translation/bounds.rs @@ -1,11 +1,11 @@ -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::InputState; use crate::util::Rect; impl InputState { /// Returns the combined bounding box of all selected shapes (public for rendering). pub fn selection_bounds(&self) -> Option { - with_legacy_measurer(|measurer| self.selection_bounds_with(measurer)) + with_scoped_measurer(|measurer| self.selection_bounds_with(measurer)) } /// Combined selection bounds using the supplied text measurement owner. diff --git a/src/input/state/core/status_hud.rs b/src/input/state/core/status_hud.rs index 4b6a6d83a..6c85e8d36 100644 --- a/src/input/state/core/status_hud.rs +++ b/src/input/state/core/status_hud.rs @@ -131,7 +131,7 @@ impl InputState { screen_height: u32, chrome_cursor_focused: bool, ) { - crate::ui_text::with_legacy_engine(|engine| { + crate::ui_text::with_scoped_engine(|engine| { self.update_status_hud_layout_for_pointer_with_engine( engine, position, diff --git a/src/input/state/core/tool_controls/presets.rs b/src/input/state/core/tool_controls/presets.rs index c3f8bcfa9..c8bbd33e3 100644 --- a/src/input/state/core/tool_controls/presets.rs +++ b/src/input/state/core/tool_controls/presets.rs @@ -4,7 +4,7 @@ use super::super::base::{ }; use super::super::default_step_marker_size; use crate::config::{PresetSlotsConfig, PresetToolStatesConfig, ToolPresetConfig}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::{DragModifier, tool::Tool}; use std::time::{Duration, Instant}; @@ -14,7 +14,7 @@ impl InputState { } pub fn apply_preset(&mut self, slot: usize) -> bool { - with_legacy_measurer(|measurer| self.apply_preset_with(measurer, slot)) + with_scoped_measurer(|measurer| self.apply_preset_with(measurer, slot)) } pub fn apply_preset_with(&mut self, measurer: &TextMeasurer, slot: usize) -> bool { diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index 502c61516..eb2931ea4 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -1,6 +1,6 @@ use super::super::base::{DrawingState, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use crate::draw::{ArrowStyle, BlurStyle, Color, FontDescriptor}; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::state::{Toast, ToastPriority}; use crate::input::{ DragBinding, MouseButton, @@ -144,7 +144,7 @@ impl InputState { /// Sets or clears an explicit tool override. Returns true if the tool changed. pub fn set_tool_override(&mut self, tool: Option) -> bool { - with_legacy_measurer(|measurer| self.set_tool_override_with(measurer, tool)) + with_scoped_measurer(|measurer| self.set_tool_override_with(measurer, tool)) } pub fn set_tool_override_with(&mut self, measurer: &TextMeasurer, tool: Option) -> bool { @@ -295,7 +295,7 @@ impl InputState { /// Sets thickness or eraser size depending on the active tool. pub fn set_thickness_for_active_tool(&mut self, value: f64) -> bool { - with_legacy_measurer(|measurer| self.set_thickness_for_active_tool_with(measurer, value)) + with_scoped_measurer(|measurer| self.set_thickness_for_active_tool_with(measurer, value)) } pub fn set_thickness_for_active_tool_with( @@ -316,7 +316,7 @@ impl InputState { /// Nudges thickness or eraser size depending on the active tool. pub fn nudge_thickness_for_active_tool(&mut self, delta: f64) -> bool { - with_legacy_measurer(|measurer| self.nudge_thickness_for_active_tool_with(measurer, delta)) + with_scoped_measurer(|measurer| self.nudge_thickness_for_active_tool_with(measurer, delta)) } pub fn nudge_thickness_for_active_tool_with( @@ -357,7 +357,7 @@ impl InputState { /// Sets the absolute thickness (px), clamped to valid bounds. Returns true if changed. pub fn set_thickness(&mut self, thickness: f64) -> bool { - with_legacy_measurer(|measurer| self.set_thickness_with(measurer, thickness)) + with_scoped_measurer(|measurer| self.set_thickness_with(measurer, thickness)) } pub fn set_thickness_with(&mut self, measurer: &TextMeasurer, thickness: f64) -> bool { @@ -381,7 +381,7 @@ impl InputState { /// Sets the absolute eraser size (px), clamped to valid bounds. Returns true if changed. pub fn set_eraser_size(&mut self, size: f64) -> bool { - with_legacy_measurer(|measurer| self.set_eraser_size_with(measurer, size)) + with_scoped_measurer(|measurer| self.set_eraser_size_with(measurer, size)) } pub fn set_eraser_size_with(&mut self, measurer: &TextMeasurer, size: f64) -> bool { diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs index 434d5f92e..d5c658563 100644 --- a/src/input/state/core/tool_controls/toolbar.rs +++ b/src/input/state/core/tool_controls/toolbar.rs @@ -12,7 +12,7 @@ pub(crate) const CLEAR_UNDO_TOAST_MS: u64 = 2000; impl InputState { /// Sets toolbar visibility without changing its persisted pin. pub fn set_toolbar_visible(&mut self, visible: bool) -> bool { - crate::ui_text::with_legacy_engine(|engine| { + crate::ui_text::with_scoped_engine(|engine| { self.set_toolbar_visible_with_engine(engine, visible) }) } @@ -244,7 +244,7 @@ impl InputState { } pub fn cycle_top_toolbar_display(&mut self) -> TopDisplayMode { - crate::ui_text::with_legacy_engine(|engine| { + crate::ui_text::with_scoped_engine(|engine| { self.cycle_top_toolbar_display_with_engine(engine) }) } @@ -426,7 +426,7 @@ impl InputState { /// Wrapper for undo that preserves existing action plumbing. pub fn toolbar_undo(&mut self) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.toolbar_undo_with_resources(resources) }) } @@ -440,7 +440,7 @@ impl InputState { /// Wrapper for redo that preserves existing action plumbing. pub fn toolbar_redo(&mut self) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.toolbar_redo_with_resources(resources) }) } @@ -454,7 +454,7 @@ impl InputState { /// Wrapper for clear that preserves existing action plumbing. pub fn toolbar_clear(&mut self) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.toolbar_clear_with_resources(resources) }) } @@ -470,7 +470,7 @@ impl InputState { /// were removed without a locked-shape warning, offers a short toast with /// an "Undo?" chip. The keyboard action and Shift+click stay instant. pub fn toolbar_clear_with_undo_toast(&mut self) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.toolbar_clear_with_undo_toast_with_resources(resources) }) } @@ -502,7 +502,7 @@ impl InputState { /// Wrapper for entering text mode. pub fn toolbar_enter_text_mode(&mut self) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.toolbar_enter_text_mode_with_resources(resources) }) } @@ -516,7 +516,7 @@ impl InputState { /// Wrapper for entering sticky note mode. pub fn toolbar_enter_sticky_note_mode(&mut self) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.toolbar_enter_sticky_note_mode_with_resources(resources) }) } diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 9f61fd0dc..1c8465c41 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -14,7 +14,7 @@ impl InputState { /// /// Returns true if the event resulted in a state change. pub fn apply_toolbar_event(&mut self, event: ToolbarEvent) -> bool { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.apply_toolbar_event_with_resources(resources, event) }) } diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs index 73f00330d..1cb598296 100644 --- a/src/input/state/core/tour.rs +++ b/src/input/state/core/tour.rs @@ -2,7 +2,7 @@ use crate::domain::Action; use crate::input::events::Key; -use crate::input::state::{InputTextResources, with_legacy_text_resources}; +use crate::input::state::{InputTextResources, with_scoped_text_resources}; use super::base::InputState; @@ -297,7 +297,7 @@ impl InputState { /// Start the guided tour. pub fn start_tour(&mut self) { - with_legacy_text_resources(|resources| self.start_tour_with_resources(resources)) + with_scoped_text_resources(|resources| self.start_tour_with_resources(resources)) } pub(crate) fn start_tour_with_resources(&mut self, resources: InputTextResources<'_>) { @@ -318,7 +318,7 @@ impl InputState { /// starts the overlay regardless of the persisted `tour_shown` flag — and /// so a future replay-specific behavior has a single call site to hang on. pub fn start_tour_replay(&mut self) { - with_legacy_text_resources(|resources| self.start_tour_replay_with_resources(resources)) + with_scoped_text_resources(|resources| self.start_tour_replay_with_resources(resources)) } pub(crate) fn start_tour_replay_with_resources(&mut self, resources: InputTextResources<'_>) { diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index 52a493260..74ca62e5f 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -1,6 +1,6 @@ use super::super::base::{DrawingState, InputState, PasteAnchor}; use crate::draw::DirtyRegionReport; -use crate::draw::{TextMeasurer, with_legacy_measurer}; +use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::util::Rect; use std::time::Instant; @@ -180,7 +180,7 @@ impl InputState { /// Cancels any in-progress interaction without exiting the application. pub(crate) fn cancel_active_interaction(&mut self) { - with_legacy_measurer(|measurer| self.cancel_active_interaction_with(measurer)) + with_scoped_measurer(|measurer| self.cancel_active_interaction_with(measurer)) } pub(crate) fn cancel_active_interaction_with(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/utility/light_mode.rs b/src/input/state/core/utility/light_mode.rs index 7a95b352f..798d4c02c 100644 --- a/src/input/state/core/utility/light_mode.rs +++ b/src/input/state/core/utility/light_mode.rs @@ -2,7 +2,7 @@ use super::super::base::{DesktopEnvironment, InputState, ShellMode}; use super::super::modes::LightModeRestore; use crate::domain::Action; use crate::draw::TextMeasurer; -use crate::input::state::{InputTextResources, with_legacy_text_resources}; +use crate::input::state::{InputTextResources, with_scoped_text_resources}; use crate::input::state::{Toast, ToastPriority}; use crate::input::tool::Tool; @@ -58,7 +58,7 @@ impl InputState { &mut self, engine: &crate::ui_text::UiTextEngine, ) -> bool { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.toggle_light_mode_with_resources(InputTextResources { measurer, ui_engine: engine, @@ -88,7 +88,7 @@ impl InputState { } pub fn toggle_light_mode_drawing(&mut self) -> bool { - with_legacy_text_resources(|resources| { + with_scoped_text_resources(|resources| { self.toggle_light_mode_drawing_with_resources(resources) }) } @@ -97,7 +97,7 @@ impl InputState { &mut self, engine: &crate::ui_text::UiTextEngine, ) -> bool { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.toggle_light_mode_drawing_with_resources(InputTextResources { measurer, ui_engine: engine, @@ -118,7 +118,7 @@ impl InputState { } pub fn set_light_mode_drawing(&mut self, drawing: bool) -> bool { - with_legacy_text_resources(|resources| { + with_scoped_text_resources(|resources| { self.set_light_mode_drawing_with_resources(resources, drawing) }) } @@ -128,7 +128,7 @@ impl InputState { engine: &crate::ui_text::UiTextEngine, drawing: bool, ) -> bool { - crate::draw::with_legacy_measurer(|measurer| { + crate::draw::with_scoped_measurer(|measurer| { self.set_light_mode_drawing_with_resources( InputTextResources { measurer, diff --git a/src/input/state/core/zoom_chip.rs b/src/input/state/core/zoom_chip.rs index 6df9c7444..0774e2ea0 100644 --- a/src/input/state/core/zoom_chip.rs +++ b/src/input/state/core/zoom_chip.rs @@ -62,7 +62,7 @@ impl InputState { screen_height: u32, chrome_cursor_focused: bool, ) { - crate::ui_text::with_legacy_engine(|engine| { + crate::ui_text::with_scoped_engine(|engine| { self.update_zoom_chip_layout_for_pointer_with_engine( engine, style, diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 8cb6ddf4c..394527fb4 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -15,7 +15,7 @@ pub(crate) use core::{InputEffect, InputEffectDrain}; pub(in crate::input::state) use spotlight::SpotlightWheelGesture; pub(crate) use spotlight::{SpotlightFrameRegions, SpotlightWheelClaim, SpotlightWheelOutcome}; pub(crate) use text_resources::InputTextResources; -pub(in crate::input::state) use text_resources::with_legacy_text_resources; +pub(in crate::input::state) use text_resources::with_scoped_text_resources; #[cfg(test)] mod tests; diff --git a/src/input/state/mouse/motion.rs b/src/input/state/mouse/motion.rs index 44db3f12a..306ace1af 100644 --- a/src/input/state/mouse/motion.rs +++ b/src/input/state/mouse/motion.rs @@ -26,7 +26,7 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.on_mouse_motion_with_canvas_and_resources( resources, screen_x, screen_y, canvas_x, canvas_y, ) diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index 570a68922..f15d50366 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -132,7 +132,7 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.on_mouse_press_with_canvas_and_resources( resources, button, screen_x, screen_y, canvas_x, canvas_y, ) diff --git a/src/input/state/mouse/release/mod.rs b/src/input/state/mouse/release/mod.rs index 470dbba4f..481ba441a 100644 --- a/src/input/state/mouse/release/mod.rs +++ b/src/input/state/mouse/release/mod.rs @@ -37,7 +37,7 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { - crate::input::state::with_legacy_text_resources(|resources| { + crate::input::state::with_scoped_text_resources(|resources| { self.on_mouse_release_with_canvas_and_resources( resources, button, screen_x, screen_y, canvas_x, canvas_y, ) diff --git a/src/input/state/text_resources.rs b/src/input/state/text_resources.rs index f252362a1..b94ff5e8e 100644 --- a/src/input/state/text_resources.rs +++ b/src/input/state/text_resources.rs @@ -12,17 +12,16 @@ pub(crate) struct InputTextResources<'a> { pub(crate) ui_engine: &'a UiTextEngine, } -/// Temporary adapter for callers whose input roots have not migrated yet. -pub(in crate::input::state) fn with_legacy_text_resources( +/// Run a public convenience operation with isolated call-local resources. +/// Backend runtime paths pass their persistent owners explicitly instead. +pub(in crate::input::state) fn with_scoped_text_resources( operation: impl FnOnce(InputTextResources<'_>) -> R, ) -> R { - crate::draw::with_legacy_measurer(|measurer| { - crate::ui_text::with_legacy_engine(|ui_engine| { - operation(InputTextResources { - measurer, - ui_engine, - }) - }) + let measurer = TextMeasurer::default(); + let ui_engine = UiTextEngine::default(); + operation(InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, }) } diff --git a/src/ui/status/bar.rs b/src/ui/status/bar.rs index 3d0a7027b..eee3e4d72 100644 --- a/src/ui/status/bar.rs +++ b/src/ui/status/bar.rs @@ -14,7 +14,7 @@ use crate::config::{Action, StatusPosition, action_display_label}; use crate::input::{BoardBackground, DrawingState, InputState, TextInputMode, Tool}; use crate::label_format::{format_binding_labels, join_binding_labels}; use crate::ui::toolbar::bindings::action_for_tool; -use crate::ui_text::{UiTextEngine, UiTextExtents, UiTextStyle, with_legacy_engine}; +use crate::ui_text::{UiTextEngine, UiTextExtents, UiTextStyle, with_scoped_engine}; mod content; mod helpers; diff --git a/src/ui/status/bar/content.rs b/src/ui/status/bar/content.rs index bb38649fa..55ff526cd 100644 --- a/src/ui/status/bar/content.rs +++ b/src/ui/status/bar/content.rs @@ -68,7 +68,7 @@ pub fn compute_status_hud_layout( screen_width: u32, screen_height: u32, ) -> Option { - with_legacy_engine(|engine| { + with_scoped_engine(|engine| { compute_status_hud_layout_with_engine( engine, input_state, diff --git a/src/ui/status/zoom_chip.rs b/src/ui/status/zoom_chip.rs index f6b1e0891..d180898e3 100644 --- a/src/ui/status/zoom_chip.rs +++ b/src/ui/status/zoom_chip.rs @@ -20,7 +20,7 @@ use super::super::primitives::{draw_pill, draw_rounded_rect}; use super::super::theme::{self, overlay}; use crate::config::StatusBarStyle; use crate::input::{BoardBackground, InputState}; -use crate::ui_text::{UiTextEngine, UiTextExtents, UiTextStyle, with_legacy_engine}; +use crate::ui_text::{UiTextEngine, UiTextExtents, UiTextStyle, with_scoped_engine}; // ============================================================================ // UI Layout Constants (not configurable) — mirror the status bar pill so the @@ -258,7 +258,7 @@ pub fn compute_zoom_chip_layout( screen_width: u32, screen_height: u32, ) -> Option { - with_legacy_engine(|engine| { + with_scoped_engine(|engine| { compute_zoom_chip_layout_with_engine( engine, input_state, diff --git a/src/ui_text.rs b/src/ui_text.rs index 57c44cfc7..623d18e6d 100644 --- a/src/ui_text.rs +++ b/src/ui_text.rs @@ -152,14 +152,11 @@ impl Default for UiTextEngine { } } -// Temporary migration bridge for unmigrated overlay/toolbar/export roots. -// Remove when every production caller receives an explicit engine. -thread_local! { - static LEGACY_UI_TEXT: UiTextEngine = UiTextEngine::default(); -} - -pub(crate) fn with_legacy_engine(f: impl FnOnce(&UiTextEngine) -> T) -> T { - LEGACY_UI_TEXT.with(f) +/// Run a public convenience operation with an isolated call-local owner. +/// Runtime paths should pass their persistent `UiTextEngine` explicitly. +pub(crate) fn with_scoped_engine(f: impl FnOnce(&UiTextEngine) -> T) -> T { + let engine = UiTextEngine::default(); + f(&engine) } impl UiTextEngine { diff --git a/src/ui_text/tests.rs b/src/ui_text/tests.rs index 9d642a607..256dbf009 100644 --- a/src/ui_text/tests.rs +++ b/src/ui_text/tests.rs @@ -252,16 +252,18 @@ fn cache_keys_keep_font_categories_quantized_size_and_wrap_units() { } #[test] -fn temporary_legacy_bridge_retains_layouts_and_matches_an_explicit_owner() { +fn scoped_convenience_engines_are_isolated_and_match_an_explicit_owner() { let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); let first = - with_legacy_engine(|engine| engine.layout(&ctx, style(14.0), "legacy bridge", Some(50.0))); + with_scoped_engine(|engine| engine.layout(&ctx, style(14.0), "scoped owner", Some(50.0))); let second = - with_legacy_engine(|engine| engine.layout(&ctx, style(14.0), "legacy bridge", Some(50.0))); - assert_eq!(first.layout, second.layout); + with_scoped_engine(|engine| engine.layout(&ctx, style(14.0), "scoped owner", Some(50.0))); + assert_ne!(first.layout, second.layout); let engine = UiTextEngine::default(); - let explicit = engine.layout(&ctx, style(14.0), "legacy bridge", Some(50.0)); + let explicit = engine.layout(&ctx, style(14.0), "scoped owner", Some(50.0)); assert_ne!(first.layout, explicit.layout); + assert_ne!(second.layout, explicit.layout); + assert_extents_eq(first.ink_extents(), second.ink_extents()); assert_extents_eq(first.ink_extents(), explicit.ink_extents()); } From db25310e623c4f1aa5c2f74634ecf47579f23781 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:34:53 +0200 Subject: [PATCH 42/42] refactor(text): require explicit runtime resource owners --- src/backend/wayland/backend/event_loop/mod.rs | 5 +- .../backend/event_loop/session_save.rs | 12 +- .../backend/event_loop/session_save/tests.rs | 2 +- src/backend/wayland/backend/state_init/mod.rs | 15 +- src/backend/wayland/backend/tray.rs | 38 ++-- src/backend/wayland/handlers/layer.rs | 8 +- src/backend/wayland/handlers/pointer/axis.rs | 4 +- .../wayland/runtime_ui_state/coordinator.rs | 33 ++- .../wayland/runtime_ui_state/live_state.rs | 14 +- .../wayland/runtime_ui_state/rollback.rs | 7 +- src/backend/wayland/runtime_ui_state/tests.rs | 7 +- .../tests/board_pin_resets.rs | 10 +- .../runtime_ui_state/tests/board_pins.rs | 10 + .../runtime_ui_state/tests/drag_previews.rs | 5 + .../runtime_ui_state/tests/layout_state.rs | 3 + .../tests/preference_actions.rs | 3 + .../runtime_ui_state/tests/preferences.rs | 9 +- .../runtime_ui_state/tests/recovery.rs | 7 +- .../runtime_ui_state/tests/text_geometry.rs | 32 ++- .../runtime_ui_state/tests/visibility.rs | 7 + .../tests/visibility_recovery.rs | 4 + .../wayland/runtime_ui_state/wayland.rs | 3 + src/backend/wayland/session/runtime.rs | 26 ++- src/backend/wayland/session/tests.rs | 60 +++++- src/backend/wayland/state.rs | 1 + src/backend/wayland/state/core/init.rs | 2 + src/backend/wayland/state/core/output.rs | 3 +- .../wayland/state/core/output/session_ops.rs | 49 ++++- .../wayland/state/core/output/tests.rs | 26 ++- .../wayland/state/core/output/transition.rs | 4 +- src/backend/wayland/state/core/session.rs | 22 +- src/backend/wayland/state/render/runtime.rs | 5 +- .../wayland/state/render/ui_effect_damage.rs | 7 +- src/backend/wayland/state/toolbar/events.rs | 8 +- src/draw/dirty.rs | 5 +- src/draw/frame/types.rs | 5 +- src/draw/mod.rs | 1 - src/draw/render/context.rs | 30 ++- src/draw/render/selection.rs | 5 +- src/draw/render/text.rs | 141 ++++++------- src/draw/shape/mod.rs | 1 - src/draw/shape/text_cache.rs | 36 +--- src/draw/shape/types.rs | 5 +- src/input/hit_test/mod.rs | 13 +- src/input/state/actions/action_ui.rs | 23 ++- src/input/state/actions/key_press/mod.rs | 24 ++- src/input/state/core/board/delete_restore.rs | 5 +- .../state/core/board/delete_restore/page.rs | 8 +- src/input/state/core/board/pages.rs | 17 +- src/input/state/core/board/switch.rs | 26 ++- src/input/state/core/board_picker/search.rs | 10 +- .../state/core/board_picker/state/actions.rs | 42 ---- .../state/core/board_picker/state/drag.rs | 6 - .../state/core/board_picker/state/edit.rs | 18 -- .../core/board_picker/state/lifecycle.rs | 22 +- .../state/core/color_picker_popup/state.rs | 10 +- src/input/state/core/command_palette/input.rs | 21 +- src/input/state/core/highlight_controls.rs | 19 +- src/input/state/core/history.rs | 5 +- src/input/state/core/ime.rs | 6 +- src/input/state/core/index.rs | 8 +- src/input/state/core/menus/commands.rs | 12 +- src/input/state/core/menus/lifecycle.rs | 5 +- src/input/state/core/radial_menu/state.rs | 14 +- .../state/core/selection_actions/clipboard.rs | 12 +- .../selection_actions/clipboard/duplicate.rs | 6 +- .../state/core/selection_actions/resize.rs | 5 +- .../selection_actions/translation/bounds.rs | 5 +- src/input/state/core/session.rs | 30 ++- src/input/state/core/status_hud.rs | 52 ++--- src/input/state/core/tool_controls/presets.rs | 5 +- .../state/core/tool_controls/settings.rs | 17 +- src/input/state/core/tool_controls/toolbar.rs | 90 +++++--- src/input/state/core/toolbar/apply/layout.rs | 88 ++++---- src/input/state/core/toolbar/apply/mod.rs | 26 +-- src/input/state/core/tour.rs | 16 +- src/input/state/core/utility/focus_mode.rs | 2 +- src/input/state/core/utility/interaction.rs | 7 +- src/input/state/core/utility/light_mode.rs | 61 ++---- .../state/core/utility/presenter_mode.rs | 3 +- src/input/state/core/zoom_chip.rs | 27 +-- src/input/state/mod.rs | 1 - src/input/state/mouse/motion.rs | 17 +- src/input/state/mouse/press.rs | 18 +- src/input/state/mouse/release/mod.rs | 18 +- src/input/state/tests/board_picker.rs | 193 +++++++++--------- src/input/state/tests/boards.rs | 2 +- src/input/state/tests/drawing.rs | 6 +- src/input/state/tests/focus_mode.rs | 6 +- src/input/state/tests/menus/context_menu.rs | 4 +- src/input/state/tests/modal.rs | 10 +- src/input/state/tests/selection/duplicate.rs | 12 +- src/input/state/tests/spotlight.rs | 2 +- src/input/state/tests/status_hud.rs | 14 +- .../tests/status_hud/engine_mutations.rs | 62 ++++-- src/input/state/tests/toolbar_display.rs | 18 +- src/input/state/tests/zoom_chip.rs | 10 +- src/input/state/text_resources.rs | 13 -- src/input/tablet/mod.rs | 70 +++++-- src/session/snapshot/apply.rs | 31 ++- src/session/tests/snapshot.rs | 18 +- src/ui.rs | 2 +- src/ui/board_picker/tests.rs | 2 +- src/ui/status/bar.rs | 4 +- src/ui/status/bar/content.rs | 33 +-- src/ui/status/bar/tests/width_budget.rs | 34 ++- src/ui/status/mod.rs | 2 +- src/ui/status/tests.rs | 7 +- src/ui/status/zoom_chip.rs | 13 +- src/ui_text.rs | 7 - src/ui_text/tests.rs | 14 +- 111 files changed, 1235 insertions(+), 854 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index fdf8c4814..753966396 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -196,7 +196,10 @@ fn advance_post_dispatch_state( if state.finish_toolbar_drag_handoff_if_due(Instant::now()) { let _ = conn.flush(); } - if state.input_state.tick_delayed_history(Instant::now()) { + if state + .input_state + .tick_delayed_history_with(state.render.text_measurer(), Instant::now()) + { state.toolbar.mark_dirty(); state.input_state.needs_redraw = true; } diff --git a/src/backend/wayland/backend/event_loop/session_save.rs b/src/backend/wayland/backend/event_loop/session_save.rs index cdff9caf1..6ae5a7447 100644 --- a/src/backend/wayland/backend/event_loop/session_save.rs +++ b/src/backend/wayland/backend/event_loop/session_save.rs @@ -82,7 +82,9 @@ fn persist_final_session_direct(state: &mut WaylandState) -> Result<(), anyhow:: if should_skip_protected_session_save(state, &options) { return Ok(()); } - let snapshot = state.input_state.snapshot_for_persistence(&options); + let snapshot = state + .input_state + .snapshot_for_persistence_with(state.render.text_measurer(), &options); let has_board_data = snapshot .as_ref() .is_some_and(session::SessionSnapshot::has_board_data); @@ -142,7 +144,9 @@ fn persist_final_session(state: &mut WaylandState) -> Result<(), anyhow::Error> options.session_file_path().display() ); let snapshot_started = Instant::now(); - let snapshot = state.input_state.snapshot_for_persistence(&options); + let snapshot = state + .input_state + .snapshot_for_persistence_with(state.render.text_measurer(), &options); log_snapshot_capture( SessionSaveReason::Shutdown, &options, @@ -243,7 +247,9 @@ pub(super) fn autosave_if_due(state: &mut WaylandState, now: Instant) -> Result< let started = Instant::now(); let snapshot_started = Instant::now(); - let snapshot = state.input_state.snapshot_for_persistence(&options); + let snapshot = state + .input_state + .snapshot_for_persistence_with(state.render.text_measurer(), &options); log_snapshot_capture( SessionSaveReason::Autosave, &options, diff --git a/src/backend/wayland/backend/event_loop/session_save/tests.rs b/src/backend/wayland/backend/event_loop/session_save/tests.rs index 8ba77bcc1..36930a73c 100644 --- a/src/backend/wayland/backend/event_loop/session_save/tests.rs +++ b/src/backend/wayland/backend/event_loop/session_save/tests.rs @@ -103,7 +103,7 @@ fn shutdown_persistence_records_wheel_history_before_capturing_the_snapshot() { options.persist_transparent = true; options.persist_history = true; let snapshot = input - .snapshot_for_persistence(&options) + .snapshot_for_persistence_with(&crate::draw::TextMeasurer::default(), &options) .expect("changed loupe snapshot"); let mut restored = make_test_input_state(); crate::session::apply_snapshot(&mut restored, snapshot, &options); diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index e96f9b069..8036c3213 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -76,6 +76,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul &keybindings.keybinding_conflicts, ); let ui_text = crate::ui_text::UiTextEngine::default(); + let text_measurer = crate::draw::TextMeasurer::default(); let runtime_ui_path = crate::paths::runtime_ui_state_file(); let (runtime_ui, runtime_ui_unavailable) = match crate::backend::wayland::runtime_ui_state::ToolbarRuntimeState::start( @@ -85,7 +86,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul runtime_wake.handle(), ) { Ok(runtime_ui) => { - runtime_ui.apply_startup_state(&ui_text, &mut input_state); + runtime_ui.apply_startup_state(&ui_text, &text_measurer, &mut input_state); (Some(runtime_ui), None) } Err(error) => { @@ -160,7 +161,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul input_state.set_command_palette_recents(palette_recents_store.recents().to_vec()); let palette_recents = crate::palette_recents::PaletteRecentsWriter::new(palette_recents_store); - apply_initial_mode(backend, &config, &mut input_state); + apply_initial_mode(backend, &config, &mut input_state, &text_measurer); let capture_wake = runtime_wake.handle(); let capture_manager = @@ -182,6 +183,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul let mut state = WaylandState::new(WaylandStateInit { ui_text, + text_measurer, globals: setup.state_globals, config, input_state, @@ -239,12 +241,17 @@ fn runtime_ui_unavailable_snapshot( } } -fn apply_initial_mode(backend: &WaylandBackend, _config: &Config, input_state: &mut InputState) { +fn apply_initial_mode( + backend: &WaylandBackend, + _config: &Config, + input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, +) { // Apply initial board from CLI (if provided). if let Some(initial_id) = backend.initial_mode.clone() { if input_state.boards.has_board(&initial_id) { info!("Starting on board '{}'", initial_id); - input_state.switch_board_force(&initial_id); + input_state.switch_board_force_with_measurer(measurer, &initial_id); } else if !initial_id.is_empty() { warn!("Requested board '{}' not found; using default", initial_id); } diff --git a/src/backend/wayland/backend/tray.rs b/src/backend/wayland/backend/tray.rs index b46bb19de..32f3063a1 100644 --- a/src/backend/wayland/backend/tray.rs +++ b/src/backend/wayland/backend/tray.rs @@ -117,27 +117,41 @@ fn apply_tray_action(state: &mut WaylandState, action: TrayAction) { state.input_state.needs_redraw = true; } TrayAction::ToggleLightMode => { - state - .input_state - .toggle_light_mode_with_engine(state.render.ui_text()); + state.input_state.toggle_light_mode_with_resources( + crate::input::state::InputTextResources { + measurer: state.render.text_measurer(), + ui_engine: state.render.ui_text(), + }, + ); state.input_state.needs_redraw = true; } TrayAction::LightDrawToggle => { - state - .input_state - .toggle_light_mode_drawing_with_engine(state.render.ui_text()); + state.input_state.toggle_light_mode_drawing_with_resources( + crate::input::state::InputTextResources { + measurer: state.render.text_measurer(), + ui_engine: state.render.ui_text(), + }, + ); state.input_state.needs_redraw = true; } TrayAction::LightDrawOn => { - state - .input_state - .set_light_mode_drawing_with_engine(state.render.ui_text(), true); + state.input_state.set_light_mode_drawing_with_resources( + crate::input::state::InputTextResources { + measurer: state.render.text_measurer(), + ui_engine: state.render.ui_text(), + }, + true, + ); state.input_state.needs_redraw = true; } TrayAction::LightDrawOff => { - state - .input_state - .set_light_mode_drawing_with_engine(state.render.ui_text(), false); + state.input_state.set_light_mode_drawing_with_resources( + crate::input::state::InputTextResources { + measurer: state.render.text_measurer(), + ui_engine: state.render.ui_text(), + }, + false, + ); state.input_state.needs_redraw = true; } } diff --git a/src/backend/wayland/handlers/layer.rs b/src/backend/wayland/handlers/layer.rs index 4055bce22..878e6ab34 100644 --- a/src/backend/wayland/handlers/layer.rs +++ b/src/backend/wayland/handlers/layer.rs @@ -12,9 +12,11 @@ impl LayerShellHandler for WaylandState { fn closed(&mut self, _conn: &Connection, _qh: &QueueHandle, layer: &LayerSurface) { if self.toolbar.is_toolbar_layer(layer) { info!("Toolbar surface closed by compositor; hiding toolbar"); - let _ = self - .input_state - .set_toolbar_visible_with_engine(self.render.ui_text(), false); + let _ = self.input_state.set_toolbar_visible_with_resources( + self.render.ui_text(), + self.render.text_measurer(), + false, + ); self.toolbar.set_visible(false); self.refresh_keyboard_interactivity(); return; diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index 495aee2bc..b834d78cf 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -379,7 +379,7 @@ mod tests { #[test] fn board_picker_page_panel_axis_consumes_before_thickness_changes() { let mut input_state = make_test_input_state(); - input_state.open_board_picker(); + input_state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input_state .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -427,7 +427,7 @@ mod tests { // over the board picker, which makes it the one that can have the list // scrolled out from under it. let mut input_state = make_test_input_state(); - input_state.open_board_picker(); + input_state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input_state .board_picker_page_panel_board_index() .expect("page panel board index"); diff --git a/src/backend/wayland/runtime_ui_state/coordinator.rs b/src/backend/wayland/runtime_ui_state/coordinator.rs index 26453229e..c7b9e81f5 100644 --- a/src/backend/wayland/runtime_ui_state/coordinator.rs +++ b/src/backend/wayland/runtime_ui_state/coordinator.rs @@ -62,11 +62,18 @@ impl ToolbarRuntimeState { pub(in crate::backend::wayland) fn apply_startup_state( &self, engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, input: &mut InputState, ) { - apply_live_toolbar_state(engine, input, self.controller.live_state(), |_| true); + apply_live_toolbar_state( + engine, + measurer, + input, + self.controller.live_state(), + |_| true, + ); apply_live_board_state(input, self.controller.live_state(), |_| true); - input.derive_toolbar_visibility_from_pins_with_engine(engine); + input.derive_toolbar_visibility_from_pins_with_resources(engine, measurer); } /// Layer retained position overrides on top of the authored seeds the @@ -84,10 +91,17 @@ impl ToolbarRuntimeState { pub(super) fn apply_live_state( &self, engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, input: &mut InputState, positions: &mut ToolbarPositionSnapshot, ) { - apply_live_toolbar_state(engine, input, self.controller.live_state(), |_| true); + apply_live_toolbar_state( + engine, + measurer, + input, + self.controller.live_state(), + |_| true, + ); apply_live_toolbar_positions(positions, self.controller.live_state(), |_| true); apply_live_board_state(input, self.controller.live_state(), |_| true); } @@ -274,6 +288,7 @@ impl ToolbarRuntimeState { pub(in crate::backend::wayland) fn refresh_config_seeds( &mut self, engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, config: &Config, input: &mut InputState, positions: &mut ToolbarPositionSnapshot, @@ -324,9 +339,13 @@ impl ToolbarRuntimeState { .retain(|target, _| !changed.contains(target)); rollback }); - apply_live_toolbar_state(engine, input, self.controller.live_state(), |target| { - changed.contains(target) - }); + apply_live_toolbar_state( + engine, + measurer, + input, + self.controller.live_state(), + |target| changed.contains(target), + ); apply_live_toolbar_positions(positions, self.controller.live_state(), |target| { changed.contains(target) }); @@ -336,7 +355,7 @@ impl ToolbarRuntimeState { // keeping toolbar preview updates scoped to changed targets above. apply_live_board_state(input, self.controller.live_state(), |_| true); if let Some(rollback) = position_rollback { - apply_toolbar_runtime_rollback(engine, input, positions, &rollback); + apply_toolbar_runtime_rollback(engine, measurer, input, positions, &rollback); } self.dispatch_writer_command(); ToolbarSeedRefresh { diff --git a/src/backend/wayland/runtime_ui_state/live_state.rs b/src/backend/wayland/runtime_ui_state/live_state.rs index f00683737..ffe71f029 100644 --- a/src/backend/wayland/runtime_ui_state/live_state.rs +++ b/src/backend/wayland/runtime_ui_state/live_state.rs @@ -44,6 +44,7 @@ pub(super) fn top_display_mode_values( /// else it is the live strip's mode. pub(super) fn apply_persisted_top_display_mode( engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, input: &mut InputState, mode: PersistedTopDisplayMode, ) { @@ -52,20 +53,21 @@ pub(super) fn apply_persisted_top_display_mode( return; } if input.toolbar_top_display_mode() != mode { - input.set_top_display_mode_with_engine(engine, mode); + input.set_top_display_mode_with_resources(engine, measurer, mode); } } pub(super) fn apply_live_toolbar_state( engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, input: &mut InputState, live: &RuntimeUiLiveState, include: impl Fn(&InteractionSeedTarget) -> bool, ) { - apply_live_display_flags(engine, input, live, &include); + apply_live_display_flags(engine, measurer, input, live, &include); apply_live_toolbar_preferences(input, live, &include); apply_live_overlay_flags(input, live, &include); - apply_live_toolbar_structure(engine, input, live, &include); + apply_live_toolbar_structure(engine, measurer, input, live, &include); } fn live_bool(live: &RuntimeUiLiveState, target: InteractionSeedTarget) -> Option { @@ -77,6 +79,7 @@ fn live_bool(live: &RuntimeUiLiveState, target: InteractionSeedTarget) -> Option fn apply_live_display_flags( engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, input: &mut InputState, live: &RuntimeUiLiveState, include: &impl Fn(&InteractionSeedTarget) -> bool, @@ -88,7 +91,7 @@ fn apply_live_display_flags( && let Some(InteractionSeedValue::TopDisplayMode(mode)) = live.get(&InteractionSeedTarget::TopDisplayMode) { - apply_persisted_top_display_mode(engine, input, *mode); + apply_persisted_top_display_mode(engine, measurer, input, *mode); } if include(&InteractionSeedTarget::StatusBar) && let Some(value) = live_bool(live, InteractionSeedTarget::StatusBar) @@ -193,6 +196,7 @@ fn apply_live_overlay_flags( fn apply_live_toolbar_structure( engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, input: &mut InputState, live: &RuntimeUiLiveState, include: &impl Fn(&InteractionSeedTarget) -> bool, @@ -220,7 +224,7 @@ fn apply_live_toolbar_structure( if include(&InteractionSeedTarget::StatusBarItem(item)) && let Some(value) = live_bool(live, InteractionSeedTarget::StatusBarItem(item)) { - input.set_status_bar_item_visible_with_engine(engine, item, value); + input.set_status_bar_item_visible_with_resources(engine, measurer, item, value); } } if include(&InteractionSeedTarget::TopPinned) diff --git a/src/backend/wayland/runtime_ui_state/rollback.rs b/src/backend/wayland/runtime_ui_state/rollback.rs index cbcd62275..d478946c1 100644 --- a/src/backend/wayland/runtime_ui_state/rollback.rs +++ b/src/backend/wayland/runtime_ui_state/rollback.rs @@ -2,6 +2,7 @@ use super::*; pub(in crate::backend::wayland) fn apply_toolbar_runtime_rollback( engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, input: &mut InputState, positions: &mut ToolbarPositionSnapshot, rollback: &PreviewRollbackSnapshot, @@ -38,14 +39,14 @@ pub(in crate::backend::wayland) fn apply_toolbar_runtime_rollback( } Target::TopDisplayMode => { if let InteractionSeedValue::TopDisplayMode(mode) = value { - apply_persisted_top_display_mode(engine, input, *mode); + apply_persisted_top_display_mode(engine, measurer, input, *mode); } } Target::StatusBarInteractive => { set_bool(value, |v| input.ui_visibility.status_bar_interactive = v) } Target::StatusBarItem(item) => set_bool(value, |v| { - input.set_status_bar_item_visible_with_engine(engine, *item, v); + input.set_status_bar_item_visible_with_resources(engine, measurer, *item, v); }), Target::StatusBar => set_bool(value, |v| input.ui_visibility.show_status_bar = v), Target::StatusBoardBadge => { @@ -107,7 +108,7 @@ pub(in crate::backend::wayland) fn apply_toolbar_runtime_rollback( // leaves live visibility alone. The preview records which path created the // rollback so this cannot be guessed incorrectly from `TopPinned` alone. if rollback.derive_toolbar_visibility_from_pins { - input.derive_toolbar_visibility_from_pins_with_engine(engine); + input.derive_toolbar_visibility_from_pins_with_resources(engine, measurer); } input.needs_redraw = true; } diff --git a/src/backend/wayland/runtime_ui_state/tests.rs b/src/backend/wayland/runtime_ui_state/tests.rs index 22e50070a..4d10b644c 100644 --- a/src/backend/wayland/runtime_ui_state/tests.rs +++ b/src/backend/wayland/runtime_ui_state/tests.rs @@ -124,6 +124,7 @@ fn apply_finish( if let ToolbarRuntimeFinish::Rollback(rollback) = finish { apply_toolbar_runtime_rollback( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), input, positions, &rollback, @@ -213,7 +214,11 @@ fn commit_display_mode( let prepared = runtime .begin_toolbar_mutation(target, input) .expect("display mode permit"); - input.set_top_display_mode_with_engine(&crate::ui_text::UiTextEngine::default(), mode); + input.set_top_display_mode_with_resources( + &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), + mode, + ); runtime.finish_toolbar_mutation(prepared, true, input) } diff --git a/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs b/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs index a5fdc59f7..c62811f47 100644 --- a/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs +++ b/src/backend/wayland/runtime_ui_state/tests/board_pin_resets.rs @@ -22,7 +22,11 @@ future_entry = [1, 2, 3] let config = Config::default(); let mut input = input_from_config(&config); let mut runtime = test_runtime(&config, &runtime_path); - runtime.apply_startup_state(&crate::ui_text::UiTextEngine::default(), &mut input); + runtime.apply_startup_state( + &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), + &mut input, + ); assert!(board_pinned(&input, "whiteboard")); assert!(matches!( @@ -67,6 +71,7 @@ fn global_runtime_reset_clears_board_pin_override_and_live_value() { let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; runtime.apply_live_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, ); @@ -109,6 +114,7 @@ fn unsupported_runtime_file_keeps_toolbar_mutations_live_only_and_byte_exact() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert!(!restarted_input.toolbar_top_minimized()); @@ -164,6 +170,7 @@ fn factory_visibility_reset_survives_restart_over_nondefault_authored_config() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert!( @@ -216,6 +223,7 @@ fn factory_order_reset_survives_restart_over_nondefault_authored_config() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert_eq!( diff --git a/src/backend/wayland/runtime_ui_state/tests/board_pins.rs b/src/backend/wayland/runtime_ui_state/tests/board_pins.rs index 09b2dd4d2..78732a197 100644 --- a/src/backend/wayland/runtime_ui_state/tests/board_pins.rs +++ b/src/backend/wayland/runtime_ui_state/tests/board_pins.rs @@ -109,6 +109,7 @@ fn board_pin_is_runtime_owned_and_survives_restart_without_touching_config() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert!(board_pinned(&restarted_input, "whiteboard")); @@ -142,6 +143,7 @@ value = true let refresh = runtime.refresh_config_seeds( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &config, &mut input, &mut positions, @@ -177,6 +179,7 @@ value = true let refresh = runtime.refresh_config_seeds( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &config, &mut input, &mut positions, @@ -220,6 +223,7 @@ value = true assert!(settle_runtime(&mut runtime).rollbacks.is_empty()); runtime.apply_live_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut ToolbarPositionSnapshot { top: (0.0, 0.0) }, ); @@ -231,6 +235,7 @@ value = true let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert!(!board_pinned(&restarted_input, &board_id)); @@ -264,6 +269,7 @@ fn restored_board_pin_is_replayed_after_same_authority_recovery() { assert!(rebuild_live); runtime.apply_live_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut ToolbarPositionSnapshot { top: (0.0, 0.0) }, ); @@ -314,6 +320,7 @@ fn deferred_board_pin_restore_is_discarded_when_reset_changes_authority() { assert_ne!(runtime.controller.authority_epoch(), original_epoch); runtime.apply_live_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut ToolbarPositionSnapshot { top: (0.0, 0.0) }, ); @@ -350,6 +357,7 @@ fn delayed_delete_and_same_id_reuse_cannot_resurrect_old_board_pin() { let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; runtime.apply_live_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, ); @@ -360,6 +368,7 @@ fn delayed_delete_and_same_id_reuse_cannot_resurrect_old_board_pin() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert!(!board_pinned(&restarted_input, "whiteboard")); @@ -392,6 +401,7 @@ fn stale_deferred_board_pin_is_rejected_after_authored_pin_reload() { let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; let refresh = runtime.refresh_config_seeds( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &config_b, &mut input, &mut positions, diff --git a/src/backend/wayland/runtime_ui_state/tests/drag_previews.rs b/src/backend/wayland/runtime_ui_state/tests/drag_previews.rs index dd03236ac..dd1e68019 100644 --- a/src/backend/wayland/runtime_ui_state/tests/drag_previews.rs +++ b/src/backend/wayland/runtime_ui_state/tests/drag_previews.rs @@ -180,6 +180,7 @@ fn relevant_reload_aborts_item_and_position_previews_without_restoring_old_seed( .to_vec(); let refresh = runtime.refresh_config_seeds( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &config_b, &mut input, &mut positions, @@ -210,6 +211,7 @@ fn relevant_reload_aborts_item_and_position_previews_without_restoring_old_seed( config_c.ui.toolbar.top_offset_y = 101.0; let refresh = runtime.refresh_config_seeds( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &config_c, &mut input, &mut positions, @@ -243,6 +245,7 @@ fn unrelated_position_reload_preserves_preview_and_cancel_only_restores_its_scop let refresh = runtime.refresh_config_seeds( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &config_b, &mut input, &mut positions, @@ -310,6 +313,7 @@ fn release_during_barrier_is_consumed_once_and_never_replayed() { assert_eq!(drain.rollbacks.len(), 1); apply_toolbar_runtime_rollback( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, &drain.rollbacks[0], @@ -393,6 +397,7 @@ fn external_source_conflict_rebuilds_live_toolbar_from_external_authority() { assert!(drain.rollbacks.is_empty()); runtime.apply_live_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, ); diff --git a/src/backend/wayland/runtime_ui_state/tests/layout_state.rs b/src/backend/wayland/runtime_ui_state/tests/layout_state.rs index c67e070bf..4a3bc16a7 100644 --- a/src/backend/wayland/runtime_ui_state/tests/layout_state.rs +++ b/src/backend/wayland/runtime_ui_state/tests/layout_state.rs @@ -86,6 +86,7 @@ fn an_authored_position_edit_drops_the_stale_drag_override() { config_b.ui.toolbar.top_offset_y = 201.0; let refresh = runtime.refresh_config_seeds( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &config_b, &mut input, &mut positions, @@ -192,6 +193,7 @@ fn a_stored_display_mode_is_restored_at_startup_over_the_config_seed() { let restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert_eq!( @@ -278,6 +280,7 @@ fn an_authored_display_mode_edit_drops_the_stale_cycle_override() { config_b.ui.toolbar.top_display_mode = TopDisplayMode::Micro; let refresh = runtime.refresh_config_seeds( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &config_b, &mut input, &mut positions, diff --git a/src/backend/wayland/runtime_ui_state/tests/preference_actions.rs b/src/backend/wayland/runtime_ui_state/tests/preference_actions.rs index 6237b2569..f7ecf1e0d 100644 --- a/src/backend/wayland/runtime_ui_state/tests/preference_actions.rs +++ b/src/backend/wayland/runtime_ui_state/tests/preference_actions.rs @@ -43,6 +43,7 @@ fn click_highlight_survives_restart_from_either_path() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); @@ -120,6 +121,7 @@ fn keyboard_only_chrome_toggles_survive_restart_without_touching_config() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); @@ -281,6 +283,7 @@ fn a_rollback_restores_every_durable_chrome_preference() { apply_toolbar_runtime_rollback( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, &PreviewRollbackSnapshot { diff --git a/src/backend/wayland/runtime_ui_state/tests/preferences.rs b/src/backend/wayland/runtime_ui_state/tests/preferences.rs index a9c32691d..ace8bf574 100644 --- a/src/backend/wayland/runtime_ui_state/tests/preferences.rs +++ b/src/backend/wayland/runtime_ui_state/tests/preferences.rs @@ -36,8 +36,9 @@ fn status_bar_content_survives_restart_without_touching_config() { let prepared = runtime .begin_toolbar_mutation(target, &input) .expect("item permit"); - input.set_status_bar_item_visible_with_engine( + input.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), StatusBarItem::Size, false, ); @@ -52,6 +53,7 @@ fn status_bar_content_survives_restart_without_touching_config() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); @@ -146,6 +148,7 @@ fn toolbar_preference_toggles_survive_restart_without_touching_config() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); @@ -201,6 +204,7 @@ fn a_seed_refresh_does_not_prune_persisted_preference_overrides() { let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; runtime.refresh_config_seeds( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &config, &mut input, &mut positions, @@ -212,6 +216,7 @@ fn a_seed_refresh_does_not_prune_persisted_preference_overrides() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert_eq!( @@ -266,6 +271,7 @@ fn section_visibility_survives_restart_without_touching_config() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); @@ -336,6 +342,7 @@ fn toolbar_layout_mode_survives_restart_without_touching_config() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); diff --git a/src/backend/wayland/runtime_ui_state/tests/recovery.rs b/src/backend/wayland/runtime_ui_state/tests/recovery.rs index 73ea1592f..bc20d6dcf 100644 --- a/src/backend/wayland/runtime_ui_state/tests/recovery.rs +++ b/src/backend/wayland/runtime_ui_state/tests/recovery.rs @@ -105,6 +105,7 @@ fn runtime_rebuild_reuses_minimize_transition_cleanup() { runtime.apply_live_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut rebuilt, &mut positions, ); @@ -161,6 +162,7 @@ fn supported_runtime_reset_returns_live_state_to_configured_defaults() { let mut positions = ToolbarPositionSnapshot { top: (0.0, 0.0) }; runtime.apply_live_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, ); @@ -372,6 +374,7 @@ fn cancelling_read_only_recovery_rebuilds_a_staged_seed_reload() { config_b.ui.toolbar.top_pinned = false; let refresh = runtime.refresh_config_seeds( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &config_b, &mut input, &mut positions, @@ -390,6 +393,7 @@ fn cancelling_read_only_recovery_rebuilds_a_staged_seed_reload() { ); runtime.apply_live_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, ); @@ -428,8 +432,9 @@ fn runtime_toolbar_routes_leave_authored_config_bytes_exactly_unchanged() { input.test_set_toolbar_display_state(input.toolbar_top_display_mode(), true); } ToolbarRuntimeUiPersistenceTarget::TopDisplayMode => { - input.set_top_display_mode_with_engine( + input.set_top_display_mode_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), crate::config::TopDisplayMode::Micro, ); } diff --git a/src/backend/wayland/runtime_ui_state/tests/text_geometry.rs b/src/backend/wayland/runtime_ui_state/tests/text_geometry.rs index d444abee3..79f5bd479 100644 --- a/src/backend/wayland/runtime_ui_state/tests/text_geometry.rs +++ b/src/backend/wayland/runtime_ui_state/tests/text_geometry.rs @@ -13,13 +13,16 @@ fn preference_reapply_and_rollback_refresh_text_geometry_before_another_frame() let engine = UiTextEngine::default(); let mut input = input_from_config(&config); let mut runtime = controller_only_runtime(&config, &temp.path().join("runtime-ui.toml")); - runtime.apply_startup_state(&engine, &mut input); + runtime.apply_startup_state(&engine, &crate::draw::TextMeasurer::default(), &mut input); assert!( input.status_hud_layout().is_none(), "startup has no previous frame dimensions" ); - input.update_status_hud_layout_for_pointer_with_engine( - &engine, + input.update_status_hud_layout_for_pointer_with_resources( + crate::input::state::InputTextResources { + measurer: &crate::draw::TextMeasurer::default(), + ui_engine: &engine, + }, StatusPosition::BottomLeft, &StatusBarStyle::default(), 1280, @@ -45,7 +48,13 @@ fn preference_reapply_and_rollback_refresh_text_geometry_before_another_frame() .set_status_bar_item_visible(StatusBarItem::Help, false); assert!( runtime - .refresh_config_seeds(&engine, &config, &mut input, &mut positions) + .refresh_config_seeds( + &engine, + &crate::draw::TextMeasurer::default(), + &config, + &mut input, + &mut positions + ) .applied ); assert!(!input.status_bar_item_visible(StatusBarItem::Help)); @@ -66,7 +75,13 @@ fn preference_reapply_and_rollback_refresh_text_geometry_before_another_frame() )]), derive_toolbar_visibility_from_pins: false, }; - apply_toolbar_runtime_rollback(&engine, &mut input, &mut positions, &rollback); + apply_toolbar_runtime_rollback( + &engine, + &crate::draw::TextMeasurer::default(), + &mut input, + &mut positions, + &rollback, + ); assert!(input.status_bar_item_visible(StatusBarItem::Help)); assert!( input @@ -77,7 +92,12 @@ fn preference_reapply_and_rollback_refresh_text_geometry_before_another_frame() .any(|s| s.kind == StatusHudSegmentKind::Help) ); assert_ne!(format!("{:?}", input.status_hud_layout()), hidden_geometry); - runtime.apply_live_state(&engine, &mut input, &mut positions); + runtime.apply_live_state( + &engine, + &crate::draw::TextMeasurer::default(), + &mut input, + &mut positions, + ); assert!(!input.status_bar_item_visible(StatusBarItem::Help)); assert_eq!(format!("{:?}", input.status_hud_layout()), hidden_geometry); } diff --git a/src/backend/wayland/runtime_ui_state/tests/visibility.rs b/src/backend/wayland/runtime_ui_state/tests/visibility.rs index 83163bcc8..5810d88ed 100644 --- a/src/backend/wayland/runtime_ui_state/tests/visibility.rs +++ b/src/backend/wayland/runtime_ui_state/tests/visibility.rs @@ -68,6 +68,7 @@ fn keyboard_visibility_toggle_persists_both_pins_and_startup_hides_the_toolbar() let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert!(!restarted_input.toolbar_top_pinned()); @@ -92,6 +93,7 @@ fn a_rolled_back_hide_toggle_restores_live_visibility_from_the_pins() { apply_toolbar_runtime_rollback( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, &pins_rollback(true), @@ -118,6 +120,7 @@ fn a_rolled_back_show_toggle_re_hides_the_toolbar() { apply_toolbar_runtime_rollback( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, &pins_rollback(false), @@ -150,6 +153,7 @@ fn a_rolled_back_pin_button_keeps_a_visible_unpinned_toolbar_visible() { apply_toolbar_runtime_rollback( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, &rollback, @@ -215,6 +219,7 @@ fn visibility_toggle_rollback_through_a_failed_reset_restores_the_screen() { assert_eq!(drain.rollbacks.len(), 1); apply_toolbar_runtime_rollback( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, &drain.rollbacks[0], @@ -374,6 +379,7 @@ fn an_exit_during_an_active_reset_barrier_still_lands_the_deferred_toggle() { if drain.rebuild_live { runtime.apply_live_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, ); @@ -404,6 +410,7 @@ fn an_exit_during_an_active_reset_barrier_still_lands_the_deferred_toggle() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert!(!restarted_input.toolbar_top_pinned()); diff --git a/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs b/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs index c230e58c8..bcf8481cd 100644 --- a/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs +++ b/src/backend/wayland/runtime_ui_state/tests/visibility_recovery.rs @@ -80,6 +80,7 @@ fn an_exit_during_retry_pending_recovery_still_lands_the_deferred_toggle() { let mut restarted = test_runtime(&config, &runtime_path); restarted.apply_startup_state( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut restarted_input, ); assert_eq!( @@ -125,6 +126,7 @@ fn a_deferred_hide_rollback_lands_in_the_presenter_restore_snapshot() { apply_toolbar_runtime_rollback( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, &pins_rollback(true), @@ -181,6 +183,7 @@ fn a_deferred_hide_rollback_lands_in_the_focus_mode_snapshot() { apply_toolbar_runtime_rollback( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, &pins_rollback(true), @@ -228,6 +231,7 @@ fn a_deferred_hide_rollback_lands_in_the_light_mode_snapshot() { apply_toolbar_runtime_rollback( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), &mut input, &mut positions, &pins_rollback(true), diff --git a/src/backend/wayland/runtime_ui_state/wayland.rs b/src/backend/wayland/runtime_ui_state/wayland.rs index e7c09fec2..1849e1a15 100644 --- a/src/backend/wayland/runtime_ui_state/wayland.rs +++ b/src/backend/wayland/runtime_ui_state/wayland.rs @@ -21,6 +21,7 @@ impl WaylandState { let mut positions = self.toolbar_position_snapshot(); apply_toolbar_runtime_rollback( self.render.ui_text(), + self.render.text_measurer(), &mut self.input_state, &mut positions, &rollback, @@ -340,6 +341,7 @@ impl WaylandState { }; let refresh = runtime.refresh_config_seeds( self.render.ui_text(), + self.render.text_measurer(), &self.config, &mut self.input_state, &mut positions, @@ -457,6 +459,7 @@ impl WaylandState { if let Some(runtime) = self.preferences.runtime_ui().state() { runtime.apply_live_state( self.render.ui_text(), + self.render.text_measurer(), &mut self.input_state, &mut positions, ); diff --git a/src/backend/wayland/session/runtime.rs b/src/backend/wayland/session/runtime.rs index db53b3b9f..3fb12aedf 100644 --- a/src/backend/wayland/session/runtime.rs +++ b/src/backend/wayland/session/runtime.rs @@ -38,6 +38,7 @@ pub(in crate::backend::wayland) struct RuntimeClearToolStateReport { #[allow(dead_code)] pub(in crate::backend::wayland) fn open_named_session_runtime( input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, session_state: &mut SessionState, target_path: &Path, now: Instant, @@ -50,6 +51,7 @@ pub(in crate::backend::wayland) fn open_named_session_runtime( let saved_current = save_current_session_before_runtime_open( input_state, + measurer, session_state, ¤t_options, now, @@ -98,6 +100,7 @@ pub(in crate::backend::wayland) fn open_named_session_runtime( let loaded_board_data = candidate_snapshot.has_board_data(); stored_session::apply_snapshot_replacing_boards( input_state, + measurer, candidate_snapshot, &candidate_options, )?; @@ -118,6 +121,7 @@ pub(in crate::backend::wayland) fn open_named_session_runtime( #[allow(dead_code)] pub(in crate::backend::wayland) fn save_named_session_as_runtime( input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, session_state: &mut SessionState, target_path: &Path, overwrite: stored_session::SaveAsOverwrite, @@ -133,6 +137,7 @@ pub(in crate::backend::wayland) fn save_named_session_as_runtime( if stored_session::catalog::session_paths_match(&previous_path, target_path) { let saved = save_current_session_before_runtime_open( input_state, + measurer, session_state, ¤t_options, now, @@ -153,7 +158,7 @@ pub(in crate::backend::wayland) fn save_named_session_as_runtime( target_options.force_resume_persistence(); let snapshot = input_state - .with_active_interaction_canceled_for_capture(|input_state| { + .with_active_interaction_canceled_for_capture_with(measurer, |input_state| { stored_session::snapshot_from_input(input_state, &target_options) }) .ok_or_else(|| anyhow!("Save Session As has no session data to write"))?; @@ -204,6 +209,7 @@ pub(in crate::backend::wayland) fn save_named_session_as_requires_overwrite( #[allow(dead_code)] pub(in crate::backend::wayland) fn clear_current_session_runtime( input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, session_state: &mut SessionState, now: Instant, ) -> Result { @@ -228,7 +234,12 @@ pub(in crate::backend::wayland) fn clear_current_session_runtime( )); } - stored_session::apply_snapshot_replacing_boards(input_state, empty_snapshot, &options)?; + stored_session::apply_snapshot_replacing_boards( + input_state, + measurer, + empty_snapshot, + &options, + )?; input_state.set_session_preflight_options(Some(options)); let _ = input_state.take_session_dirty(); input_state.clear_session_dirty(); @@ -243,6 +254,7 @@ pub(in crate::backend::wayland) fn clear_current_session_runtime( #[allow(dead_code)] pub(in crate::backend::wayland) fn clear_saved_tool_state_runtime( input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, session_state: &mut SessionState, default_tool_state: stored_session::ToolStateSnapshot, now: Instant, @@ -255,7 +267,7 @@ pub(in crate::backend::wayland) fn clear_saved_tool_state_runtime( (None, None) }; - stored_session::apply_tool_state_snapshot(input_state, default_tool_state); + stored_session::apply_tool_state_snapshot(input_state, measurer, default_tool_state); input_state.mark_session_dirty(); session_state.record_input_dirty(now, true); @@ -267,6 +279,7 @@ pub(in crate::backend::wayland) fn clear_saved_tool_state_runtime( fn save_current_session_before_runtime_open( input_state: &mut InputState, + measurer: &crate::draw::TextMeasurer, session_state: &mut SessionState, options: &SessionOptions, now: Instant, @@ -275,9 +288,10 @@ fn save_current_session_before_runtime_open( return Ok(false); } - let snapshot = input_state.with_active_interaction_canceled_for_capture(|input_state| { - stored_session::snapshot_from_input(input_state, options) - }); + let snapshot = input_state + .with_active_interaction_canceled_for_capture_with(measurer, |input_state| { + stored_session::snapshot_from_input(input_state, options) + }); if should_skip_unloaded_contentless_save( session_state.has_loaded_board_data(), session_state.is_dirty(), diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index 8fe74b886..c98ce87b4 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -259,6 +259,7 @@ fn runtime_save_as_new_path_writes_and_switches_active_target() { let report = save_named_session_as_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &target_options.session_file_path(), stored_session::SaveAsOverwrite::Deny, @@ -312,6 +313,7 @@ fn runtime_save_as_rejects_existing_target_without_confirmation() { let err = save_named_session_as_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &target_options.session_file_path(), stored_session::SaveAsOverwrite::Deny, @@ -351,6 +353,7 @@ fn runtime_save_as_rejects_existing_sidecar_without_confirmation() { let err = save_named_session_as_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &target_options.session_file_path(), stored_session::SaveAsOverwrite::Deny, @@ -453,6 +456,7 @@ fn runtime_save_as_confirmed_overwrite_removes_stale_sidecars_but_keeps_lock() { save_named_session_as_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &target_options.session_file_path(), stored_session::SaveAsOverwrite::ConfirmReplace, @@ -488,6 +492,7 @@ fn runtime_save_as_current_target_noop_does_not_require_overwrite_cleanup() { let mut session_state = SessionState::new(Some(current_options.clone())); let report = save_named_session_as_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, ¤t_options.session_file_path(), stored_session::SaveAsOverwrite::Deny, @@ -523,6 +528,7 @@ fn runtime_save_as_rejects_symlink_before_current_target_shortcut() { let mut session_state = SessionState::new(Some(current_options.clone())); let err = save_named_session_as_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &symlink_path, stored_session::SaveAsOverwrite::Deny, @@ -556,8 +562,13 @@ fn runtime_clear_persists_boundary_then_clears_live_session() { session_state.mark_loaded(true); session_state.record_input_dirty(Instant::now(), true); - let report = clear_current_session_runtime(&mut input, &mut session_state, Instant::now()) - .expect("runtime clear"); + let report = clear_current_session_runtime( + &mut input, + &crate::draw::TextMeasurer::default(), + &mut session_state, + Instant::now(), + ) + .expect("runtime clear"); assert_eq!(report.cleared_path, current_options.session_file_path()); assert!(report.persisted); @@ -592,8 +603,13 @@ fn runtime_clear_primary_cleanup_failure_after_marker_still_clears_live_session( session_state.mark_loaded(true); session_state.record_input_dirty(Instant::now(), true); - let report = clear_current_session_runtime(&mut input, &mut session_state, Instant::now()) - .expect("runtime clear should treat durable marker as committed"); + let report = clear_current_session_runtime( + &mut input, + &crate::draw::TextMeasurer::default(), + &mut session_state, + Instant::now(), + ) + .expect("runtime clear should treat durable marker as committed"); assert_eq!(report.cleared_path, current_options.session_file_path()); assert_eq!(input.boards.active_frame().shapes.len(), 0); @@ -623,8 +639,13 @@ fn runtime_clear_persistence_failure_leaves_live_session_unchanged() { session_state.mark_loaded(true); session_state.record_input_dirty(Instant::now(), true); - let err = clear_current_session_runtime(&mut input, &mut session_state, Instant::now()) - .expect_err("symlink primary should abort durable clear before memory mutation"); + let err = clear_current_session_runtime( + &mut input, + &crate::draw::TextMeasurer::default(), + &mut session_state, + Instant::now(), + ) + .expect_err("symlink primary should abort durable clear before memory mutation"); assert!(format!("{err:#}").contains("symlink"), "{err:#}"); assert_eq!(input.boards.active_frame().shapes.len(), 1); @@ -660,6 +681,7 @@ fn runtime_clear_saved_tool_state_resets_live_tools_and_preserves_saved_boards() config.ui.show_status_bar = true; let report = clear_saved_tool_state_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, stored_session::ToolStateSnapshot::from_config(&config), Instant::now(), @@ -705,6 +727,7 @@ fn runtime_clear_saved_tool_state_without_active_session_resets_live_tools_only( let report = clear_saved_tool_state_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, stored_session::ToolStateSnapshot::from_config(&config), Instant::now(), @@ -731,6 +754,7 @@ fn runtime_open_success_commits_target_and_catalog_after_apply() { let mut session_state = SessionState::new(Some(current_options.clone())); let report = open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -781,6 +805,7 @@ fn runtime_open_marks_full_damage_after_replacing_boards() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -826,6 +851,7 @@ fn runtime_open_uses_recoverable_backup_without_mutating_candidate_artifacts() { let mut session_state = SessionState::new(Some(current_options)); open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_options.session_file_path(), Instant::now(), @@ -869,6 +895,7 @@ fn runtime_open_replaces_boards_missing_from_candidate_snapshot() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -901,6 +928,7 @@ fn runtime_open_resyncs_canvas_pointer_after_same_active_board_view_offset() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -933,6 +961,7 @@ fn runtime_open_releases_old_board_slots_for_candidate_boards() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -970,6 +999,7 @@ fn runtime_open_apply_capacity_failure_keeps_current_session_active() { let err = open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_options.session_file_path(), Instant::now(), @@ -1030,6 +1060,7 @@ fn runtime_open_rejects_full_candidate_snapshot_that_omits_overlay_board() { let mut session_state = SessionState::new(Some(current_options.clone())); let err = open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_options.session_file_path(), Instant::now(), @@ -1079,6 +1110,7 @@ fn runtime_open_clears_deleted_page_restore_state_from_previous_session() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -1112,6 +1144,7 @@ fn runtime_open_cancels_active_interaction_from_previous_session() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -1133,7 +1166,7 @@ fn runtime_open_closes_active_board_picker_drag() { let candidate_path = candidate_options.session_file_path(); let mut input = test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert!(input.board_picker_start_drag(0)); assert!(input.is_board_picker_open()); assert!(input.board_picker_is_dragging()); @@ -1141,6 +1174,7 @@ fn runtime_open_closes_active_board_picker_drag() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -1163,7 +1197,7 @@ fn runtime_open_closes_active_board_picker_page_drag() { let candidate_path = candidate_options.session_file_path(); let mut input = test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert!(input.board_picker_start_page_drag(0)); assert!(input.is_board_picker_open()); assert!(input.board_picker_is_page_dragging()); @@ -1171,6 +1205,7 @@ fn runtime_open_closes_active_board_picker_page_drag() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -1210,6 +1245,7 @@ fn runtime_open_saves_current_after_canceling_active_selection_move() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -1273,6 +1309,7 @@ fn runtime_open_saves_current_after_canceling_active_text_edit() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -1316,6 +1353,7 @@ fn runtime_open_saves_current_after_canceling_color_picker_preview() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -1364,6 +1402,7 @@ fn runtime_open_current_save_failure_preserves_active_selection_move() { let err = open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_options.session_file_path(), Instant::now(), @@ -1431,6 +1470,7 @@ fn runtime_open_current_save_failure_preserves_spatial_index_for_active_selectio let err = open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_options.session_file_path(), Instant::now(), @@ -1481,6 +1521,7 @@ fn runtime_open_clears_stale_selection_and_hit_cache() { open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_path, Instant::now(), @@ -1507,6 +1548,7 @@ fn runtime_open_candidate_failure_after_current_save_keeps_current_active() { let err = open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_options.session_file_path(), Instant::now(), @@ -1568,6 +1610,7 @@ fn runtime_open_rejects_readonly_candidate_parent_before_commit() { let mut session_state = SessionState::new(Some(current_options.clone())); let err = open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_options.session_file_path(), Instant::now(), @@ -1605,6 +1648,7 @@ fn runtime_open_current_save_failure_aborts_before_candidate_load() { let err = open_named_session_runtime( &mut input, + &crate::draw::TextMeasurer::default(), &mut session_state, &candidate_options.session_file_path(), Instant::now(), diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index 5535bc082..a625d4b8b 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -127,6 +127,7 @@ pub(super) use helpers::{ pub(in crate::backend::wayland) struct WaylandStateInit { pub ui_text: crate::ui_text::UiTextEngine, + pub text_measurer: crate::draw::TextMeasurer, pub globals: ProtocolGlobals, pub config: Config, pub input_state: InputState, diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index d90bf3c52..2a641bf6f 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -6,6 +6,7 @@ impl WaylandState { pub(in crate::backend::wayland) fn new(init: WaylandStateInit) -> Self { let WaylandStateInit { ui_text, + text_measurer, globals, config, input_state, @@ -119,6 +120,7 @@ impl WaylandState { render: super::super::render::RenderRuntime::new( crate::ui::theme::Theme::resolve(config.ui.theme.to_theme_mode()), ui_text, + text_measurer, ), suppression: Default::default(), shortcut_coach: Default::default(), diff --git a/src/backend/wayland/state/core/output.rs b/src/backend/wayland/state/core/output.rs index 2f81575ef..abfab083b 100644 --- a/src/backend/wayland/state/core/output.rs +++ b/src/backend/wayland/state/core/output.rs @@ -74,6 +74,7 @@ fn live_source_reconciliation_ready( fn replace_output_session_snapshot( input_state: &mut crate::input::InputState, + measurer: &crate::draw::TextMeasurer, snapshot: Option, options: &session::SessionOptions, ) -> anyhow::Result<()> { @@ -82,7 +83,7 @@ fn replace_output_session_snapshot( boards: Vec::new(), tool_state: None, }); - session::apply_snapshot_replacing_boards(input_state, snapshot, options) + session::apply_snapshot_replacing_boards(input_state, measurer, snapshot, options) } #[cfg(test)] diff --git a/src/backend/wayland/state/core/output/session_ops.rs b/src/backend/wayland/state/core/output/session_ops.rs index 4c11b4912..24b959adf 100644 --- a/src/backend/wayland/state/core/output/session_ops.rs +++ b/src/backend/wayland/state/core/output/session_ops.rs @@ -50,7 +50,12 @@ impl WaylandState { context, options.session_file_path().display() ); - replace_output_session_snapshot(&mut self.input_state, Some(*snapshot), options)?; + replace_output_session_snapshot( + &mut self.input_state, + self.render.text_measurer(), + Some(*snapshot), + options, + )?; } session::LoadSnapshotOutcome::LoadedFromBackup(snapshot) => { warn!( @@ -58,7 +63,12 @@ impl WaylandState { context, options.backup_file_path().display() ); - replace_output_session_snapshot(&mut self.input_state, Some(*snapshot), options)?; + replace_output_session_snapshot( + &mut self.input_state, + self.render.text_measurer(), + Some(*snapshot), + options, + )?; self.input_state.push_toast(ToastPriority::Info, "output", Toast::warning("Restored drawings from the session backup; the primary session had no board data.")); } session::LoadSnapshotOutcome::LoadedFromRecovery(snapshot) => { @@ -67,7 +77,12 @@ impl WaylandState { context, options.recovery_file_path().display() ); - replace_output_session_snapshot(&mut self.input_state, Some(*snapshot), options)?; + replace_output_session_snapshot( + &mut self.input_state, + self.render.text_measurer(), + Some(*snapshot), + options, + )?; self.input_state.push_toast(ToastPriority::Info, "output", Toast::warning("Restored session from recovery file; normal save previously exceeded the size limit.")); } session::LoadSnapshotOutcome::Empty => { @@ -76,7 +91,12 @@ impl WaylandState { options.session_file_path().display(), context ); - replace_output_session_snapshot(&mut self.input_state, None, options)?; + replace_output_session_snapshot( + &mut self.input_state, + self.render.text_measurer(), + None, + options, + )?; } session::LoadSnapshotOutcome::EmptyAfterCorruption { backup_path } => { // An empty canvas here is indistinguishable from "no session @@ -88,7 +108,12 @@ impl WaylandState { context, backup_path.display() ); - replace_output_session_snapshot(&mut self.input_state, None, options)?; + replace_output_session_snapshot( + &mut self.input_state, + self.render.text_measurer(), + None, + options, + )?; self.input_state.push_toast( ToastPriority::Critical, "session.corrupt", @@ -105,13 +130,23 @@ impl WaylandState { path.display(), context ); - replace_output_session_snapshot(&mut self.input_state, None, options)?; + replace_output_session_snapshot( + &mut self.input_state, + self.render.text_measurer(), + None, + options, + )?; } session::LoadSnapshotOutcome::ExpandedTooLarge { path, max_expanded_size, } => { - replace_output_session_snapshot(&mut self.input_state, None, options)?; + replace_output_session_snapshot( + &mut self.input_state, + self.render.text_measurer(), + None, + options, + )?; self.session.protect_session_path(path.clone()); if self.session.mark_expanded_load_notified(&path) { notification::send_notification_async( diff --git a/src/backend/wayland/state/core/output/tests.rs b/src/backend/wayland/state/core/output/tests.rs index 3e00d63bd..32416903d 100644 --- a/src/backend/wayland/state/core/output/tests.rs +++ b/src/backend/wayland/state/core/output/tests.rs @@ -34,7 +34,13 @@ fn empty_output_load_replaces_source_board_contents() { add_test_line(&mut input); assert_eq!(input.boards.active_frame().shapes.len(), 1); - replace_output_session_snapshot(&mut input, None, &options).expect("empty output replacement"); + replace_output_session_snapshot( + &mut input, + &crate::draw::TextMeasurer::default(), + None, + &options, + ) + .expect("empty output replacement"); assert!(input.boards.active_frame().shapes.is_empty()); } @@ -58,8 +64,13 @@ fn partial_output_load_clears_boards_omitted_from_snapshot() { tool_state: None, }; - replace_output_session_snapshot(&mut input, Some(snapshot), &options) - .expect("partial output replacement"); + replace_output_session_snapshot( + &mut input, + &crate::draw::TextMeasurer::default(), + Some(snapshot), + &options, + ) + .expect("partial output replacement"); input.switch_board_force("transparent"); assert!(input.boards.active_frame().shapes.is_empty()); @@ -85,8 +96,13 @@ fn failed_output_replacement_preserves_source_board_contents() { tool_state: None, }; - let err = replace_output_session_snapshot(&mut input, Some(snapshot), &options) - .expect_err("oversized replacement must fail before mutating live boards"); + let err = replace_output_session_snapshot( + &mut input, + &crate::draw::TextMeasurer::default(), + Some(snapshot), + &options, + ) + .expect_err("oversized replacement must fail before mutating live boards"); assert!(err.to_string().contains("current runtime allows")); assert_eq!(input.boards.active_frame().shapes.len(), 1); diff --git a/src/backend/wayland/state/core/output/transition.rs b/src/backend/wayland/state/core/output/transition.rs index 7692ad945..007368559 100644 --- a/src/backend/wayland/state/core/output/transition.rs +++ b/src/backend/wayland/state/core/output/transition.rs @@ -248,7 +248,9 @@ impl WaylandState { if self.should_skip_protected_session_save(options) { return Ok(()); } - let snapshot = self.input_state.snapshot_for_persistence(options); + let snapshot = self + .input_state + .snapshot_for_persistence_with(self.render.text_measurer(), options); if self.should_skip_unloaded_contentless_session_save(options, snapshot.as_ref())? { return Ok(()); } diff --git a/src/backend/wayland/state/core/session.rs b/src/backend/wayland/state/core/session.rs index 6b2881e53..36321e561 100644 --- a/src/backend/wayland/state/core/session.rs +++ b/src/backend/wayland/state/core/session.rs @@ -56,6 +56,7 @@ impl WaylandState { let loaded_board_data = candidate_snapshot.has_board_data(); stored_session::apply_snapshot_replacing_boards( &mut self.input_state, + self.render.text_measurer(), candidate_snapshot, &candidate_options, )?; @@ -129,9 +130,10 @@ impl WaylandState { let snapshot = self .input_state - .with_active_interaction_canceled_for_capture(|input_state| { - stored_session::snapshot_from_input(input_state, &target_options) - }) + .with_active_interaction_canceled_for_capture_with( + self.render.text_measurer(), + |input_state| stored_session::snapshot_from_input(input_state, &target_options), + ) .ok_or_else(|| anyhow!("Save Session As has no session data to write"))?; let outcome = session_save::run_persistence_operation( self, @@ -233,6 +235,7 @@ impl WaylandState { } stored_session::apply_snapshot_replacing_boards( &mut self.input_state, + self.render.text_measurer(), empty_snapshot, &options, )?; @@ -267,7 +270,11 @@ impl WaylandState { } else { (None, None) }; - stored_session::apply_tool_state_snapshot(&mut self.input_state, default_tool_state); + stored_session::apply_tool_state_snapshot( + &mut self.input_state, + self.render.text_measurer(), + default_tool_state, + ); self.input_state.mark_session_dirty(); Ok(RuntimeClearToolStateReport { session_path, @@ -336,9 +343,10 @@ impl WaylandState { } let snapshot = self .input_state - .with_active_interaction_canceled_for_capture(|input_state| { - stored_session::snapshot_from_input(input_state, options) - }); + .with_active_interaction_canceled_for_capture_with( + self.render.text_measurer(), + |input_state| stored_session::snapshot_from_input(input_state, options), + ); let snapshot = if let Some(snapshot) = snapshot { snapshot } else if session_persistence_enabled(options) { diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index b80a4009d..9bf6fa6f8 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -133,6 +133,7 @@ impl RenderRuntime { pub(in crate::backend::wayland) fn new( theme: crate::ui::theme::Theme, ui_text: crate::ui_text::UiTextEngine, + text_measurer: crate::draw::TextMeasurer, ) -> Self { Self { canvas_layer_cache: CanvasLayerCache::new(), @@ -141,7 +142,7 @@ impl RenderRuntime { ui_caches: crate::ui::UiRenderCaches::default(), help_content: crate::ui::HelpContentCache::default(), ui_text, - text_measurer: crate::draw::TextMeasurer::default(), + text_measurer, ui_damage: UiDamageHistory::default(), profile_ui_baseline: Vec::new(), } @@ -245,10 +246,12 @@ mod tests { let mut dark = RenderRuntime::new( crate::ui::theme::Theme::resolve(crate::ui::theme::ThemeMode::Dark), crate::ui_text::UiTextEngine::default(), + crate::draw::TextMeasurer::default(), ); let light = RenderRuntime::new( crate::ui::theme::Theme::resolve(crate::ui::theme::ThemeMode::Light), crate::ui_text::UiTextEngine::default(), + crate::draw::TextMeasurer::default(), ); assert_eq!(dark.theme(), &crate::ui::theme::Theme::dark()); assert_eq!(light.theme(), &crate::ui::theme::Theme::light()); diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index de4fe0f93..a85a127ef 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -150,8 +150,11 @@ impl WaylandState { chrome_cursor_can_rehit(self.has_cursor_focus(), self.cursor_blocked_by_toolbar()); let status_hud_rect = if flags.active(UiEffect::StatusHud) { self.input_state - .update_status_hud_layout_for_pointer_with_engine( - self.render.ui_text(), + .update_status_hud_layout_for_pointer_with_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, self.config.ui.status_bar_position, &self.config.ui.status_bar_style, width, diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index 245ba9018..54f687989 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -260,7 +260,13 @@ impl WaylandState { }; let pin_durability = pin_durability(prepared_runtime.as_ref()); - let applied = self.input_state.apply_toolbar_event(event); + let applied = self.input_state.apply_toolbar_event_with_resources( + crate::input::state::InputTextResources { + measurer: self.render.text_measurer(), + ui_engine: self.render.ui_text(), + }, + event, + ); if applied { self.toolbar.mark_dirty(); self.input_state.needs_redraw = true; diff --git a/src/draw/dirty.rs b/src/draw/dirty.rs index 8a1e97976..8d23793c2 100644 --- a/src/draw/dirty.rs +++ b/src/draw/dirty.rs @@ -3,7 +3,7 @@ //! Collects axis-aligned rectangles that need repainting between frames. use super::Shape; -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; use crate::util::Rect; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -75,7 +75,8 @@ impl DirtyTracker { /// Adds the bounding box for the given shape, or full damage if none is available. pub fn mark_shape(&mut self, shape: &Shape) { - with_scoped_measurer(|measurer| self.mark_shape_with(shape, measurer)); + let measurer = TextMeasurer::default(); + self.mark_shape_with(shape, &measurer); } /// Adds bounds measured with the supplied owner, or full damage if unavailable. diff --git a/src/draw/frame/types.rs b/src/draw/frame/types.rs index 53246e783..d38378b11 100644 --- a/src/draw/frame/types.rs +++ b/src/draw/frame/types.rs @@ -1,5 +1,5 @@ +use crate::draw::TextMeasurer; use crate::draw::shape::Shape; -use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::util::Rect; use serde::{Deserialize, Serialize}; use std::cell::Cell; @@ -60,7 +60,8 @@ impl DrawnShape { /// [`Self::invalidate_bounds`]) trips an assertion, so the test suite /// catches invalidation bugs while release builds get the O(1) fast path. pub fn bounding_box(&self) -> Option { - with_scoped_measurer(|measurer| self.bounding_box_with(measurer)) + let measurer = TextMeasurer::default(); + self.bounding_box_with(&measurer) } /// Returns memoized bounds using the supplied owner for text measurements diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 993979f5c..b7961730b 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -48,7 +48,6 @@ pub use render::{ render_text_with_halo_with_measurer, render_text_with_measurer, selection_handle_rects, spotlight_regions_for_frame, sticky_note_foreground, text_outline_color, }; -pub(crate) use shape::with_scoped_measurer; #[allow(unused_imports)] pub use shape::{ ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, MAX_PEN_SMOOTHING, diff --git a/src/draw/render/context.rs b/src/draw/render/context.rs index d6cbaa830..8b400ec3a 100644 --- a/src/draw/render/context.rs +++ b/src/draw/render/context.rs @@ -27,9 +27,8 @@ impl<'c, 'r> RenderCtx<'c, 'r> { } pub fn render_shape(&mut self, shape: &Shape) { - crate::draw::with_scoped_measurer(|measurer| { - self.render_shape_with_measurer(measurer, shape) - }) + let measurer = crate::draw::TextMeasurer::default(); + self.render_shape_with_measurer(&measurer, shape); } pub fn render_shape_with_measurer( @@ -41,9 +40,8 @@ impl<'c, 'r> RenderCtx<'c, 'r> { } pub fn render_shape_with_halo(&mut self, shape: &Shape, text_halo_enabled: bool) { - crate::draw::with_scoped_measurer(|measurer| { - self.render_shape_with_halo_with_measurer(measurer, shape, text_halo_enabled) - }) + let measurer = crate::draw::TextMeasurer::default(); + self.render_shape_with_halo_with_measurer(&measurer, shape, text_halo_enabled); } pub fn render_shape_with_halo_with_measurer( @@ -56,9 +54,8 @@ impl<'c, 'r> RenderCtx<'c, 'r> { } pub fn render_shape_over(&mut self, shape: &Shape, known_background_luminance: Option) { - crate::draw::with_scoped_measurer(|measurer| { - self.render_shape_over_with_measurer(measurer, shape, known_background_luminance) - }) + let measurer = crate::draw::TextMeasurer::default(); + self.render_shape_over_with_measurer(&measurer, shape, known_background_luminance); } pub fn render_shape_over_with_measurer( @@ -81,14 +78,13 @@ impl<'c, 'r> RenderCtx<'c, 'r> { known_background_luminance: Option, text_halo_enabled: bool, ) { - crate::draw::with_scoped_measurer(|measurer| { - self.render_shape_over_with_halo_with_measurer( - measurer, - shape, - known_background_luminance, - text_halo_enabled, - ) - }) + let measurer = crate::draw::TextMeasurer::default(); + self.render_shape_over_with_halo_with_measurer( + &measurer, + shape, + known_background_luminance, + text_halo_enabled, + ); } pub fn render_shape_over_with_halo_with_measurer( diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs index 17a3cf059..5d29b476f 100644 --- a/src/draw/render/selection.rs +++ b/src/draw/render/selection.rs @@ -30,9 +30,8 @@ const SELECTION_GLOW: Color = Color { /// Renders a selection halo overlay for a drawn shape. pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { - crate::draw::with_scoped_measurer(|measurer| { - render_selection_halo_with_measurer(measurer, ctx, drawn) - }) + let measurer = crate::draw::TextMeasurer::default(); + render_selection_halo_with_measurer(&measurer, ctx, drawn); } pub fn render_selection_halo_with_measurer( diff --git a/src/draw/render/text.rs b/src/draw/render/text.rs index 5d6398848..a0a6088a4 100644 --- a/src/draw/render/text.rs +++ b/src/draw/render/text.rs @@ -37,20 +37,19 @@ pub fn render_text( background_enabled: bool, wrap_width: Option, ) { - crate::draw::with_scoped_measurer(|measurer| { - render_text_with_measurer( - measurer, - ctx, - x, - y, - text, - color, - size, - font_descriptor, - background_enabled, - wrap_width, - ) - }) + let measurer = crate::draw::TextMeasurer::default(); + render_text_with_measurer( + &measurer, + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + ); } #[allow(clippy::too_many_arguments)] @@ -95,21 +94,20 @@ pub fn render_text_with_halo( wrap_width: Option, halo_enabled: bool, ) { - crate::draw::with_scoped_measurer(|measurer| { - render_text_with_halo_with_measurer( - measurer, - ctx, - x, - y, - text, - color, - size, - font_descriptor, - background_enabled, - wrap_width, - halo_enabled, - ) - }) + let measurer = crate::draw::TextMeasurer::default(); + render_text_with_halo_with_measurer( + &measurer, + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + halo_enabled, + ); } #[allow(clippy::too_many_arguments)] @@ -162,21 +160,20 @@ pub fn render_text_over( wrap_width: Option, known_background_luminance: Option, ) { - crate::draw::with_scoped_measurer(|measurer| { - render_text_over_with_measurer( - measurer, - ctx, - x, - y, - text, - color, - size, - font_descriptor, - background_enabled, - wrap_width, - known_background_luminance, - ) - }) + let measurer = crate::draw::TextMeasurer::default(); + render_text_over_with_measurer( + &measurer, + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + known_background_luminance, + ); } #[allow(clippy::too_many_arguments)] @@ -224,22 +221,21 @@ pub fn render_text_over_with_halo( known_background_luminance: Option, halo_enabled: bool, ) { - crate::draw::with_scoped_measurer(|measurer| { - render_text_over_with_halo_with_measurer( - measurer, - ctx, - x, - y, - text, - color, - size, - font_descriptor, - background_enabled, - wrap_width, - known_background_luminance, - halo_enabled, - ) - }) + let measurer = crate::draw::TextMeasurer::default(); + render_text_over_with_halo_with_measurer( + &measurer, + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + known_background_luminance, + halo_enabled, + ); } #[allow(clippy::too_many_arguments)] @@ -413,19 +409,18 @@ pub fn render_sticky_note( font_descriptor: &FontDescriptor, wrap_width: Option, ) { - crate::draw::with_scoped_measurer(|measurer| { - render_sticky_note_with_measurer( - measurer, - ctx, - x, - y, - text, - background, - size, - font_descriptor, - wrap_width, - ) - }) + let measurer = crate::draw::TextMeasurer::default(); + render_sticky_note_with_measurer( + &measurer, + ctx, + x, + y, + text, + background, + size, + font_descriptor, + wrap_width, + ); } #[allow(clippy::too_many_arguments)] diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs index fa604f2aa..1edccfa1f 100644 --- a/src/draw/shape/mod.rs +++ b/src/draw/shape/mod.rs @@ -16,7 +16,6 @@ pub use polygon::{ }; pub use smoothing::{MAX_PEN_SMOOTHING, clamp_pen_smoothing, smooth_path, smooth_pressure_path}; pub use text_cache::TextMeasurer; -pub(crate) use text_cache::with_scoped_measurer; pub use types::{ ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, Shape, StepMarkerLabel, diff --git a/src/draw/shape/text_cache.rs b/src/draw/shape/text_cache.rs index 5d61005f0..6d076d02a 100644 --- a/src/draw/shape/text_cache.rs +++ b/src/draw/shape/text_cache.rs @@ -47,34 +47,18 @@ impl TextMeasurement { } } -/// Run a public convenience operation with an isolated call-local owner. -/// Runtime paths should pass their persistent `TextMeasurer` explicitly. -pub(crate) fn with_scoped_measurer(f: impl FnOnce(&TextMeasurer) -> R) -> R { - let measurer = TextMeasurer::default(); - f(&measurer) -} - #[cfg(test)] #[test] -fn scoped_convenience_measurements_do_not_share_cache_entries() { - with_scoped_measurer(|measurer| { - assert_eq!(measurer.cache_len(), 0); - assert!( - measurer - .measure("first owner", "Sans", 14.0, None) - .is_some() - ); - assert_eq!(measurer.cache_len(), 1); - }); - with_scoped_measurer(|measurer| { - assert_eq!(measurer.cache_len(), 0); - assert!( - measurer - .measure("second owner", "Sans", 14.0, None) - .is_some() - ); - assert_eq!(measurer.cache_len(), 1); - }); +fn independent_measurement_owners_do_not_share_cache_entries() { + let first = TextMeasurer::default(); + assert_eq!(first.cache_len(), 0); + assert!(first.measure("first owner", "Sans", 14.0, None).is_some()); + assert_eq!(first.cache_len(), 1); + + let second = TextMeasurer::default(); + assert_eq!(second.cache_len(), 0); + assert!(second.measure("second owner", "Sans", 14.0, None).is_some()); + assert_eq!(second.cache_len(), 1); } /// Build a Pango layout configured exactly like the measurement and render diff --git a/src/draw/shape/types.rs b/src/draw/shape/types.rs index 1bdd3b286..64be2a0aa 100644 --- a/src/draw/shape/types.rs +++ b/src/draw/shape/types.rs @@ -6,7 +6,7 @@ use super::bounds::{ use super::polygon::{PolygonKind, bounding_box_for_polygon}; use super::step_marker::step_marker_bounds_with; use super::text::{bounding_box_for_sticky_note_with, bounding_box_for_text_with}; -use super::text_cache::{TextMeasurer, with_scoped_measurer}; +use super::text_cache::TextMeasurer; use crate::draw::color::Color; use crate::draw::font::FontDescriptor; use crate::util::Rect; @@ -435,7 +435,8 @@ impl Shape { /// Returns `None` when the shape has no drawable area or its full bounds cannot be /// represented safely by [`Rect`]. pub fn bounding_box(&self) -> Option { - with_scoped_measurer(|measurer| self.bounding_box_with(measurer)) + let measurer = TextMeasurer::default(); + self.bounding_box_with(&measurer) } /// Computes bounds using the caller's canonical text measurement owner. diff --git a/src/input/hit_test/mod.rs b/src/input/hit_test/mod.rs index 7cace960f..abf37b13d 100644 --- a/src/input/hit_test/mod.rs +++ b/src/input/hit_test/mod.rs @@ -10,7 +10,7 @@ use crate::draw::shape::{ arrow_label_ends, arrow_label_layout_with, step_marker_outline_thickness, step_marker_radius_with, }; -use crate::draw::{DrawnShape, Shape, TextMeasurer, with_scoped_measurer}; +use crate::draw::{DrawnShape, Shape, TextMeasurer}; use crate::util::Rect; const MAX_HIT_TEST_TOLERANCE: f64 = i32::MAX as f64; @@ -44,7 +44,8 @@ impl HitTestTolerance { pub(crate) use shapes::ellipse_fill_hit; pub fn compute_hit_bounds(shape: &DrawnShape, tolerance: f64) -> Option { - with_scoped_measurer(|measurer| compute_hit_bounds_with(measurer, shape, tolerance)) + let measurer = TextMeasurer::default(); + compute_hit_bounds_with(&measurer, shape, tolerance) } /// Computes tolerance-inflated bounds with the supplied text measurement owner. @@ -74,7 +75,8 @@ pub(crate) fn compute_hit_bounds_with_tolerance( /// Returns `true` if the point intersects the provided shape within tolerance. pub fn hit_test(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool { - with_scoped_measurer(|measurer| hit_test_with(measurer, shape, point, tolerance)) + let measurer = TextMeasurer::default(); + hit_test_with(&measurer, shape, point, tolerance) } /// Tests stroke geometry with the supplied text measurement owner. @@ -242,9 +244,8 @@ pub(crate) fn hit_test_with_tolerance( /// Stroke erasing intentionally keeps using `hit_test`, while direct point /// targeting includes filled interiors for closed fill-capable shapes. pub fn hit_test_for_point_targeting(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool { - with_scoped_measurer(|measurer| { - hit_test_for_point_targeting_with(measurer, shape, point, tolerance) - }) + let measurer = TextMeasurer::default(); + hit_test_for_point_targeting_with(&measurer, shape, point, tolerance) } /// Tests selection targets, including filled interiors, with explicit measurements. diff --git a/src/input/state/actions/action_ui.rs b/src/input/state/actions/action_ui.rs index 97838cf9f..193b0ca7d 100644 --- a/src/input/state/actions/action_ui.rs +++ b/src/input/state/actions/action_ui.rs @@ -48,11 +48,11 @@ impl InputState { true } Action::ToggleToolbar => { - self.handle_toggle_toolbar_with_engine(resources.ui_engine); + self.handle_toggle_toolbar_with_resources(resources); true } Action::CycleToolbarDisplay => { - self.handle_cycle_toolbar_display_with_engine(resources.ui_engine); + self.handle_cycle_toolbar_display_with_resources(resources); true } Action::TogglePresenterMode => { @@ -284,13 +284,20 @@ impl InputState { } } - fn handle_toggle_toolbar_with_engine(&mut self, engine: &crate::ui_text::UiTextEngine) { + fn handle_toggle_toolbar_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) { if self.presenter_hides_toolbars() { return; } self.break_focus_mode(); let now_visible = !self.toolbar_visible(); - if !self.set_toolbar_visible_with_engine(engine, now_visible) { + if !self.set_toolbar_visible_with_resources( + resources.ui_engine, + resources.measurer, + now_visible, + ) { return; } let previous_top_pinned = self.toolbar_top_pinned(); @@ -310,13 +317,17 @@ impl InputState { } } - fn handle_cycle_toolbar_display_with_engine(&mut self, engine: &crate::ui_text::UiTextEngine) { + fn handle_cycle_toolbar_display_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + ) { if self.presenter_hides_toolbars() { return; } self.break_focus_mode(); let previous_mode = self.toolbar_top_display_mode(); - let mode = self.cycle_top_toolbar_display_with_engine(engine); + let mode = + self.cycle_top_toolbar_display_with_resources(resources.ui_engine, resources.measurer); self.pending_onboarding_usage.used_toolbar_toggle = true; let toast = self.toolbar_display_toast(mode); self.push_toast(ToastPriority::Info, "ui", toast); diff --git a/src/input/state/actions/key_press/mod.rs b/src/input/state/actions/key_press/mod.rs index 40b046f5d..20806f646 100644 --- a/src/input/state/actions/key_press/mod.rs +++ b/src/input/state/actions/key_press/mod.rs @@ -34,9 +34,15 @@ impl InputState { /// - Help toggle (configurable) /// - Modifier key tracking pub fn on_key_press(&mut self, key: Key) { - crate::input::state::with_scoped_text_resources(|resources| { - self.on_key_press_with_resources(resources, key) - }); + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.on_key_press_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + key, + ); } pub(crate) fn on_key_press_with_resources( @@ -48,9 +54,15 @@ impl InputState { } pub fn on_key_repeat(&mut self, key: Key) { - crate::input::state::with_scoped_text_resources(|resources| { - self.on_key_repeat_with_resources(resources, key) - }); + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.on_key_repeat_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + key, + ); } pub(crate) fn on_key_repeat_with_resources( diff --git a/src/input/state/core/board/delete_restore.rs b/src/input/state/core/board/delete_restore.rs index 65543136d..ff0d72db7 100644 --- a/src/input/state/core/board/delete_restore.rs +++ b/src/input/state/core/board/delete_restore.rs @@ -1,6 +1,6 @@ use super::super::base::{BOARD_DELETE_CONFIRM_MS, InputState}; use crate::domain::Action; -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; use crate::input::boards::{ BoardDeleteOutcome, BoardDeleteRejection, BoardDeleteRequest, BoardDeleteTarget, BoardIdentityGeneration, BoardRestoreOutcome, BoardRestoreRejection, BoardRestoreRequest, @@ -112,7 +112,8 @@ impl InputState { } pub fn delete_active_board(&mut self) { - with_scoped_measurer(|measurer| self.delete_active_board_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.delete_active_board_with_measurer(&measurer); } pub fn delete_active_board_with_measurer(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/board/delete_restore/page.rs b/src/input/state/core/board/delete_restore/page.rs index 1e57c6cf5..c38ac3b44 100644 --- a/src/input/state/core/board/delete_restore/page.rs +++ b/src/input/state/core/board/delete_restore/page.rs @@ -1,7 +1,7 @@ use super::super::super::base::{InputState, PAGE_DELETE_CONFIRM_MS}; use crate::domain::Action; use crate::draw::PageDeleteOutcome as CanvasPageDeleteOutcome; -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; use crate::input::boards::{ PageDeleteBoardTarget, PageDeleteOutcome, PageDeleteRequest, PageDeleteTarget, PageOperationRejection, PageRestoreOutcome, PageRestorePlacement, PageRestoreRejection, @@ -108,7 +108,8 @@ impl InputState { } pub fn page_delete(&mut self) -> CanvasPageDeleteOutcome { - with_scoped_measurer(|measurer| self.page_delete_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.page_delete_with_measurer(&measurer) } pub fn page_delete_with_measurer( @@ -254,7 +255,8 @@ impl InputState { /// Restore the most recently deleted page. pub fn restore_deleted_page(&mut self) { - with_scoped_measurer(|measurer| self.restore_deleted_page_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.restore_deleted_page_with_measurer(&measurer); } pub fn restore_deleted_page_with_measurer(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/board/pages.rs b/src/input/state/core/board/pages.rs index 85e727db2..e272d2852 100644 --- a/src/input/state/core/board/pages.rs +++ b/src/input/state/core/board/pages.rs @@ -1,6 +1,6 @@ use super::super::base::InputState; use crate::draw::Color; -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; use crate::input::boards::PendingBoardRuntimeUiAction; use crate::input::state::{Toast, ToastPriority}; use crate::input::{BoardBackground, runtime_contrast_pen_color}; @@ -337,7 +337,8 @@ impl InputState { } pub fn page_prev(&mut self) -> bool { - with_scoped_measurer(|measurer| self.page_prev_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.page_prev_with_measurer(&measurer) } pub fn page_prev_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { @@ -352,7 +353,8 @@ impl InputState { } pub fn page_next(&mut self) -> bool { - with_scoped_measurer(|measurer| self.page_next_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.page_next_with_measurer(&measurer) } pub fn page_next_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { @@ -367,7 +369,8 @@ impl InputState { } pub fn switch_to_page(&mut self, index: usize) -> bool { - with_scoped_measurer(|measurer| self.switch_to_page_with_measurer(measurer, index)) + let measurer = TextMeasurer::default(); + self.switch_to_page_with_measurer(&measurer, index) } pub fn switch_to_page_with_measurer(&mut self, measurer: &TextMeasurer, index: usize) -> bool { @@ -382,7 +385,8 @@ impl InputState { } pub fn page_new(&mut self) { - with_scoped_measurer(|measurer| self.page_new_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.page_new_with_measurer(&measurer); } pub fn page_new_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -399,7 +403,8 @@ impl InputState { } pub fn page_duplicate(&mut self) { - with_scoped_measurer(|measurer| self.page_duplicate_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.page_duplicate_with_measurer(&measurer); } pub fn page_duplicate_with_measurer(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/board/switch.rs b/src/input/state/core/board/switch.rs index 333b6fc53..8c1fd8846 100644 --- a/src/input/state/core/board/switch.rs +++ b/src/input/state/core/board/switch.rs @@ -1,5 +1,5 @@ use super::super::base::InputState; -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; use crate::input::state::{Toast, ToastPriority}; use crate::input::{BOARD_ID_TRANSPARENT, BoardSpec}; @@ -45,7 +45,8 @@ impl InputState { /// /// Also resets drawing state to prevent partial shapes crossing modes. pub fn switch_board(&mut self, target_id: &str) { - with_scoped_measurer(|measurer| self.switch_board_with_measurer(measurer, target_id)) + let measurer = TextMeasurer::default(); + self.switch_board_with_measurer(&measurer, target_id); } pub fn switch_board_with_measurer(&mut self, measurer: &TextMeasurer, target_id: &str) { @@ -54,7 +55,8 @@ impl InputState { /// Switches to a different board without toggle semantics. pub fn switch_board_force(&mut self, target_id: &str) { - with_scoped_measurer(|measurer| self.switch_board_force_with_measurer(measurer, target_id)) + let measurer = TextMeasurer::default(); + self.switch_board_force_with_measurer(&measurer, target_id); } pub fn switch_board_force_with_measurer(&mut self, measurer: &TextMeasurer, target_id: &str) { @@ -88,7 +90,8 @@ impl InputState { } pub fn create_board(&mut self) -> bool { - with_scoped_measurer(|measurer| self.create_board_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.create_board_with_measurer(&measurer) } pub fn create_board_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { @@ -111,7 +114,8 @@ impl InputState { } pub fn switch_board_slot(&mut self, slot: usize) { - with_scoped_measurer(|measurer| self.switch_board_slot_with_measurer(measurer, slot)) + let measurer = TextMeasurer::default(); + self.switch_board_slot_with_measurer(&measurer, slot); } pub fn switch_board_slot_with_measurer(&mut self, measurer: &TextMeasurer, slot: usize) { @@ -125,7 +129,8 @@ impl InputState { } pub fn switch_board_next(&mut self) { - with_scoped_measurer(|measurer| self.switch_board_next_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.switch_board_next_with_measurer(&measurer); } pub fn switch_board_next_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -139,7 +144,8 @@ impl InputState { } pub fn switch_board_prev(&mut self) { - with_scoped_measurer(|measurer| self.switch_board_prev_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.switch_board_prev_with_measurer(&measurer); } pub fn switch_board_prev_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -154,7 +160,8 @@ impl InputState { /// Duplicate the active board. pub fn duplicate_board(&mut self) { - with_scoped_measurer(|measurer| self.duplicate_board_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.duplicate_board_with_measurer(&measurer); } pub fn duplicate_board_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -215,7 +222,8 @@ impl InputState { /// Switch to the most recently used board (other than the current one). pub fn switch_board_recent(&mut self) { - with_scoped_measurer(|measurer| self.switch_board_recent_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.switch_board_recent_with_measurer(&measurer); } pub fn switch_board_recent_with_measurer(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/board_picker/search.rs b/src/input/state/core/board_picker/search.rs index 546275458..6e855e95b 100644 --- a/src/input/state/core/board_picker/search.rs +++ b/src/input/state/core/board_picker/search.rs @@ -186,7 +186,7 @@ mod tests { #[test] fn board_picker_match_index_accepts_numeric_board_selection() { let mut state = make_state(); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!( state.board_picker_match_index("3"), @@ -197,7 +197,7 @@ mod tests { #[test] fn board_picker_match_index_trims_surrounding_whitespace() { let mut state = make_state(); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let blueprint_index = state .boards .board_states() @@ -214,7 +214,7 @@ mod tests { #[test] fn board_picker_match_index_rejects_whitespace_only_queries() { let mut state = make_state(); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!(state.board_picker_match_index(" \t "), None); } @@ -222,7 +222,7 @@ mod tests { #[test] fn board_picker_append_search_returns_focus_to_board_list() { let mut state = make_state(); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); state.board_picker_set_focus(BoardPickerFocus::PagePanel); assert_eq!(state.board_picker_focus(), BoardPickerFocus::PagePanel); @@ -235,7 +235,7 @@ mod tests { #[test] fn board_picker_append_search_resets_stale_query_before_appending() { let mut state = make_state(); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); state.board_picker.search = "old".to_string(); state.board_picker.search_last_input = Some(Instant::now() - BOARD_PICKER_SEARCH_TIMEOUT - Duration::from_millis(1)); diff --git a/src/input/state/core/board_picker/state/actions.rs b/src/input/state/core/board_picker/state/actions.rs index 2ccefd200..672aee9a8 100644 --- a/src/input/state/core/board_picker/state/actions.rs +++ b/src/input/state/core/board_picker/state/actions.rs @@ -17,12 +17,6 @@ impl InputState { !self.board_picker_is_quick() && index >= self.boards.board_count() } - pub(crate) fn board_picker_activate_row(&mut self, index: usize) { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_activate_row_with_measurer(measurer, index) - }) - } - pub(crate) fn board_picker_activate_row_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, @@ -39,12 +33,6 @@ impl InputState { } } - pub(crate) fn board_picker_activate_page(&mut self, page_index: usize) { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_activate_page_with_measurer(measurer, page_index) - }) - } - pub(crate) fn board_picker_activate_page_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, @@ -68,12 +56,6 @@ impl InputState { self.close_board_picker(); } - pub(crate) fn board_picker_add_page(&mut self) { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_add_page_with_measurer(measurer) - }) - } - pub(crate) fn board_picker_add_page_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, @@ -94,12 +76,6 @@ impl InputState { } } - pub(crate) fn board_picker_delete_page(&mut self, page_index: usize) -> PageDeleteOutcome { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_delete_page_with_measurer(measurer, page_index) - }) - } - pub(crate) fn board_picker_delete_page_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, @@ -115,12 +91,6 @@ impl InputState { outcome } - pub(crate) fn board_picker_duplicate_page(&mut self, page_index: usize) { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_duplicate_page_with_measurer(measurer, page_index) - }) - } - pub(crate) fn board_picker_duplicate_page_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, @@ -142,12 +112,6 @@ impl InputState { } } - pub(crate) fn board_picker_create_new(&mut self) { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_create_new_with_measurer(measurer) - }) - } - pub(crate) fn board_picker_create_new_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, @@ -172,12 +136,6 @@ impl InputState { self.needs_redraw = true; } - pub(crate) fn board_picker_delete_selected(&mut self) { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_delete_selected_with_measurer(measurer) - }) - } - pub(crate) fn board_picker_delete_selected_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, diff --git a/src/input/state/core/board_picker/state/drag.rs b/src/input/state/core/board_picker/state/drag.rs index 6b6fac85e..1cccae9d4 100644 --- a/src/input/state/core/board_picker/state/drag.rs +++ b/src/input/state/core/board_picker/state/drag.rs @@ -159,12 +159,6 @@ impl InputState { true } - pub(crate) fn board_picker_finish_page_drag(&mut self) -> bool { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_finish_page_drag_with_measurer(measurer) - }) - } - pub(crate) fn board_picker_finish_page_drag_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, diff --git a/src/input/state/core/board_picker/state/edit.rs b/src/input/state/core/board_picker/state/edit.rs index 44cab41de..1720151e5 100644 --- a/src/input/state/core/board_picker/state/edit.rs +++ b/src/input/state/core/board_picker/state/edit.rs @@ -68,12 +68,6 @@ impl InputState { self.needs_redraw = true; } - pub(crate) fn board_picker_commit_page_edit(&mut self) -> bool { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_commit_page_edit_with_measurer(measurer) - }) - } - pub(crate) fn board_picker_commit_page_edit_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, @@ -267,12 +261,6 @@ impl InputState { self.needs_redraw = true; } - pub(crate) fn board_picker_rename_selected(&mut self) { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_rename_selected_with_measurer(measurer) - }) - } - pub(crate) fn board_picker_rename_selected_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, @@ -295,12 +283,6 @@ impl InputState { } } - pub(crate) fn board_picker_edit_color_selected(&mut self) { - crate::draw::with_scoped_measurer(|measurer| { - self.board_picker_edit_color_selected_with_measurer(measurer) - }) - } - pub(crate) fn board_picker_edit_color_selected_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, diff --git a/src/input/state/core/board_picker/state/lifecycle.rs b/src/input/state/core/board_picker/state/lifecycle.rs index cf314f424..95c58a3d7 100644 --- a/src/input/state/core/board_picker/state/lifecycle.rs +++ b/src/input/state/core/board_picker/state/lifecycle.rs @@ -1,6 +1,6 @@ use super::super::super::base::InputState; use super::super::{BoardPickerFocus, BoardPickerMode, BoardPickerPageNavMode, BoardPickerState}; -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; impl InputState { pub(crate) fn is_board_picker_open(&self) -> bool { @@ -15,18 +15,10 @@ impl InputState { self.board_picker_mode() == BoardPickerMode::Quick } - pub(crate) fn open_board_picker(&mut self) { - with_scoped_measurer(|measurer| self.open_board_picker_with_measurer(measurer)) - } - pub(crate) fn open_board_picker_with_measurer(&mut self, measurer: &TextMeasurer) { self.open_board_picker_in_mode(measurer, BoardPickerMode::Full); } - pub(crate) fn open_board_picker_quick(&mut self) { - with_scoped_measurer(|measurer| self.open_board_picker_quick_with_measurer(measurer)) - } - pub(crate) fn open_board_picker_quick_with_measurer(&mut self, measurer: &TextMeasurer) { self.open_board_picker_in_mode(measurer, BoardPickerMode::Quick); self.board_picker_select_recent(); @@ -54,10 +46,6 @@ impl InputState { self.needs_redraw = true; } - pub(crate) fn toggle_board_picker(&mut self) { - with_scoped_measurer(|measurer| self.toggle_board_picker_with_measurer(measurer)) - } - pub(crate) fn toggle_board_picker_with_measurer(&mut self, measurer: &TextMeasurer) { if self.is_board_picker_open() { self.close_board_picker(); @@ -66,10 +54,6 @@ impl InputState { } } - pub(crate) fn toggle_board_picker_quick(&mut self) { - with_scoped_measurer(|measurer| self.toggle_board_picker_quick_with(measurer)) - } - pub(crate) fn toggle_board_picker_quick_with(&mut self, measurer: &TextMeasurer) { if self.is_board_picker_open() { self.close_board_picker(); @@ -344,13 +328,13 @@ mod tests { let mut state = make_test_input_state(); state.open_radial_menu(320.0, 240.0); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert!(state.is_board_picker_open()); assert!(!state.is_radial_menu_open()); state.close_board_picker(); state.open_radial_menu(320.0, 240.0); - state.open_board_picker_quick(); + state.open_board_picker_quick_with_measurer(&crate::draw::TextMeasurer::default()); assert!(state.is_board_picker_open()); assert!(state.board_picker_is_quick()); assert!(!state.is_radial_menu_open()); diff --git a/src/input/state/core/color_picker_popup/state.rs b/src/input/state/core/color_picker_popup/state.rs index acbc34b0b..d2693647f 100644 --- a/src/input/state/core/color_picker_popup/state.rs +++ b/src/input/state/core/color_picker_popup/state.rs @@ -1,6 +1,6 @@ //! Color picker popup state methods for InputState. -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; use std::borrow::Cow; use crate::draw::Color; @@ -47,7 +47,8 @@ impl InputState { /// Opens the color picker popup with the current color. pub fn open_color_picker_popup(&mut self) { - with_scoped_measurer(|measurer| self.open_color_picker_popup_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.open_color_picker_popup_with_measurer(&measurer); } pub fn open_color_picker_popup_with_measurer(&mut self, measurer: &TextMeasurer) { @@ -61,9 +62,8 @@ impl InputState { /// index is past the palette — a stale click on a snapshot rendered before /// the palette shrank, which must open nothing. pub fn open_color_picker_popup_for_quick_color(&mut self, index: usize) -> bool { - with_scoped_measurer(|measurer| { - self.open_color_picker_popup_for_quick_color_with_measurer(measurer, index) - }) + let measurer = TextMeasurer::default(); + self.open_color_picker_popup_for_quick_color_with_measurer(&measurer, index) } pub fn open_color_picker_popup_for_quick_color_with_measurer( diff --git a/src/input/state/core/command_palette/input.rs b/src/input/state/core/command_palette/input.rs index f2b7c2f79..f2631d4dd 100644 --- a/src/input/state/core/command_palette/input.rs +++ b/src/input/state/core/command_palette/input.rs @@ -485,15 +485,18 @@ impl InputState { screen_width: u32, screen_height: u32, ) -> bool { - crate::input::state::with_scoped_text_resources(|resources| { - self.handle_command_palette_click_with_resources( - resources, - x, - y, - screen_width, - screen_height, - ) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.handle_command_palette_click_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + x, + y, + screen_width, + screen_height, + ) } pub(crate) fn handle_command_palette_click_with_resources( diff --git a/src/input/state/core/highlight_controls.rs b/src/input/state/core/highlight_controls.rs index 8c7ef5dd4..690e98910 100644 --- a/src/input/state/core/highlight_controls.rs +++ b/src/input/state/core/highlight_controls.rs @@ -1,6 +1,6 @@ use super::base::{DrawingState, InputState}; use super::history_limits::HistoryMode; -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; use crate::input::tool::Tool; use cairo::Context as CairoContext; use std::time::Instant; @@ -133,7 +133,8 @@ impl InputState { /// Sets highlight-only tool mode on/off and keeps click highlight in sync. pub fn set_highlight_tool(&mut self, enable: bool) { - with_scoped_measurer(|measurer| self.set_highlight_tool_with_measurer(measurer, enable)) + let measurer = TextMeasurer::default(); + self.set_highlight_tool_with_measurer(&measurer, enable) } pub fn set_highlight_tool_with_measurer(&mut self, measurer: &TextMeasurer, enable: bool) { @@ -162,7 +163,8 @@ impl InputState { /// Toggles the combined highlight tool and click highlight together. pub fn toggle_all_highlights(&mut self) -> bool { - with_scoped_measurer(|measurer| self.toggle_all_highlights_with_measurer(measurer)) + let measurer = TextMeasurer::default(); + self.toggle_all_highlights_with_measurer(&measurer) } pub fn toggle_all_highlights_with_measurer(&mut self, measurer: &TextMeasurer) -> bool { @@ -234,6 +236,15 @@ impl InputState { /// Advance delayed history playback; returns true if a step was applied. pub fn tick_delayed_history(&mut self, now: Instant) -> bool { + let measurer = TextMeasurer::default(); + self.tick_delayed_history_with(&measurer, now) + } + + pub(crate) fn tick_delayed_history_with( + &mut self, + measurer: &TextMeasurer, + now: Instant, + ) -> bool { let Some(mode) = self.history_limits.due_mode(now) else { return false; }; @@ -243,7 +254,7 @@ impl InputState { }; let did_step = action.is_some(); if let Some(action) = action { - self.apply_action_side_effects(&action); + self.apply_action_side_effects_with(measurer, &action); } self.history_limits.finish_due_step(now, did_step); if self.history_limits.has_pending() { diff --git a/src/input/state/core/history.rs b/src/input/state/core/history.rs index 0910b7a07..a742ecdc0 100644 --- a/src/input/state/core/history.rs +++ b/src/input/state/core/history.rs @@ -1,11 +1,12 @@ use super::base::InputState; +use crate::draw::TextMeasurer; use crate::draw::frame::UndoAction; -use crate::draw::{TextMeasurer, with_scoped_measurer}; impl InputState { /// Applies side effects after an undoable action mutates the frame. pub fn apply_action_side_effects(&mut self, action: &UndoAction) { - with_scoped_measurer(|measurer| self.apply_action_side_effects_with(measurer, action)); + let measurer = TextMeasurer::default(); + self.apply_action_side_effects_with(&measurer, action); } pub fn apply_action_side_effects_with(&mut self, measurer: &TextMeasurer, action: &UndoAction) { diff --git a/src/input/state/core/ime.rs b/src/input/state/core/ime.rs index 3af7b9252..bf8389a16 100644 --- a/src/input/state/core/ime.rs +++ b/src/input/state/core/ime.rs @@ -383,7 +383,8 @@ impl InputState { } pub fn ime_apply_done(&mut self) -> bool { - crate::draw::with_scoped_measurer(|measurer| self.ime_apply_done_with(measurer)) + let measurer = crate::draw::TextMeasurer::default(); + self.ime_apply_done_with(&measurer) } pub(crate) fn ime_apply_done_with(&mut self, measurer: &crate::draw::TextMeasurer) -> bool { @@ -396,7 +397,8 @@ impl InputState { } pub fn ime_clear(&mut self) -> bool { - crate::draw::with_scoped_measurer(|measurer| self.ime_clear_with(measurer)) + let measurer = crate::draw::TextMeasurer::default(); + self.ime_clear_with(&measurer) } pub(crate) fn ime_clear_with(&mut self, measurer: &crate::draw::TextMeasurer) -> bool { diff --git a/src/input/state/core/index.rs b/src/input/state/core/index.rs index 0200917ef..6ad3457c2 100644 --- a/src/input/state/core/index.rs +++ b/src/input/state/core/index.rs @@ -4,7 +4,7 @@ mod grid; mod owner; use super::base::InputState; -use crate::draw::{ShapeId, TextMeasurer, with_scoped_measurer}; +use crate::draw::{ShapeId, TextMeasurer}; use owner::ActiveFrameOrderGuard; pub(in crate::input::state) use owner::CanvasIndex; #[cfg(test)] @@ -63,7 +63,8 @@ impl InputState { /// Instead of invalidating the entire spatial index, this method updates /// only the affected cells, providing O(1) amortized updates instead of O(n). pub fn invalidate_hit_cache_for(&mut self, id: ShapeId) { - with_scoped_measurer(|measurer| self.invalidate_hit_cache_for_with(measurer, id)) + let measurer = TextMeasurer::default(); + self.invalidate_hit_cache_for_with(&measurer, id); } /// Refreshes one shape in the index using the supplied text measurements. @@ -127,7 +128,8 @@ impl InputState { /// Performs hit-testing against the active frame and returns the top-most shape id. pub fn hit_test_at(&mut self, x: i32, y: i32) -> Option { - with_scoped_measurer(|measurer| self.hit_test_at_with(measurer, x, y)) + let measurer = TextMeasurer::default(); + self.hit_test_at_with(&measurer, x, y) } /// Finds the topmost shape using the supplied canonical text measurements. diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index 884693d87..8fb102649 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -80,9 +80,15 @@ impl InputState { } pub fn execute_menu_command(&mut self, command: MenuCommand) { - crate::input::state::with_scoped_text_resources(|resources| { - self.execute_menu_command_with_resources(resources, command) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.execute_menu_command_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + command, + ); } pub(crate) fn execute_menu_command_with_resources( diff --git a/src/input/state/core/menus/lifecycle.rs b/src/input/state/core/menus/lifecycle.rs index 57863236f..dab451f31 100644 --- a/src/input/state/core/menus/lifecycle.rs +++ b/src/input/state/core/menus/lifecycle.rs @@ -1,7 +1,7 @@ use super::super::base::InputState; use super::types::{ContextMenuKind, MenuCommand}; use crate::draw::ShapeId; -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; impl InputState { /// Closes the currently open context menu. @@ -58,7 +58,8 @@ impl InputState { } pub fn toggle_context_menu_via_keyboard(&mut self) { - with_scoped_measurer(|measurer| self.toggle_context_menu_via_keyboard_with(measurer)) + let measurer = TextMeasurer::default(); + self.toggle_context_menu_via_keyboard_with(&measurer); } pub fn toggle_context_menu_via_keyboard_with(&mut self, measurer: &TextMeasurer) { diff --git a/src/input/state/core/radial_menu/state.rs b/src/input/state/core/radial_menu/state.rs index 3ee2d255a..9dc68ccaa 100644 --- a/src/input/state/core/radial_menu/state.rs +++ b/src/input/state/core/radial_menu/state.rs @@ -139,9 +139,12 @@ impl InputState { /// Select the currently hovered segment and close the menu. pub fn radial_menu_select_hovered(&mut self) { - crate::input::state::with_scoped_text_resources(|resources| { - self.radial_menu_select_hovered_with_resources(resources) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.radial_menu_select_hovered_with_resources(crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }); } pub(crate) fn radial_menu_select_hovered_with_resources( @@ -182,9 +185,8 @@ impl InputState { /// Adjust thickness via scroll wheel while the menu is open. pub fn radial_menu_adjust_thickness(&mut self, delta: f64) -> bool { - crate::draw::with_scoped_measurer(|measurer| { - self.radial_menu_adjust_thickness_with_measurer(measurer, delta) - }) + let measurer = crate::draw::TextMeasurer::default(); + self.radial_menu_adjust_thickness_with_measurer(&measurer, delta) } pub(crate) fn radial_menu_adjust_thickness_with_measurer( diff --git a/src/input/state/core/selection_actions/clipboard.rs b/src/input/state/core/selection_actions/clipboard.rs index 6a9406836..b2491b6c4 100644 --- a/src/input/state/core/selection_actions/clipboard.rs +++ b/src/input/state/core/selection_actions/clipboard.rs @@ -1,8 +1,8 @@ use super::super::base::{ClipboardFingerprint, ClipboardPasteRequest, InputState, PasteAnchor}; use super::super::selection::LocalSelectionContext; use crate::draw::Shape; +use crate::draw::TextMeasurer; use crate::draw::frame::UndoAction; -use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::state::{Toast, ToastPriority}; use crate::util::Rect; @@ -160,16 +160,6 @@ impl InputState { self.selection_clipboard.mark_superseded(generation); } - pub(crate) fn paste_clipboard_shapes_from_request( - &mut self, - request: &ClipboardPasteRequest, - shapes: Vec, - ) -> usize { - with_scoped_measurer(|measurer| { - self.paste_clipboard_shapes_from_request_with(measurer, request, shapes) - }) - } - pub(crate) fn paste_clipboard_shapes_from_request_with( &mut self, measurer: &TextMeasurer, diff --git a/src/input/state/core/selection_actions/clipboard/duplicate.rs b/src/input/state/core/selection_actions/clipboard/duplicate.rs index e4fe588b4..ee02d7046 100644 --- a/src/input/state/core/selection_actions/clipboard/duplicate.rs +++ b/src/input/state/core/selection_actions/clipboard/duplicate.rs @@ -1,15 +1,11 @@ use super::super::super::base::InputState; +use crate::draw::TextMeasurer; use crate::draw::frame::UndoAction; -use crate::draw::{TextMeasurer, with_scoped_measurer}; const DUPLICATE_OFFSET: i32 = 12; #[allow(dead_code)] impl InputState { - pub(crate) fn duplicate_selection(&mut self) -> bool { - with_scoped_measurer(|measurer| self.duplicate_selection_with(measurer)) - } - pub(crate) fn duplicate_selection_with(&mut self, measurer: &TextMeasurer) -> bool { let ids_len = self.selected_shape_ids().len(); if ids_len == 0 { diff --git a/src/input/state/core/selection_actions/resize.rs b/src/input/state/core/selection_actions/resize.rs index a7f56a60a..1ce4d5e0a 100644 --- a/src/input/state/core/selection_actions/resize.rs +++ b/src/input/state/core/selection_actions/resize.rs @@ -1,8 +1,8 @@ //! Selection resize functionality. use crate::draw::ShapeId; +use crate::draw::TextMeasurer; use crate::draw::frame::ShapeSnapshot; -use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::InputState; use crate::input::state::core::base::SelectionHandle; use crate::util::Rect; @@ -15,7 +15,8 @@ const HANDLE_TOLERANCE: i32 = 4; impl InputState { /// Hit test for selection handles. Returns the handle if mouse is over one. pub fn hit_selection_handle(&self, x: i32, y: i32) -> Option { - with_scoped_measurer(|measurer| self.hit_selection_handle_with(measurer, x, y)) + let measurer = TextMeasurer::default(); + self.hit_selection_handle_with(&measurer, x, y) } /// Hit-tests selection handles using the supplied text measurement owner. diff --git a/src/input/state/core/selection_actions/translation/bounds.rs b/src/input/state/core/selection_actions/translation/bounds.rs index 3fe59030b..58d4a7fa3 100644 --- a/src/input/state/core/selection_actions/translation/bounds.rs +++ b/src/input/state/core/selection_actions/translation/bounds.rs @@ -1,11 +1,12 @@ -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; use crate::input::InputState; use crate::util::Rect; impl InputState { /// Returns the combined bounding box of all selected shapes (public for rendering). pub fn selection_bounds(&self) -> Option { - with_scoped_measurer(|measurer| self.selection_bounds_with(measurer)) + let measurer = TextMeasurer::default(); + self.selection_bounds_with(&measurer) } /// Combined selection bounds using the supplied text measurement owner. diff --git a/src/input/state/core/session.rs b/src/input/state/core/session.rs index a9f8827a8..a27d7264d 100644 --- a/src/input/state/core/session.rs +++ b/src/input/state/core/session.rs @@ -158,8 +158,9 @@ impl InputState { } #[allow(dead_code)] - pub(crate) fn with_active_interaction_canceled_for_capture( + pub(crate) fn with_active_interaction_canceled_for_capture_with( &mut self, + measurer: &crate::draw::TextMeasurer, capture: impl FnOnce(&Self) -> T, ) -> T { if !self.has_cancelable_session_capture_interaction() { @@ -167,7 +168,7 @@ impl InputState { } let rollback = ActiveInteractionRollback::capture(self); - self.cancel_active_interaction(); + self.cancel_active_interaction_with(measurer); if self.is_color_picker_popup_open() { self.close_color_picker_popup(true); } @@ -177,11 +178,12 @@ impl InputState { } /// Snapshot boards for persistence without writing in-progress edits as empty text. - pub(crate) fn snapshot_for_persistence( + pub(crate) fn snapshot_for_persistence_with( &mut self, + measurer: &crate::draw::TextMeasurer, options: &crate::session::SessionOptions, ) -> Option { - self.with_active_interaction_canceled_for_capture(|input| { + self.with_active_interaction_canceled_for_capture_with(measurer, |input| { crate::session::snapshot_from_input(input, options) }) } @@ -304,9 +306,12 @@ mod tests { assert!(state.has_pending_board_delete()); state.begin_pointer_drag(MouseButton::Left, None); - state.with_active_interaction_canceled_for_capture(|input| { - assert!(!input.has_active_pointer_interaction()); - }); + state.with_active_interaction_canceled_for_capture_with( + &crate::draw::TextMeasurer::default(), + |input| { + assert!(!input.has_active_pointer_interaction()); + }, + ); assert!(state.has_active_pointer_interaction()); state.delete_active_board_at_with_measurer( @@ -328,9 +333,12 @@ mod tests { })); state.begin_pointer_drag(MouseButton::Left, None); - state.with_active_interaction_canceled_for_capture(|input| { - assert!(!input.has_active_pointer_interaction()); - }); + state.with_active_interaction_canceled_for_capture_with( + &crate::draw::TextMeasurer::default(), + |input| { + assert!(!input.has_active_pointer_interaction()); + }, + ); assert!(state.text_block_drag_active()); assert!(state.pointer_drag_button_matches(MouseButton::Left)); @@ -371,7 +379,7 @@ mod tests { assert_eq!(first_snapshot_text(&live), ""); let persisted = state - .snapshot_for_persistence(&options) + .snapshot_for_persistence_with(&crate::draw::TextMeasurer::default(), &options) .expect("persistence snapshot"); assert_eq!(first_snapshot_text(&persisted), "Original"); diff --git a/src/input/state/core/status_hud.rs b/src/input/state/core/status_hud.rs index 6c85e8d36..ea13c9550 100644 --- a/src/input/state/core/status_hud.rs +++ b/src/input/state/core/status_hud.rs @@ -47,9 +47,10 @@ impl InputState { } } - pub(crate) fn set_status_bar_item_visible_with_engine( + pub(crate) fn set_status_bar_item_visible_with_resources( &mut self, engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, item: StatusBarItem, visible: bool, ) -> bool { @@ -71,7 +72,7 @@ impl InputState { StatusBarItem::Help => self.ui_visibility.show_status_help = visible, StatusBarItem::About => self.ui_visibility.show_status_about = visible, } - self.refresh_status_hud_layout_with_engine(engine); + self.refresh_status_hud_layout_with_resources(engine, measurer); self.needs_redraw = true; true } @@ -82,17 +83,21 @@ impl InputState { /// narrow outputs — between the mutation and the next frame, and hover is /// re-derived so a vanished segment cannot stay lit. Damage stays with /// the render effect pass, which re-measures with that frame's inputs. - pub(crate) fn refresh_status_hud_layout_with_engine( + pub(crate) fn refresh_status_hud_layout_with_resources( &mut self, engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, ) { let Some(inputs) = self.status_hud.rebuild_inputs() else { self.status_hud.layout = None; self.status_hud.hover = None; return; }; - self.update_status_hud_layout_for_pointer_with_engine( - engine, + self.update_status_hud_layout_for_pointer_with_resources( + crate::input::state::InputTextResources { + measurer, + ui_engine: engine, + }, inputs.position, &inputs.style, inputs.screen_width, @@ -114,7 +119,13 @@ impl InputState { screen_width: u32, screen_height: u32, ) { - self.update_status_hud_layout_for_pointer( + let engine = crate::ui_text::UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); + self.update_status_hud_layout_for_pointer_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }, position, style, screen_width, @@ -123,29 +134,9 @@ impl InputState { ); } - pub(crate) fn update_status_hud_layout_for_pointer( + pub(crate) fn update_status_hud_layout_for_pointer_with_resources( &mut self, - position: StatusPosition, - style: &StatusBarStyle, - screen_width: u32, - screen_height: u32, - chrome_cursor_focused: bool, - ) { - crate::ui_text::with_scoped_engine(|engine| { - self.update_status_hud_layout_for_pointer_with_engine( - engine, - position, - style, - screen_width, - screen_height, - chrome_cursor_focused, - ) - }); - } - - pub(crate) fn update_status_hud_layout_for_pointer_with_engine( - &mut self, - engine: &crate::ui_text::UiTextEngine, + resources: crate::input::state::InputTextResources<'_>, position: StatusPosition, style: &StatusBarStyle, screen_width: u32, @@ -153,8 +144,9 @@ impl InputState { chrome_cursor_focused: bool, ) { let layout = if self.ui_visibility.show_status_bar { - crate::ui::compute_status_hud_layout_with_engine( - engine, + crate::ui::compute_status_hud_layout_with_resources( + resources.ui_engine, + resources.measurer, self, position, style, diff --git a/src/input/state/core/tool_controls/presets.rs b/src/input/state/core/tool_controls/presets.rs index c8bbd33e3..1fcaf1657 100644 --- a/src/input/state/core/tool_controls/presets.rs +++ b/src/input/state/core/tool_controls/presets.rs @@ -4,7 +4,7 @@ use super::super::base::{ }; use super::super::default_step_marker_size; use crate::config::{PresetSlotsConfig, PresetToolStatesConfig, ToolPresetConfig}; -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; use crate::input::{DragModifier, tool::Tool}; use std::time::{Duration, Instant}; @@ -14,7 +14,8 @@ impl InputState { } pub fn apply_preset(&mut self, slot: usize) -> bool { - with_scoped_measurer(|measurer| self.apply_preset_with(measurer, slot)) + let measurer = TextMeasurer::default(); + self.apply_preset_with(&measurer, slot) } pub fn apply_preset_with(&mut self, measurer: &TextMeasurer, slot: usize) -> bool { diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index eb2931ea4..2e05cd7aa 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -1,6 +1,6 @@ use super::super::base::{DrawingState, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; +use crate::draw::TextMeasurer; use crate::draw::{ArrowStyle, BlurStyle, Color, FontDescriptor}; -use crate::draw::{TextMeasurer, with_scoped_measurer}; use crate::input::state::{Toast, ToastPriority}; use crate::input::{ DragBinding, MouseButton, @@ -144,7 +144,8 @@ impl InputState { /// Sets or clears an explicit tool override. Returns true if the tool changed. pub fn set_tool_override(&mut self, tool: Option) -> bool { - with_scoped_measurer(|measurer| self.set_tool_override_with(measurer, tool)) + let measurer = TextMeasurer::default(); + self.set_tool_override_with(&measurer, tool) } pub fn set_tool_override_with(&mut self, measurer: &TextMeasurer, tool: Option) -> bool { @@ -295,7 +296,8 @@ impl InputState { /// Sets thickness or eraser size depending on the active tool. pub fn set_thickness_for_active_tool(&mut self, value: f64) -> bool { - with_scoped_measurer(|measurer| self.set_thickness_for_active_tool_with(measurer, value)) + let measurer = TextMeasurer::default(); + self.set_thickness_for_active_tool_with(&measurer, value) } pub fn set_thickness_for_active_tool_with( @@ -316,7 +318,8 @@ impl InputState { /// Nudges thickness or eraser size depending on the active tool. pub fn nudge_thickness_for_active_tool(&mut self, delta: f64) -> bool { - with_scoped_measurer(|measurer| self.nudge_thickness_for_active_tool_with(measurer, delta)) + let measurer = TextMeasurer::default(); + self.nudge_thickness_for_active_tool_with(&measurer, delta) } pub fn nudge_thickness_for_active_tool_with( @@ -357,7 +360,8 @@ impl InputState { /// Sets the absolute thickness (px), clamped to valid bounds. Returns true if changed. pub fn set_thickness(&mut self, thickness: f64) -> bool { - with_scoped_measurer(|measurer| self.set_thickness_with(measurer, thickness)) + let measurer = TextMeasurer::default(); + self.set_thickness_with(&measurer, thickness) } pub fn set_thickness_with(&mut self, measurer: &TextMeasurer, thickness: f64) -> bool { @@ -381,7 +385,8 @@ impl InputState { /// Sets the absolute eraser size (px), clamped to valid bounds. Returns true if changed. pub fn set_eraser_size(&mut self, size: f64) -> bool { - with_scoped_measurer(|measurer| self.set_eraser_size_with(measurer, size)) + let measurer = TextMeasurer::default(); + self.set_eraser_size_with(&measurer, size) } pub fn set_eraser_size_with(&mut self, measurer: &TextMeasurer, size: f64) -> bool { diff --git a/src/input/state/core/tool_controls/toolbar.rs b/src/input/state/core/tool_controls/toolbar.rs index d5c658563..bb6ad6ad5 100644 --- a/src/input/state/core/tool_controls/toolbar.rs +++ b/src/input/state/core/tool_controls/toolbar.rs @@ -12,36 +12,38 @@ pub(crate) const CLEAR_UNDO_TOAST_MS: u64 = 2000; impl InputState { /// Sets toolbar visibility without changing its persisted pin. pub fn set_toolbar_visible(&mut self, visible: bool) -> bool { - crate::ui_text::with_scoped_engine(|engine| { - self.set_toolbar_visible_with_engine(engine, visible) - }) + let engine = crate::ui_text::UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); + self.set_toolbar_visible_with_resources(&engine, &measurer, visible) } - pub(crate) fn set_toolbar_visible_with_engine( + pub(crate) fn set_toolbar_visible_with_resources( &mut self, engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, visible: bool, ) -> bool { if !self.toolbar.set_visible(visible) { return false; } - self.refresh_status_hud_layout_with_engine(engine); + self.refresh_status_hud_layout_with_resources(engine, measurer); self.needs_redraw = true; true } /// Re-derive live visibility from the persisted pin without surfacing a /// toolbar hidden by a transient chrome owner. - pub(crate) fn derive_toolbar_visibility_from_pins_with_engine( + pub(crate) fn derive_toolbar_visibility_from_pins_with_resources( &mut self, engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, ) { let visible = self.toolbar.top_pinned(); if self.modes.retarget_visibility_from_pin(visible) { return; } self.toolbar.derive_visibility_from_pins(); - self.refresh_status_hud_layout_with_engine(engine); + self.refresh_status_hud_layout_with_resources(engine, measurer); } pub(crate) fn warn_if_all_chrome_hidden(&mut self) { @@ -233,29 +235,31 @@ impl InputState { } } - pub(crate) fn set_top_display_mode_with_engine( + pub(crate) fn set_top_display_mode_with_resources( &mut self, engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, mode: TopDisplayMode, ) { self.toolbar.set_top_display_mode(mode); - self.refresh_status_hud_layout_with_engine(engine); + self.refresh_status_hud_layout_with_resources(engine, measurer); self.needs_redraw = true; } pub fn cycle_top_toolbar_display(&mut self) -> TopDisplayMode { - crate::ui_text::with_scoped_engine(|engine| { - self.cycle_top_toolbar_display_with_engine(engine) - }) + let engine = crate::ui_text::UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); + self.cycle_top_toolbar_display_with_resources(&engine, &measurer) } - pub(crate) fn cycle_top_toolbar_display_with_engine( + pub(crate) fn cycle_top_toolbar_display_with_resources( &mut self, engine: &crate::ui_text::UiTextEngine, + measurer: &crate::draw::TextMeasurer, ) -> TopDisplayMode { let current = self.top_display_state(); let next = self.toolbar.cycle_top_display_mode(current); - self.refresh_status_hud_layout_with_engine(engine); + self.refresh_status_hud_layout_with_resources(engine, measurer); self.needs_redraw = true; next } @@ -426,9 +430,12 @@ impl InputState { /// Wrapper for undo that preserves existing action plumbing. pub fn toolbar_undo(&mut self) { - crate::input::state::with_scoped_text_resources(|resources| { - self.toolbar_undo_with_resources(resources) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.toolbar_undo_with_resources(crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }); } pub(crate) fn toolbar_undo_with_resources( @@ -440,9 +447,12 @@ impl InputState { /// Wrapper for redo that preserves existing action plumbing. pub fn toolbar_redo(&mut self) { - crate::input::state::with_scoped_text_resources(|resources| { - self.toolbar_redo_with_resources(resources) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.toolbar_redo_with_resources(crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }); } pub(crate) fn toolbar_redo_with_resources( @@ -454,9 +464,12 @@ impl InputState { /// Wrapper for clear that preserves existing action plumbing. pub fn toolbar_clear(&mut self) { - crate::input::state::with_scoped_text_resources(|resources| { - self.toolbar_clear_with_resources(resources) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.toolbar_clear_with_resources(crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }); } pub(crate) fn toolbar_clear_with_resources( @@ -470,9 +483,14 @@ impl InputState { /// were removed without a locked-shape warning, offers a short toast with /// an "Undo?" chip. The keyboard action and Shift+click stay instant. pub fn toolbar_clear_with_undo_toast(&mut self) { - crate::input::state::with_scoped_text_resources(|resources| { - self.toolbar_clear_with_undo_toast_with_resources(resources) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.toolbar_clear_with_undo_toast_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + ); } pub(crate) fn toolbar_clear_with_undo_toast_with_resources( @@ -502,9 +520,12 @@ impl InputState { /// Wrapper for entering text mode. pub fn toolbar_enter_text_mode(&mut self) { - crate::input::state::with_scoped_text_resources(|resources| { - self.toolbar_enter_text_mode_with_resources(resources) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.toolbar_enter_text_mode_with_resources(crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }); } pub(crate) fn toolbar_enter_text_mode_with_resources( @@ -516,9 +537,14 @@ impl InputState { /// Wrapper for entering sticky note mode. pub fn toolbar_enter_sticky_note_mode(&mut self) { - crate::input::state::with_scoped_text_resources(|resources| { - self.toolbar_enter_sticky_note_mode_with_resources(resources) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.toolbar_enter_sticky_note_mode_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + ); } pub(crate) fn toolbar_enter_sticky_note_mode_with_resources( diff --git a/src/input/state/core/toolbar/apply/layout.rs b/src/input/state/core/toolbar/apply/layout.rs index d888c340b..cadbd9454 100644 --- a/src/input/state/core/toolbar/apply/layout.rs +++ b/src/input/state/core/toolbar/apply/layout.rs @@ -62,9 +62,9 @@ impl InputState { } /// Set the top strip's display form (micro chip click → `Full`). - pub(super) fn apply_toolbar_set_top_display_mode_with_engine( + pub(super) fn apply_toolbar_set_top_display_mode_with_resources( &mut self, - engine: &crate::ui_text::UiTextEngine, + resources: crate::input::state::InputTextResources<'_>, mode: crate::config::TopDisplayMode, ) -> bool { // Same presenter gate as Action::CycleToolbarDisplay: while presenter @@ -78,7 +78,7 @@ impl InputState { if self.top_display_state() == mode { return false; } - self.set_top_display_mode_with_engine(engine, mode); + self.set_top_display_mode_with_resources(resources.ui_engine, resources.measurer, mode); true } @@ -205,9 +205,9 @@ impl InputState { } } - pub(super) fn apply_toolbar_toggle_status_bar_with_engine( + pub(super) fn apply_toolbar_toggle_status_bar_with_resources( &mut self, - engine: &crate::ui_text::UiTextEngine, + resources: crate::input::state::InputTextResources<'_>, show: bool, ) -> bool { if self.presenter_mode_active() && self.presenter_mode_config().hide_status_bar { @@ -218,7 +218,7 @@ impl InputState { self.break_focus_mode(); if self.ui_visibility.show_status_bar != show { self.ui_visibility.show_status_bar = show; - self.refresh_status_hud_layout_with_engine(engine); + self.refresh_status_hud_layout_with_resources(resources.ui_engine, resources.measurer); self.needs_redraw = true; true } else { @@ -236,34 +236,41 @@ impl InputState { true } - pub(super) fn apply_toolbar_set_status_bar_item_visible_with_engine( + pub(super) fn apply_toolbar_set_status_bar_item_visible_with_resources( &mut self, - engine: &crate::ui_text::UiTextEngine, + resources: crate::input::state::InputTextResources<'_>, item: crate::config::StatusBarItem, visible: bool, ) -> bool { - self.set_status_bar_item_visible_with_engine(engine, item, visible) + self.set_status_bar_item_visible_with_resources( + resources.ui_engine, + resources.measurer, + item, + visible, + ) } - pub(super) fn apply_toolbar_toggle_status_board_badge_with_engine( + pub(super) fn apply_toolbar_toggle_status_board_badge_with_resources( &mut self, - engine: &crate::ui_text::UiTextEngine, + resources: crate::input::state::InputTextResources<'_>, show: bool, ) -> bool { - self.set_status_bar_item_visible_with_engine( - engine, + self.set_status_bar_item_visible_with_resources( + resources.ui_engine, + resources.measurer, crate::config::StatusBarItem::Board, show, ) } - pub(super) fn apply_toolbar_toggle_status_page_badge_with_engine( + pub(super) fn apply_toolbar_toggle_status_page_badge_with_resources( &mut self, - engine: &crate::ui_text::UiTextEngine, + resources: crate::input::state::InputTextResources<'_>, show: bool, ) -> bool { - self.set_status_bar_item_visible_with_engine( - engine, + self.set_status_bar_item_visible_with_resources( + resources.ui_engine, + resources.measurer, crate::config::StatusBarItem::Page, show, ) @@ -774,6 +781,11 @@ mod tests { use crate::config::{StatusBarItem, StatusBarStyle, StatusPosition, TopDisplayMode}; use crate::ui_text::UiTextEngine; let engine = UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }; for event in [ ToolbarEvent::SetTopDisplayMode(TopDisplayMode::Micro), ToolbarEvent::ToggleStatusBar(false), @@ -784,8 +796,11 @@ mod tests { let mut explicit = make_test_input_state(); let mut legacy = make_test_input_state(); for input in [&mut explicit, &mut legacy] { - input.update_status_hud_layout_for_pointer_with_engine( - &engine, + input.update_status_hud_layout_for_pointer_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &engine, + }, StatusPosition::BottomLeft, &StatusBarStyle::default(), 1280, @@ -793,23 +808,24 @@ mod tests { true, ); } - let changed = match event.clone() { - ToolbarEvent::SetTopDisplayMode(mode) => { - explicit.apply_toolbar_set_top_display_mode_with_engine(&engine, mode) - } - ToolbarEvent::ToggleStatusBar(show) => { - explicit.apply_toolbar_toggle_status_bar_with_engine(&engine, show) - } - ToolbarEvent::SetStatusBarItemVisible(item, visible) => explicit - .apply_toolbar_set_status_bar_item_visible_with_engine(&engine, item, visible), - ToolbarEvent::ToggleStatusBoardBadge(show) => { - explicit.apply_toolbar_toggle_status_board_badge_with_engine(&engine, show) - } - ToolbarEvent::ToggleStatusPageBadge(show) => { - explicit.apply_toolbar_toggle_status_page_badge_with_engine(&engine, show) - } - _ => unreachable!(), - }; + let changed = + match event.clone() { + ToolbarEvent::SetTopDisplayMode(mode) => { + explicit.apply_toolbar_set_top_display_mode_with_resources(resources, mode) + } + ToolbarEvent::ToggleStatusBar(show) => { + explicit.apply_toolbar_toggle_status_bar_with_resources(resources, show) + } + ToolbarEvent::SetStatusBarItemVisible(item, visible) => explicit + .apply_toolbar_set_status_bar_item_visible_with_resources( + resources, item, visible, + ), + ToolbarEvent::ToggleStatusBoardBadge(show) => explicit + .apply_toolbar_toggle_status_board_badge_with_resources(resources, show), + ToolbarEvent::ToggleStatusPageBadge(show) => explicit + .apply_toolbar_toggle_status_page_badge_with_resources(resources, show), + _ => unreachable!(), + }; assert_eq!(changed, legacy.apply_toolbar_event(event)); assert_eq!( explicit.ui_visibility.show_status_bar, diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index 1c8465c41..d0a5481cf 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -14,9 +14,15 @@ impl InputState { /// /// Returns true if the event resulted in a state change. pub fn apply_toolbar_event(&mut self, event: ToolbarEvent) -> bool { - crate::input::state::with_scoped_text_resources(|resources| { - self.apply_toolbar_event_with_resources(resources, event) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.apply_toolbar_event_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + event, + ) } pub(crate) fn apply_toolbar_event_with_resources( @@ -216,7 +222,7 @@ impl InputState { self.apply_toolbar_set_top_minimized(minimized) } ToolbarEvent::SetTopDisplayMode(mode) => { - self.apply_toolbar_set_top_display_mode_with_engine(resources.ui_engine, mode) + self.apply_toolbar_set_top_display_mode_with_resources(resources, mode) } ToolbarEvent::CloseTopToolbar => self.apply_toolbar_set_top_minimized(true), ToolbarEvent::PinTopToolbar(pin) => self.apply_toolbar_pin_top_toolbar(pin), @@ -267,22 +273,18 @@ impl InputState { ToolbarEvent::ToggleIdleFade(enable) => self.apply_toolbar_toggle_idle_fade(enable), ToolbarEvent::ToggleToolPreview(show) => self.apply_toolbar_toggle_tool_preview(show), ToolbarEvent::ToggleStatusBar(show) => { - self.apply_toolbar_toggle_status_bar_with_engine(resources.ui_engine, show) + self.apply_toolbar_toggle_status_bar_with_resources(resources, show) } ToolbarEvent::SetStatusBarInteractive(interactive) => { self.apply_toolbar_set_status_bar_interactive(interactive) } ToolbarEvent::SetStatusBarItemVisible(item, visible) => self - .apply_toolbar_set_status_bar_item_visible_with_engine( - resources.ui_engine, - item, - visible, - ), + .apply_toolbar_set_status_bar_item_visible_with_resources(resources, item, visible), ToolbarEvent::ToggleStatusBoardBadge(show) => { - self.apply_toolbar_toggle_status_board_badge_with_engine(resources.ui_engine, show) + self.apply_toolbar_toggle_status_board_badge_with_resources(resources, show) } ToolbarEvent::ToggleStatusPageBadge(show) => { - self.apply_toolbar_toggle_status_page_badge_with_engine(resources.ui_engine, show) + self.apply_toolbar_toggle_status_page_badge_with_resources(resources, show) } ToolbarEvent::ToggleFloatingBadgeAlways(show) => { self.apply_toolbar_toggle_floating_badge_always(show) diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs index 1cb598296..e71fa2526 100644 --- a/src/input/state/core/tour.rs +++ b/src/input/state/core/tour.rs @@ -2,7 +2,7 @@ use crate::domain::Action; use crate::input::events::Key; -use crate::input::state::{InputTextResources, with_scoped_text_resources}; +use crate::input::state::InputTextResources; use super::base::InputState; @@ -297,7 +297,12 @@ impl InputState { /// Start the guided tour. pub fn start_tour(&mut self) { - with_scoped_text_resources(|resources| self.start_tour_with_resources(resources)) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.start_tour_with_resources(InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }); } pub(crate) fn start_tour_with_resources(&mut self, resources: InputTextResources<'_>) { @@ -318,7 +323,12 @@ impl InputState { /// starts the overlay regardless of the persisted `tour_shown` flag — and /// so a future replay-specific behavior has a single call site to hang on. pub fn start_tour_replay(&mut self) { - with_scoped_text_resources(|resources| self.start_tour_replay_with_resources(resources)) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.start_tour_replay_with_resources(InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }); } pub(crate) fn start_tour_replay_with_resources(&mut self, resources: InputTextResources<'_>) { diff --git a/src/input/state/core/utility/focus_mode.rs b/src/input/state/core/utility/focus_mode.rs index 94e7b89be..472a90fe9 100644 --- a/src/input/state/core/utility/focus_mode.rs +++ b/src/input/state/core/utility/focus_mode.rs @@ -120,7 +120,7 @@ impl InputState { || self.fallback_mode_badge_may_be_active(); if !anything_to_hide { self.clear_all_chrome_recovery_toast(); - self.set_toolbar_visible_with_engine(resources.ui_engine, true); + self.set_toolbar_visible_with_resources(resources.ui_engine, resources.measurer, true); self.ui_visibility.show_status_bar = true; self.ui_visibility.show_floating_badge = true; self.ui_visibility.show_zoom_chip = true; diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index 74ca62e5f..1c0ae9cdf 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -1,6 +1,6 @@ use super::super::base::{DrawingState, InputState, PasteAnchor}; use crate::draw::DirtyRegionReport; -use crate::draw::{TextMeasurer, with_scoped_measurer}; +use crate::draw::TextMeasurer; use crate::util::Rect; use std::time::Instant; @@ -178,11 +178,6 @@ impl InputState { true } - /// Cancels any in-progress interaction without exiting the application. - pub(crate) fn cancel_active_interaction(&mut self) { - with_scoped_measurer(|measurer| self.cancel_active_interaction_with(measurer)) - } - pub(crate) fn cancel_active_interaction_with(&mut self, measurer: &TextMeasurer) { // A canceled interaction never leaves a dangling block-move drag. self.text_editing.set_text_block_drag(None); diff --git a/src/input/state/core/utility/light_mode.rs b/src/input/state/core/utility/light_mode.rs index 798d4c02c..5a896b05d 100644 --- a/src/input/state/core/utility/light_mode.rs +++ b/src/input/state/core/utility/light_mode.rs @@ -2,7 +2,7 @@ use super::super::base::{DesktopEnvironment, InputState, ShellMode}; use super::super::modes::LightModeRestore; use crate::domain::Action; use crate::draw::TextMeasurer; -use crate::input::state::{InputTextResources, with_scoped_text_resources}; +use crate::input::state::InputTextResources; use crate::input::state::{Toast, ToastPriority}; use crate::input::tool::Tool; @@ -54,18 +54,6 @@ impl InputState { .unwrap_or_else(|| self.active_tool()) } - pub(crate) fn toggle_light_mode_with_engine( - &mut self, - engine: &crate::ui_text::UiTextEngine, - ) -> bool { - crate::draw::with_scoped_measurer(|measurer| { - self.toggle_light_mode_with_resources(InputTextResources { - measurer, - ui_engine: engine, - }) - }) - } - pub(crate) fn toggle_light_mode_with_resources( &mut self, resources: InputTextResources<'_>, @@ -88,20 +76,11 @@ impl InputState { } pub fn toggle_light_mode_drawing(&mut self) -> bool { - with_scoped_text_resources(|resources| { - self.toggle_light_mode_drawing_with_resources(resources) - }) - } - - pub(crate) fn toggle_light_mode_drawing_with_engine( - &mut self, - engine: &crate::ui_text::UiTextEngine, - ) -> bool { - crate::draw::with_scoped_measurer(|measurer| { - self.toggle_light_mode_drawing_with_resources(InputTextResources { - measurer, - ui_engine: engine, - }) + let measurer = TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.toggle_light_mode_drawing_with_resources(InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, }) } @@ -118,25 +97,15 @@ impl InputState { } pub fn set_light_mode_drawing(&mut self, drawing: bool) -> bool { - with_scoped_text_resources(|resources| { - self.set_light_mode_drawing_with_resources(resources, drawing) - }) - } - - pub(crate) fn set_light_mode_drawing_with_engine( - &mut self, - engine: &crate::ui_text::UiTextEngine, - drawing: bool, - ) -> bool { - crate::draw::with_scoped_measurer(|measurer| { - self.set_light_mode_drawing_with_resources( - InputTextResources { - measurer, - ui_engine: engine, - }, - drawing, - ) - }) + let measurer = TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.set_light_mode_drawing_with_resources( + InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + drawing, + ) } pub(crate) fn set_light_mode_drawing_with_resources( diff --git a/src/input/state/core/utility/presenter_mode.rs b/src/input/state/core/utility/presenter_mode.rs index 2b855655d..ba69111c1 100644 --- a/src/input/state/core/utility/presenter_mode.rs +++ b/src/input/state/core/utility/presenter_mode.rs @@ -152,8 +152,9 @@ impl InputState { } crate::config::PresenterToolbarMode::Micro => { // The top strip stays up as the micro chip. - self.set_top_display_mode_with_engine( + self.set_top_display_mode_with_resources( resources.ui_engine, + resources.measurer, crate::config::TopDisplayMode::Micro, ); } diff --git a/src/input/state/core/zoom_chip.rs b/src/input/state/core/zoom_chip.rs index 0774e2ea0..737ad1336 100644 --- a/src/input/state/core/zoom_chip.rs +++ b/src/input/state/core/zoom_chip.rs @@ -52,25 +52,14 @@ impl InputState { screen_width: u32, screen_height: u32, ) { - self.update_zoom_chip_layout_for_pointer(style, screen_width, screen_height, true); - } - - pub(crate) fn update_zoom_chip_layout_for_pointer( - &mut self, - style: &StatusBarStyle, - screen_width: u32, - screen_height: u32, - chrome_cursor_focused: bool, - ) { - crate::ui_text::with_scoped_engine(|engine| { - self.update_zoom_chip_layout_for_pointer_with_engine( - engine, - style, - screen_width, - screen_height, - chrome_cursor_focused, - ) - }); + let engine = crate::ui_text::UiTextEngine::default(); + self.update_zoom_chip_layout_for_pointer_with_engine( + &engine, + style, + screen_width, + screen_height, + true, + ); } pub(crate) fn update_zoom_chip_layout_for_pointer_with_engine( diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 394527fb4..6ddfb4f17 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -15,7 +15,6 @@ pub(crate) use core::{InputEffect, InputEffectDrain}; pub(in crate::input::state) use spotlight::SpotlightWheelGesture; pub(crate) use spotlight::{SpotlightFrameRegions, SpotlightWheelClaim, SpotlightWheelOutcome}; pub(crate) use text_resources::InputTextResources; -pub(in crate::input::state) use text_resources::with_scoped_text_resources; #[cfg(test)] mod tests; diff --git a/src/input/state/mouse/motion.rs b/src/input/state/mouse/motion.rs index 306ace1af..2c888227f 100644 --- a/src/input/state/mouse/motion.rs +++ b/src/input/state/mouse/motion.rs @@ -26,11 +26,18 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { - crate::input::state::with_scoped_text_resources(|resources| { - self.on_mouse_motion_with_canvas_and_resources( - resources, screen_x, screen_y, canvas_x, canvas_y, - ) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.on_mouse_motion_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + screen_x, + screen_y, + canvas_x, + canvas_y, + ); } pub(crate) fn on_mouse_motion_with_canvas_and_resources( diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index f15d50366..57c515683 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -132,11 +132,19 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { - crate::input::state::with_scoped_text_resources(|resources| { - self.on_mouse_press_with_canvas_and_resources( - resources, button, screen_x, screen_y, canvas_x, canvas_y, - ) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.on_mouse_press_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + button, + screen_x, + screen_y, + canvas_x, + canvas_y, + ); } pub(crate) fn on_mouse_press_with_canvas_and_resources( diff --git a/src/input/state/mouse/release/mod.rs b/src/input/state/mouse/release/mod.rs index 481ba441a..77de4f5d5 100644 --- a/src/input/state/mouse/release/mod.rs +++ b/src/input/state/mouse/release/mod.rs @@ -37,11 +37,19 @@ impl InputState { canvas_x: i32, canvas_y: i32, ) { - crate::input::state::with_scoped_text_resources(|resources| { - self.on_mouse_release_with_canvas_and_resources( - resources, button, screen_x, screen_y, canvas_x, canvas_y, - ) - }) + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + self.on_mouse_release_with_canvas_and_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + button, + screen_x, + screen_y, + canvas_x, + canvas_y, + ); } pub(crate) fn on_mouse_release_with_canvas_and_resources( diff --git a/src/input/state/tests/board_picker.rs b/src/input/state/tests/board_picker.rs index 4f4a38bb2..1d23b4f02 100644 --- a/src/input/state/tests/board_picker.rs +++ b/src/input/state/tests/board_picker.rs @@ -40,7 +40,7 @@ fn apply_pending_board_pin(input: &mut crate::input::state::InputState) { #[test] fn board_picker_search_selects_transposed_match() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); for ch in "balckboard".chars() { input.board_picker_append_search(ch); } @@ -52,7 +52,7 @@ fn board_picker_search_selects_transposed_match() { #[test] fn board_picker_search_selects_prefix_match() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); for ch in "blue".chars() { input.board_picker_append_search(ch); } @@ -66,7 +66,7 @@ fn board_picker_selects_recent_board() { let mut input = create_test_input_state(); input.switch_board("blackboard"); input.switch_board("whiteboard"); - input.open_board_picker_quick(); + input.open_board_picker_quick_with_measurer(&crate::draw::TextMeasurer::default()); let selected = input.board_picker_selected_index().expect("selection"); let name = &input.boards.board_states()[selected].spec.name; assert_eq!(name, "Blackboard"); @@ -76,7 +76,7 @@ fn board_picker_selects_recent_board() { fn board_picker_quick_mode_hides_new_row() { let mut input = create_test_input_state(); let board_count = input.boards.board_count(); - input.open_board_picker_quick(); + input.open_board_picker_quick_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!(input.board_picker_row_count(), board_count); if board_count > 0 { assert!(!input.board_picker_is_new_row(board_count - 1)); @@ -93,12 +93,12 @@ fn board_picker_quick_mode_pins_board_to_top() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); input.switch_board("whiteboard"); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected(blackboard_index); input.board_picker_toggle_pin_selected(); apply_pending_board_pin(&mut input); - input.open_board_picker_quick(); - input.board_picker_activate_row(0); + input.open_board_picker_quick_with_measurer(&crate::draw::TextMeasurer::default()); + input.board_picker_activate_row_with_measurer(&crate::draw::TextMeasurer::default(), 0); assert_eq!(input.board_id(), "blackboard"); } @@ -112,19 +112,19 @@ fn board_picker_full_mode_pins_board_to_top() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); input.switch_board("whiteboard"); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected(blackboard_index); input.board_picker_toggle_pin_selected(); apply_pending_board_pin(&mut input); - input.open_board_picker(); - input.board_picker_activate_row(0); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); + input.board_picker_activate_row_with_measurer(&crate::draw::TextMeasurer::default(), 0); assert_eq!(input.board_id(), "blackboard"); } #[test] fn board_picker_drag_pinned_clamped_to_pinned_section() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let blackboard_index = input .boards .board_states() @@ -156,7 +156,7 @@ fn board_picker_drag_pinned_clamped_to_pinned_section() { #[test] fn board_picker_drag_unpinned_clamped_after_pinned() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let blackboard_index = input .boards .board_states() @@ -263,7 +263,7 @@ fn assert_page_visible(layout: crate::input::state::BoardPickerLayout, page_inde #[test] fn board_picker_page_hit_testing_uses_rendered_thumbnail_positions() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); let layout = *input.board_picker_layout().expect("layout"); @@ -300,7 +300,7 @@ fn board_picker_page_hit_testing_uses_rendered_thumbnail_positions() { #[test] fn board_picker_empty_page_list_has_no_page_hit() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -322,7 +322,7 @@ fn board_picker_empty_page_list_has_no_page_hit() { #[test] fn board_picker_add_card_clickable_when_pages_exactly_fill_rows() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); let board_index = input @@ -364,7 +364,7 @@ fn board_picker_add_card_clickable_when_pages_exactly_fill_rows() { #[test] fn board_picker_overflow_hitbox_matches_rendered_hint_position() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); let board_index = input @@ -394,7 +394,7 @@ fn board_picker_overflow_hitbox_matches_rendered_hint_position() { #[test] fn board_picker_page_ten_hit_testing_returns_absolute_index() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -438,7 +438,7 @@ fn board_picker_page_ten_hit_testing_returns_absolute_index() { #[test] fn board_picker_page_ten_operations_use_absolute_index() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -452,20 +452,20 @@ fn board_picker_page_ten_operations_use_absolute_index() { } update_picker_layout(&mut input, 1280, 720); - input.board_picker_duplicate_page(9); + input.board_picker_duplicate_page_with_measurer(&crate::draw::TextMeasurer::default(), 9); let pages = &input.boards.board_states()[board_index].pages; assert_eq!(pages.active_index(), 10); assert_eq!(pages.page_name(10), Some("Page 10")); assert_eq!(pages.page_name(9), Some("Page 10")); - input.board_picker_delete_page(9); + input.board_picker_delete_page_with_measurer(&crate::draw::TextMeasurer::default(), 9); assert_eq!( input.boards.board_states()[board_index].pages.page_count(), 13, "first delete should only request confirmation" ); - input.board_picker_delete_page(9); + input.board_picker_delete_page_with_measurer(&crate::draw::TextMeasurer::default(), 9); let pages = &input.boards.board_states()[board_index].pages; assert_eq!(pages.page_count(), 12); @@ -481,7 +481,7 @@ fn board_picker_active_page_ten_visible_on_open_and_focus() { .pages .switch_to_page(9); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); let layout = *input.board_picker_layout().expect("layout"); assert_page_visible(layout, 9); @@ -496,7 +496,7 @@ fn board_picker_active_page_ten_visible_on_open_and_focus() { #[test] fn board_picker_keyboard_focus_scrolls_absolute_page_into_view() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -515,7 +515,7 @@ fn board_picker_keyboard_focus_scrolls_absolute_page_into_view() { #[test] fn board_picker_sticky_add_works_when_visible_grid_is_full() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -529,7 +529,7 @@ fn board_picker_sticky_add_works_when_visible_grid_is_full() { assert!(input.board_picker_page_add_card_at(add_x, add_y)); - input.board_picker_add_page(); + input.board_picker_add_page_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); let pages = &input.boards.board_states()[board_index].pages; @@ -542,7 +542,7 @@ fn board_picker_sticky_add_works_when_visible_grid_is_full() { fn board_picker_ctrl_n_adds_page_while_page_panel_focused() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -568,18 +568,18 @@ fn board_picker_ctrl_n_adds_page_while_page_panel_focused() { #[test] fn board_picker_add_and_duplicate_scroll_to_newly_active_page() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); set_board_page_count(&mut input, board_index, 15); update_picker_layout(&mut input, 1280, 720); - input.board_picker_add_page(); + input.board_picker_add_page_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); assert_page_visible(*input.board_picker_layout().expect("layout"), 15); - input.board_picker_duplicate_page(15); + input.board_picker_duplicate_page_with_measurer(&crate::draw::TextMeasurer::default(), 15); update_picker_layout(&mut input, 1280, 720); assert_page_visible(*input.board_picker_layout().expect("layout"), 16); } @@ -587,7 +587,7 @@ fn board_picker_add_and_duplicate_scroll_to_newly_active_page() { #[test] fn board_picker_wheel_scroll_changes_visible_page_window() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -619,7 +619,7 @@ fn board_picker_wheel_scroll_changes_visible_page_window() { #[test] fn board_picker_repeated_wheel_scroll_uses_state_between_layouts() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -641,7 +641,7 @@ fn board_picker_repeated_wheel_scroll_uses_state_between_layouts() { #[test] fn board_picker_wheel_scroll_up_clamps_focus_to_last_visible_page() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -681,7 +681,7 @@ fn board_picker_wheel_scroll_up_clamps_focus_to_last_visible_page() { fn board_picker_page_search_wheel_scroll_syncs_cursor_with_visible_focus() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -737,7 +737,7 @@ fn board_picker_page_search_wheel_scroll_syncs_cursor_with_visible_focus() { fn board_picker_page_search_wheel_scroll_without_visible_match_clears_cursor() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -775,7 +775,7 @@ fn board_picker_page_search_wheel_scroll_without_visible_match_clears_cursor() { #[test] fn board_picker_column_change_keeps_focused_absolute_page_visible() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -798,7 +798,7 @@ fn board_picker_column_change_keeps_focused_absolute_page_visible() { fn board_picker_page_jump_focuses_absolute_page_and_scrolls_visible() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -828,7 +828,7 @@ fn board_picker_page_jump_focuses_absolute_page_and_scrolls_visible() { fn board_picker_page_jump_edges_keep_picker_open() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -874,7 +874,7 @@ fn board_picker_page_jump_edges_keep_picker_open() { fn board_picker_page_search_slash_starts_without_inserting_slash() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); @@ -891,7 +891,7 @@ fn board_picker_page_search_slash_starts_without_inserting_slash() { fn board_picker_selecting_current_board_row_clears_page_nav_mode() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); input.board_picker_set_focus(BoardPickerFocus::PagePanel); @@ -919,7 +919,7 @@ fn board_picker_selecting_current_board_row_clears_page_nav_mode() { fn board_picker_page_search_finds_named_page_beyond_visible_window() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -944,7 +944,7 @@ fn board_picker_page_search_finds_named_page_beyond_visible_window() { fn board_picker_page_search_numeric_is_exact_page_number() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -968,7 +968,7 @@ fn board_picker_page_search_numeric_is_exact_page_number() { fn board_picker_page_search_no_match_enter_is_noop() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -1002,7 +1002,7 @@ fn board_picker_page_search_no_match_enter_is_noop() { fn board_picker_page_search_f3_cycles_matches() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -1032,7 +1032,7 @@ fn board_picker_page_search_f3_cycles_matches() { fn board_picker_page_search_enter_opens_absolute_page_beyond_nine() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -1062,7 +1062,7 @@ fn board_picker_page_search_enter_opens_absolute_page_beyond_nine() { fn board_picker_page_search_rename_updates_derived_match() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -1081,7 +1081,9 @@ fn board_picker_page_search_rename_updates_derived_match() { page_index: 9, buffer: "Target".to_string(), }); - assert!(input.board_picker_commit_page_edit()); + assert!( + input.board_picker_commit_page_edit_with_measurer(&crate::draw::TextMeasurer::default()) + ); assert_eq!(input.board_picker_page_search_active_match(), Some(9)); assert_eq!(input.board_picker_page_focus_page_index(), Some(9)); @@ -1091,7 +1093,7 @@ fn board_picker_page_search_rename_updates_derived_match() { fn board_picker_page_search_pending_delete_preserves_confirmed_delete_clamps() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let board_index = input .board_picker_page_panel_board_index() .expect("page panel board index"); @@ -1112,14 +1114,14 @@ fn board_picker_page_search_pending_delete_preserves_confirmed_delete_clamps() { assert_eq!(input.board_picker_page_search_active_match(), Some(1)); assert_eq!( - input.board_picker_delete_page(1), + input.board_picker_delete_page_with_measurer(&crate::draw::TextMeasurer::default(), 1), PageDeleteOutcome::Pending ); assert_eq!(input.board_picker_page_search_cursor(), Some(1)); assert_eq!(input.board_picker_page_search_active_match(), Some(1)); assert_eq!( - input.board_picker_delete_page(1), + input.board_picker_delete_page_with_measurer(&crate::draw::TextMeasurer::default(), 1), PageDeleteOutcome::Removed ); assert_eq!(input.board_picker_page_search_cursor(), Some(0)); @@ -1130,7 +1132,7 @@ fn board_picker_page_search_pending_delete_preserves_confirmed_delete_clamps() { #[test] fn board_picker_row_action_hitboxes_match_rendered_positions() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); let layout = *input.board_picker_layout().expect("layout"); @@ -1162,7 +1164,7 @@ fn board_picker_row_action_hitboxes_match_rendered_positions() { #[test] fn board_picker_palette_hit_testing_uses_rendered_coordinates() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let solid_board_index = input .boards @@ -1174,7 +1176,7 @@ fn board_picker_palette_hit_testing_uses_rendered_coordinates() { .board_picker_row_for_board(solid_board_index) .expect("solid row"); input.board_picker_set_selected(solid_row); - input.board_picker_edit_color_selected(); + input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); let layout = *input.board_picker_layout().expect("layout"); @@ -1193,7 +1195,7 @@ fn board_picker_palette_hit_testing_uses_rendered_coordinates() { #[test] fn board_picker_page_focus_clamps_to_existing_pages() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); update_picker_layout(&mut input, 1280, 720); let board_index = input @@ -1214,7 +1216,7 @@ fn board_picker_page_focus_clamps_to_existing_pages() { #[test] fn board_picker_footer_text_prefers_active_search_query() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker.search = "blue".to_string(); assert_eq!( @@ -1227,13 +1229,13 @@ fn board_picker_footer_text_prefers_active_search_query() { fn board_picker_footer_text_changes_for_quick_and_page_panel_modes() { let route_measurer = crate::draw::TextMeasurer::default(); let mut input = create_test_input_state(); - input.open_board_picker_quick(); + input.open_board_picker_quick_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!( input.board_picker_footer_text(), "Enter: switch Type: jump Esc: close" ); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!( input.board_picker_footer_text(), "Enter: open F2: rename Ctrl+C: color Ctrl+N: new Del: delete" @@ -1278,14 +1280,14 @@ fn board_picker_title_and_recent_label_reflect_mode_and_recent_boards() { "transparent".to_string(), ]); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!(input.board_picker_title(3, 8), "Boards (3/8)"); assert_eq!( input.board_picker_recent_label(), Some("Recent: Blackboard, Overlay".to_string()) ); - input.open_board_picker_quick(); + input.open_board_picker_quick_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!(input.board_picker_title(3, 8), "Switch board"); } @@ -1299,12 +1301,12 @@ fn board_picker_rename_selected_promotes_quick_mode_to_full_editing() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); - input.open_board_picker_quick(); + input.open_board_picker_quick_with_measurer(&crate::draw::TextMeasurer::default()); let selected_row = input .board_picker_row_for_board(blackboard_index) .expect("blackboard row"); input.board_picker_set_selected(selected_row); - input.board_picker_rename_selected(); + input.board_picker_rename_selected_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!(input.board_picker_mode(), BoardPickerMode::Full); assert_eq!( @@ -1324,7 +1326,7 @@ fn board_picker_f2_starts_board_name_edit_not_color_edit() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let selected_row = input .board_picker_row_for_board(blackboard_index) .expect("blackboard row"); @@ -1347,7 +1349,7 @@ fn board_picker_f2_key_route_starts_board_name_edit_not_color_edit() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let selected_row = input .board_picker_row_for_board(blackboard_index) .expect("blackboard row"); @@ -1371,12 +1373,12 @@ fn board_picker_f2_switches_color_edit_back_to_name_edit() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let selected_row = input .board_picker_row_for_board(blackboard_index) .expect("blackboard row"); input.board_picker_set_selected(selected_row); - input.board_picker_edit_color_selected(); + input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!( input.board_picker_edit_state(), Some((BoardPickerEditMode::Color, selected_row, "#111111")) @@ -1401,7 +1403,7 @@ fn board_picker_ctrl_c_starts_board_color_edit() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let selected_row = input .board_picker_row_for_board(blackboard_index) .expect("blackboard row"); @@ -1425,13 +1427,13 @@ fn board_picker_edit_color_selected_shows_info_toast_for_transparent_board() { .position(|board| board.spec.background.is_transparent()) .expect("transparent board"); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected( input .board_picker_row_for_board(transparent_index) .expect("transparent row"), ); - input.board_picker_edit_color_selected(); + input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); assert!(input.board_picker_edit_state().is_none()); assert_eq!( @@ -1450,7 +1452,7 @@ fn board_picker_commit_edit_rejects_invalid_colors_and_keeps_edit_open() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let selected_row = input .board_picker_row_for_board(blackboard_index) .expect("blackboard row"); @@ -1490,7 +1492,7 @@ fn open_board_picker_closes_help_and_clears_transient_picker_state() { buffer: "Draft".to_string(), }); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert!(input.pending_onboarding_usage.used_board_picker); assert!(input.is_board_picker_open()); @@ -1510,7 +1512,7 @@ fn open_board_picker_closes_help_and_clears_transient_picker_state() { #[test] fn close_board_picker_clears_transient_picker_state() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker.search = "blue".to_string(); input.board_picker.drag = Some(BoardPickerDrag { source_row: 0, @@ -1543,7 +1545,7 @@ fn close_board_picker_clears_transient_picker_state() { #[test] fn board_picker_active_index_prefers_hover_over_selected_row() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected(0); if let BoardPickerState::Open { hover_index, .. } = &mut input.board_picker.state { @@ -1556,7 +1558,7 @@ fn board_picker_active_index_prefers_hover_over_selected_row() { #[test] fn board_picker_page_panel_board_index_falls_back_to_active_board_for_new_row() { let mut input = create_test_input_state(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected(input.boards.board_count()); assert_eq!( @@ -1569,11 +1571,11 @@ fn board_picker_page_panel_board_index_falls_back_to_active_board_for_new_row() fn toggle_board_picker_quick_opens_quick_mode_and_closes_on_second_toggle() { let mut input = create_test_input_state(); - input.toggle_board_picker_quick(); + input.toggle_board_picker_quick_with(&crate::draw::TextMeasurer::default()); assert!(input.is_board_picker_open()); assert_eq!(input.board_picker_mode(), BoardPickerMode::Quick); - input.toggle_board_picker_quick(); + input.toggle_board_picker_quick_with(&crate::draw::TextMeasurer::default()); assert!(!input.is_board_picker_open()); } @@ -1581,9 +1583,12 @@ fn toggle_board_picker_quick_opens_quick_mode_and_closes_on_second_toggle() { fn board_picker_activate_row_on_new_row_creates_board_and_starts_editing() { let mut input = create_test_input_state(); let initial_count = input.boards.board_count(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); - input.board_picker_activate_row(initial_count); + input.board_picker_activate_row_with_measurer( + &crate::draw::TextMeasurer::default(), + initial_count, + ); let active_row = input .board_picker_row_for_board(input.boards.active_index()) @@ -1609,14 +1614,14 @@ fn board_picker_activate_page_switches_board_page_and_closes_picker() { .position(|board| board.spec.id == "whiteboard") .expect("whiteboard board"); set_board_page_count(&mut input, whiteboard_index, 2); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected( input .board_picker_row_for_board(whiteboard_index) .expect("whiteboard row"), ); - input.board_picker_activate_page(1); + input.board_picker_activate_page_with_measurer(&crate::draw::TextMeasurer::default(), 1); assert_eq!(input.board_id(), "whiteboard"); assert_eq!(input.boards.active_board().pages.active_index(), 1); @@ -1633,14 +1638,14 @@ fn board_picker_activate_page_ignores_out_of_range_indices() { .position(|board| board.spec.id == "whiteboard") .expect("whiteboard board"); set_board_page_count(&mut input, whiteboard_index, 1); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected( input .board_picker_row_for_board(whiteboard_index) .expect("whiteboard row"), ); - input.board_picker_activate_page(5); + input.board_picker_activate_page_with_measurer(&crate::draw::TextMeasurer::default(), 5); assert_eq!(input.board_id(), "transparent"); assert!(input.is_board_picker_open()); @@ -1650,9 +1655,9 @@ fn board_picker_activate_page_ignores_out_of_range_indices() { fn board_picker_create_new_from_quick_mode_promotes_to_full_and_starts_editing() { let mut input = create_test_input_state(); let initial_count = input.boards.board_count(); - input.open_board_picker_quick(); + input.open_board_picker_quick_with_measurer(&crate::draw::TextMeasurer::default()); - input.board_picker_create_new(); + input.board_picker_create_new_with_measurer(&crate::draw::TextMeasurer::default()); let active_row = input .board_picker_row_for_board(input.boards.active_index()) @@ -1679,14 +1684,14 @@ fn board_picker_duplicate_page_uses_selected_page_panel_board() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); set_board_page_count(&mut input, blackboard_index, 1); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected( input .board_picker_row_for_board(blackboard_index) .expect("blackboard row"), ); - input.board_picker_duplicate_page(0); + input.board_picker_duplicate_page_with_measurer(&crate::draw::TextMeasurer::default(), 0); assert_eq!( input.boards.board_states()[blackboard_index] @@ -1706,14 +1711,14 @@ fn board_picker_add_page_uses_selected_page_panel_board() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); set_board_page_count(&mut input, blackboard_index, 1); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected( input .board_picker_row_for_board(blackboard_index) .expect("blackboard row"), ); - input.board_picker_add_page(); + input.board_picker_add_page_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!( input.boards.board_states()[blackboard_index] @@ -1733,14 +1738,14 @@ fn board_picker_delete_page_requires_confirmation_for_multi_page_boards() { .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); set_board_page_count(&mut input, blackboard_index, 2); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected( input .board_picker_row_for_board(blackboard_index) .expect("blackboard row"), ); - input.board_picker_delete_page(1); + input.board_picker_delete_page_with_measurer(&crate::draw::TextMeasurer::default(), 1); assert_eq!( input.boards.board_states()[blackboard_index] .pages @@ -1753,7 +1758,7 @@ fn board_picker_delete_page_requires_confirmation_for_multi_page_boards() { .is_some_and(|toast| toast.message.contains("Click delete again to confirm.")) ); - input.board_picker_delete_page(1); + input.board_picker_delete_page_with_measurer(&crate::draw::TextMeasurer::default(), 1); assert_eq!( input.boards.board_states()[blackboard_index] .pages @@ -1766,10 +1771,10 @@ fn board_picker_delete_page_requires_confirmation_for_multi_page_boards() { fn board_picker_delete_selected_ignores_new_row() { let mut input = create_test_input_state(); let initial_count = input.boards.board_count(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected(initial_count); - input.board_picker_delete_selected(); + input.board_picker_delete_selected_with_measurer(&crate::draw::TextMeasurer::default()); assert_eq!(input.boards.board_count(), initial_count); assert_eq!(input.board_picker_selected_index(), Some(initial_count)); @@ -1779,7 +1784,7 @@ fn board_picker_delete_selected_ignores_new_row() { fn board_picker_toggle_pin_selected_ignores_new_row() { let mut input = create_test_input_state(); let board_count = input.boards.board_count(); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); input.board_picker_set_selected(board_count); let pinned_before = input.board_picker_pinned_count(); @@ -1821,12 +1826,12 @@ fn board_picker_activate_existing_row_switches_board_and_closes_picker() { .iter() .position(|board| board.spec.id == "blackboard") .expect("blackboard board"); - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); let row = input .board_picker_row_for_board(blackboard_index) .expect("blackboard row"); - input.board_picker_activate_row(row); + input.board_picker_activate_row_with_measurer(&crate::draw::TextMeasurer::default(), row); assert_eq!(input.board_id(), "blackboard"); assert!(!input.is_board_picker_open()); diff --git a/src/input/state/tests/boards.rs b/src/input/state/tests/boards.rs index 237a84651..b2acbf2e1 100644 --- a/src/input/state/tests/boards.rs +++ b/src/input/state/tests/boards.rs @@ -79,7 +79,7 @@ fn switch_board_recent_shows_toast_when_no_other_recent_board_exists() { #[test] fn switch_board_updates_open_board_picker_selection_and_clears_hover() { let mut state = create_test_input_state(); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); if let BoardPickerState::Open { hover_index, .. } = &mut state.board_picker.state { *hover_index = Some(0); diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index 139dc4a59..15edc546d 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -464,7 +464,7 @@ fn marker_size_increase_updates_accumulated_cleanup_bounds() { assert!(state.set_thickness(32.0)); let _ = state.take_dirty_regions(); - state.cancel_active_interaction(); + state.cancel_active_interaction_with(&crate::draw::TextMeasurer::default()); let dirty = state.take_dirty_regions(); let marker_width = (32.0f64 * 1.35).max(32.0 + 1.0); let expanded_bounds = @@ -495,7 +495,7 @@ fn eraser_size_increase_updates_accumulated_cleanup_bounds() { assert!(state.set_eraser_size(32.0)); let _ = state.take_dirty_regions(); - state.cancel_active_interaction(); + state.cancel_active_interaction_with(&crate::draw::TextMeasurer::default()); let dirty = state.take_dirty_regions(); let expanded_bounds = crate::draw::shape::bounding_box_for_eraser(&[(10, 10), (20, 10)], 32.0) .expect("expanded eraser preview should have bounds"); @@ -521,7 +521,7 @@ fn cancel_active_path_dirties_full_accumulated_provisional_bounds() { state.on_mouse_motion(30, 10); let _ = state.take_dirty_regions(); - state.cancel_active_interaction(); + state.cancel_active_interaction_with(&crate::draw::TextMeasurer::default()); let dirty = state.take_dirty_regions(); let thick = state.thickness_for_tool(Tool::Pen); let full_bounds = crate::draw::shape::bounding_box_for_points(&[(10, 10), (30, 10)], thick) diff --git a/src/input/state/tests/focus_mode.rs b/src/input/state/tests/focus_mode.rs index 1dbcb26f7..c052c97c6 100644 --- a/src/input/state/tests/focus_mode.rs +++ b/src/input/state/tests/focus_mode.rs @@ -287,8 +287,9 @@ fn focus_mode_rescues_when_the_enabled_status_bar_has_no_visible_content() { state.ui_visibility.show_floating_badge = false; state.ui_visibility.show_zoom_chip = false; for item in StatusBarItem::ALL { - state.set_status_bar_item_visible_with_engine( + state.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), item, false, ); @@ -395,8 +396,9 @@ fn focus_mode_hides_a_fallback_badge_when_the_enabled_status_bar_is_empty() { state.ui_visibility.show_zoom_chip = false; state.set_zoom_status(true, false, 2.0, (0.0, 0.0)); for item in StatusBarItem::ALL { - state.set_status_bar_item_visible_with_engine( + state.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), item, false, ); diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index b557e2b6d..c6e05f221 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -613,7 +613,7 @@ fn page_delete_from_context_reconciles_board_picker_page_search_cursor() { &[Some("Match one"), Some("Match two"), Some("Other")], 0, ); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); state.board_picker_set_focus(BoardPickerFocus::PagePanel); state.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); @@ -667,7 +667,7 @@ fn page_search_active_match_clamps_stale_cursor_after_external_page_delete() { &[Some("Match one"), Some("Match two"), Some("Other")], 0, ); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); state.board_picker_set_focus(BoardPickerFocus::PagePanel); state.handle_board_picker_key_with_measurer(&route_measurer, Key::Char('/')); diff --git a/src/input/state/tests/modal.rs b/src/input/state/tests/modal.rs index a24c53153..299fbae5e 100644 --- a/src/input/state/tests/modal.rs +++ b/src/input/state/tests/modal.rs @@ -26,7 +26,7 @@ fn opening_help_closes_the_color_picker_popup() { #[test] fn opening_help_closes_the_board_picker() { let mut state = create_test_input_state(); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert!(state.is_board_picker_open()); state.toggle_help_overlay(); @@ -40,7 +40,7 @@ fn opening_help_closes_the_board_picker() { #[test] fn a_context_menu_keeps_the_board_picker_open() { let mut state = create_test_input_state(); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert!(state.is_board_picker_open()); state.open_page_context_menu((10, 10), 0, 0); @@ -63,7 +63,9 @@ fn every_opener_ends_the_tour() { (|state: &mut crate::input::InputState| state.toggle_help_overlay()) as fn(&mut crate::input::InputState), ), - ("board picker", |state| state.open_board_picker()), + ("board picker", |state| { + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()) + }), ("palette", |state| state.toggle_command_palette()), ("color picker", |state| state.open_color_picker_popup()), ("radial", |state| state.toggle_radial_menu(100.0, 100.0)), @@ -125,7 +127,7 @@ fn openers_leave_no_excluded_surface_behind() { state.open_page_context_menu((10, 10), 0, 0) }), ("board picker", ModalSurface::BoardPicker, |state| { - state.open_board_picker() + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()) }), ]; diff --git a/src/input/state/tests/selection/duplicate.rs b/src/input/state/tests/selection/duplicate.rs index eabf6bb1f..997d7ebe4 100644 --- a/src/input/state/tests/selection/duplicate.rs +++ b/src/input/state/tests/selection/duplicate.rs @@ -85,7 +85,11 @@ fn copy_paste_selection_centers_shape_at_pointer() { ) .expect("local selection fallback"); assert_eq!( - state.paste_clipboard_shapes_from_request(&request, shapes), + state.paste_clipboard_shapes_from_request_with( + test_text_resources.measurer, + &request, + shapes, + ), 1 ); state.finish_clipboard_paste_request(request.id); @@ -135,7 +139,11 @@ fn immediate_paste_after_copy_uses_pending_local_publish_shapes() { .expect("pending local publish selection"); assert_eq!( - state.paste_clipboard_shapes_from_request(&request, shapes), + state.paste_clipboard_shapes_from_request_with( + test_text_resources.measurer, + &request, + shapes, + ), 1 ); state.finish_clipboard_paste_request(request.id); diff --git a/src/input/state/tests/spotlight.rs b/src/input/state/tests/spotlight.rs index 790177ce9..437a7a891 100644 --- a/src/input/state/tests/spotlight.rs +++ b/src/input/state/tests/spotlight.rs @@ -842,7 +842,7 @@ fn cancelling_a_knob_drag_restores_the_factor_it_started_from() { crate::draw::MAX_SPOTLIGHT_MAGNIFICATION ); - state.cancel_active_interaction(); + state.cancel_active_interaction_with(&crate::draw::TextMeasurer::default()); assert!(matches!(state.state, DrawingState::Idle)); assert_eq!(magnification_of(&state, id), 2.0); } diff --git a/src/input/state/tests/status_hud.rs b/src/input/state/tests/status_hud.rs index dd5725a63..2eafaa563 100644 --- a/src/input/state/tests/status_hud.rs +++ b/src/input/state/tests/status_hud.rs @@ -116,7 +116,11 @@ fn status_hud_layout_rebuild_preserves_cleared_hover_after_pointer_leave() { // may rebuild different geometry at those stale coordinates. input.clear_chrome_hover(); input.set_toolbar_visible(false); - input.update_status_hud_layout_for_pointer( + input.update_status_hud_layout_for_pointer_with_resources( + crate::input::state::InputTextResources { + measurer: &crate::draw::TextMeasurer::default(), + ui_engine: &crate::ui_text::UiTextEngine::default(), + }, StatusPosition::BottomLeft, &StatusBarStyle::default(), 1280, @@ -423,8 +427,9 @@ fn disabling_every_content_item_removes_the_hud_and_restores_badge_fallback() { let mut input = create_test_input_state(); input.boards.new_page(); for item in StatusBarItem::ALL { - input.set_status_bar_item_visible_with_engine( + input.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), item, false, ); @@ -455,8 +460,9 @@ fn changing_status_hud_content_leaves_damage_to_the_effect_pass() { update_hud_layout(&mut input, 1280, 720); let _ = input.take_dirty_region_report(); - assert!(input.set_status_bar_item_visible_with_engine( + assert!(input.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), StatusBarItem::About, false )); @@ -552,7 +558,7 @@ fn status_hud_ignored_while_other_eclipsing_overlays_are_open() { // Board picker (also covers a picker opened between press and release: // check_status_hud_click shares the same guard). - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert!(!input.status_hud_contains(x, y)); assert!( !input diff --git a/src/input/state/tests/status_hud/engine_mutations.rs b/src/input/state/tests/status_hud/engine_mutations.rs index 0c4ceef60..d8fa6023e 100644 --- a/src/input/state/tests/status_hud/engine_mutations.rs +++ b/src/input/state/tests/status_hud/engine_mutations.rs @@ -7,8 +7,11 @@ fn seeded(engine: &UiTextEngine) -> InputState { input.presenter_mode_config_mut_for_test().hide_toolbars = true; input.presenter_mode_config_mut_for_test().toolbar_mode = crate::config::PresenterToolbarMode::Micro; - input.update_status_hud_layout_for_pointer_with_engine( - engine, + input.update_status_hud_layout_for_pointer_with_resources( + crate::input::state::InputTextResources { + measurer: &crate::draw::TextMeasurer::default(), + ui_engine: engine, + }, StatusPosition::BottomLeft, &StatusBarStyle::default(), 1280, @@ -27,7 +30,12 @@ fn explicit_mutations_rebuild_immediately_and_preserve_no_op_results() { assert_eq!(input.status_hud.hover, Some(StatusHudSegmentKind::Help)); let before = format!("{:?}", input.status_hud_layout()); input.needs_redraw = false; - assert!(input.set_status_bar_item_visible_with_engine(&engine, StatusBarItem::Help, false)); + assert!(input.set_status_bar_item_visible_with_resources( + &engine, + &crate::draw::TextMeasurer::default(), + StatusBarItem::Help, + false + )); assert_eq!(input.status_hud.hover, None); assert!( !input @@ -40,10 +48,19 @@ fn explicit_mutations_rebuild_immediately_and_preserve_no_op_results() { assert_ne!(format!("{:?}", input.status_hud_layout()), before); assert!(input.needs_redraw); input.needs_redraw = false; - assert!(!input.set_status_bar_item_visible_with_engine(&engine, StatusBarItem::Help, false)); + assert!(!input.set_status_bar_item_visible_with_resources( + &engine, + &crate::draw::TextMeasurer::default(), + StatusBarItem::Help, + false + )); assert!(!input.needs_redraw); - assert!(input.set_toolbar_visible_with_engine(&engine, false)); + assert!(input.set_toolbar_visible_with_resources( + &engine, + &crate::draw::TextMeasurer::default(), + false + )); assert!( input .status_hud_layout() @@ -53,11 +70,20 @@ fn explicit_mutations_rebuild_immediately_and_preserve_no_op_results() { .any(|s| s.kind == StatusHudSegmentKind::Toolbar) ); input.needs_redraw = false; - assert!(!input.set_toolbar_visible_with_engine(&engine, false)); + assert!(!input.set_toolbar_visible_with_resources( + &engine, + &crate::draw::TextMeasurer::default(), + false + )); assert!(!input.needs_redraw); let mut unpainted = create_test_input_state(); - assert!(unpainted.set_status_bar_item_visible_with_engine(&engine, StatusBarItem::Help, false)); + assert!(unpainted.set_status_bar_item_visible_with_resources( + &engine, + &crate::draw::TextMeasurer::default(), + StatusBarItem::Help, + false + )); assert!( unpainted.status_hud_layout().is_none(), "mutations do not invent frame dimensions" @@ -152,11 +178,11 @@ fn explicit_focus_rescue_and_display_cycle_refresh_saved_geometry() { ui_engine: &engine, }; let mut input = seeded(&engine); - input.set_toolbar_visible_with_engine(&engine, false); + input.set_toolbar_visible_with_resources(&engine, &crate::draw::TextMeasurer::default(), false); input.ui_visibility.show_status_bar = false; input.ui_visibility.show_floating_badge = false; input.ui_visibility.show_zoom_chip = false; - input.refresh_status_hud_layout_with_engine(&engine); + input.refresh_status_hud_layout_with_resources(&engine, &crate::draw::TextMeasurer::default()); assert!(!input.focus_mode_active()); input.toggle_focus_mode_with_resources(resources); assert!(input.toolbar_visible(), "Focus rescues fully hidden chrome"); @@ -164,13 +190,23 @@ fn explicit_focus_rescue_and_display_cycle_refresh_saved_geometry() { assert!(!input.focus_mode_active()); // Rescue restores status visibility after its toolbar refresh. Do not add a new refresh. assert!(input.status_hud_layout().is_none()); - input.set_top_display_mode_with_engine(&engine, crate::config::TopDisplayMode::Full); + input.set_top_display_mode_with_resources( + &engine, + &crate::draw::TextMeasurer::default(), + crate::config::TopDisplayMode::Full, + ); assert!(input.status_hud_layout().is_some()); assert_eq!( - input.cycle_top_toolbar_display_with_engine(&engine), + input.cycle_top_toolbar_display_with_resources( + &engine, + &crate::draw::TextMeasurer::default() + ), crate::config::TopDisplayMode::Micro ); - input.derive_toolbar_visibility_from_pins_with_engine(&engine); + input.derive_toolbar_visibility_from_pins_with_resources( + &engine, + &crate::draw::TextMeasurer::default(), + ); assert!(input.status_hud_layout().is_some()); } @@ -183,7 +219,7 @@ fn explicit_action_and_toolbar_routes_refresh_status_geometry_before_another_fra ui_engine: &engine, }; let mut input = seeded(&engine); - input.set_toolbar_visible_with_engine(&engine, true); + input.set_toolbar_visible_with_resources(&engine, &crate::draw::TextMeasurer::default(), true); input.handle_action_with_resources(resources, crate::domain::Action::ToggleToolbar); assert!(!input.toolbar_visible()); assert!( diff --git a/src/input/state/tests/toolbar_display.rs b/src/input/state/tests/toolbar_display.rs index 2329fed7d..e5a67280d 100644 --- a/src/input/state/tests/toolbar_display.rs +++ b/src/input/state/tests/toolbar_display.rs @@ -562,8 +562,9 @@ fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { refresh_status_hud_layout(&mut state); assert!(state.status_hud_layout().is_some()); for item in StatusBarItem::ALL { - state.set_status_bar_item_visible_with_engine( + state.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), item, false, ); @@ -578,8 +579,9 @@ fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { ); assert!(!state.status_hud_effectively_visible()); - assert!(state.set_status_bar_item_visible_with_engine( + assert!(state.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), StatusBarItem::About, true )); @@ -591,8 +593,9 @@ fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { state.status_hud_effectively_visible(), "policy sees the synchronously refreshed measured cache" ); - assert!(state.set_status_bar_item_visible_with_engine( + assert!(state.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), StatusBarItem::About, false )); @@ -619,8 +622,9 @@ fn enabled_but_empty_status_bar_does_not_suppress_chrome_recovery_warning() { fn width_shed_content_never_reports_an_effectively_visible_hud() { let mut state = create_test_input_state(); for item in StatusBarItem::ALL { - state.set_status_bar_item_visible_with_engine( + state.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), item, false, ); @@ -633,8 +637,9 @@ fn width_shed_content_never_reports_an_effectively_visible_hud() { ); assert!(state.status_hud_layout().is_none()); - assert!(state.set_status_bar_item_visible_with_engine( + assert!(state.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), StatusBarItem::About, true )); @@ -659,8 +664,9 @@ fn toolbar_hint_prevents_a_false_all_chrome_warning_when_it_becomes_visible() { let mut state = create_test_input_state(); for item in StatusBarItem::ALL { - state.set_status_bar_item_visible_with_engine( + state.set_status_bar_item_visible_with_resources( &crate::ui_text::UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), item, item == StatusBarItem::ToolbarHint, ); diff --git a/src/input/state/tests/zoom_chip.rs b/src/input/state/tests/zoom_chip.rs index f3cb59bf5..8274ad7ef 100644 --- a/src/input/state/tests/zoom_chip.rs +++ b/src/input/state/tests/zoom_chip.rs @@ -151,7 +151,13 @@ fn zoom_chip_layout_rebuild_preserves_cleared_hover_after_pointer_leave() { // Pointer leave clears hover but retains the last coordinates. A redraw // must not reapply that stale hit while focus is elsewhere. input.clear_chrome_hover(); - input.update_zoom_chip_layout_for_pointer(&StatusBarStyle::default(), 1280, 720, false); + input.update_zoom_chip_layout_for_pointer_with_engine( + &crate::ui_text::UiTextEngine::default(), + &StatusBarStyle::default(), + 1280, + 720, + false, + ); assert_eq!( input.zoom_chip.hover, None, @@ -441,7 +447,7 @@ fn zoom_chip_ignored_while_eclipsing_overlay_open() { // An overlay rendering above the chip suppresses its presses (also covers // an overlay opened between press and release: check_zoom_chip_click // shares the same guard). - input.open_board_picker(); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); assert!(!input.zoom_chip_contains(x, y)); assert!(!input.check_zoom_chip_click(ZoomChipButtonKind::In, x, y).0); input.close_board_picker(); diff --git a/src/input/state/text_resources.rs b/src/input/state/text_resources.rs index b94ff5e8e..0bd9cc6e7 100644 --- a/src/input/state/text_resources.rs +++ b/src/input/state/text_resources.rs @@ -12,18 +12,5 @@ pub(crate) struct InputTextResources<'a> { pub(crate) ui_engine: &'a UiTextEngine, } -/// Run a public convenience operation with isolated call-local resources. -/// Backend runtime paths pass their persistent owners explicitly instead. -pub(in crate::input::state) fn with_scoped_text_resources( - operation: impl FnOnce(InputTextResources<'_>) -> R, -) -> R { - let measurer = TextMeasurer::default(); - let ui_engine = UiTextEngine::default(); - operation(InputTextResources { - measurer: &measurer, - ui_engine: &ui_engine, - }) -} - #[cfg(test)] mod tests; diff --git a/src/input/tablet/mod.rs b/src/input/tablet/mod.rs index a5fbeae41..eee17c73a 100644 --- a/src/input/tablet/mod.rs +++ b/src/input/tablet/mod.rs @@ -31,17 +31,8 @@ impl Default for TabletSettings { /// Apply a normalized pressure value [0.0, 1.0] to a pressure-sensitive tool. pub fn apply_pressure_to_state(pressure01: f64, state: &mut InputState, settings: TabletSettings) { - try_apply_pressure_to_state(pressure01, state, settings); -} - -/// Apply pressure and report whether the active tool accepted the sample. -pub(crate) fn try_apply_pressure_to_state( - pressure01: f64, - state: &mut InputState, - settings: TabletSettings, -) -> bool { let measurer = crate::draw::TextMeasurer::default(); - try_apply_pressure_to_state_with(&measurer, pressure01, state, settings) + let _ = try_apply_pressure_to_state_with(&measurer, pressure01, state, settings); } pub(crate) fn try_apply_pressure_to_state_with( @@ -97,7 +88,8 @@ mod tests { state.style.current_thickness = 3.0; state.needs_redraw = false; - assert!(!try_apply_pressure_to_state( + assert!(!try_apply_pressure_to_state_with( + &crate::draw::TextMeasurer::default(), 0.8, &mut state, TabletSettings::default() @@ -118,7 +110,12 @@ mod tests { max_thickness: 8.0, }; - assert!(!try_apply_pressure_to_state(0.8, &mut state, settings)); + assert!(!try_apply_pressure_to_state_with( + &crate::draw::TextMeasurer::default(), + 0.8, + &mut state, + settings + )); assert_eq!(state.style.current_thickness, 4.0); assert!(!state.needs_redraw); @@ -135,13 +132,23 @@ mod tests { max_thickness: 6.0, }; - assert!(try_apply_pressure_to_state(-1.0, &mut state, settings)); + assert!(try_apply_pressure_to_state_with( + &crate::draw::TextMeasurer::default(), + -1.0, + &mut state, + settings + )); assert_eq!(state.style.current_thickness, 2.0); assert_eq!(state.thickness_for_tool(Tool::Pen), 2.0); assert!(state.needs_redraw); state.needs_redraw = false; - assert!(try_apply_pressure_to_state(2.0, &mut state, settings)); + assert!(try_apply_pressure_to_state_with( + &crate::draw::TextMeasurer::default(), + 2.0, + &mut state, + settings + )); assert_eq!(state.style.current_thickness, 6.0); assert_eq!(state.thickness_for_tool(Tool::Pen), 6.0); assert!(state.needs_redraw); @@ -157,7 +164,12 @@ mod tests { max_thickness: MAX_STROKE_THICKNESS + 50.0, }; - assert!(try_apply_pressure_to_state(1.0, &mut state, settings)); + assert!(try_apply_pressure_to_state_with( + &crate::draw::TextMeasurer::default(), + 1.0, + &mut state, + settings + )); assert_eq!(state.style.current_thickness, MAX_STROKE_THICKNESS); assert_eq!(state.thickness_for_tool(Tool::Pen), MAX_STROKE_THICKNESS); @@ -176,9 +188,19 @@ mod tests { state.set_tool_override(Some(Tool::Pen)); state.on_mouse_press(MouseButton::Left, 0, 0); - assert!(try_apply_pressure_to_state(0.0, &mut state, settings)); + assert!(try_apply_pressure_to_state_with( + &crate::draw::TextMeasurer::default(), + 0.0, + &mut state, + settings + )); state.on_mouse_motion(10, 0); - assert!(try_apply_pressure_to_state(1.0, &mut state, settings)); + assert!(try_apply_pressure_to_state_with( + &crate::draw::TextMeasurer::default(), + 1.0, + &mut state, + settings + )); state.on_mouse_motion(20, 0); state.on_mouse_release(MouseButton::Left, 20, 0); @@ -203,7 +225,12 @@ mod tests { state.set_tool_override(Some(Tool::Marker)); assert!(state.set_thickness(31.0)); - assert!(!try_apply_pressure_to_state(0.05, &mut state, settings)); + assert!(!try_apply_pressure_to_state_with( + &crate::draw::TextMeasurer::default(), + 0.05, + &mut state, + settings + )); state.on_mouse_press(MouseButton::Left, 0, 0); state.on_mouse_motion(10, 0); state.on_mouse_release(MouseButton::Left, 20, 0); @@ -229,7 +256,12 @@ mod tests { state.set_tool_override(Some(Tool::StepMarker)); assert!(state.set_thickness(30.0)); - assert!(!try_apply_pressure_to_state(0.05, &mut state, settings)); + assert!(!try_apply_pressure_to_state_with( + &crate::draw::TextMeasurer::default(), + 0.05, + &mut state, + settings + )); state.on_mouse_press(MouseButton::Left, 20, 20); state.on_mouse_release(MouseButton::Left, 20, 20); diff --git a/src/session/snapshot/apply.rs b/src/session/snapshot/apply.rs index 5bfe422e8..30010fcc6 100644 --- a/src/session/snapshot/apply.rs +++ b/src/session/snapshot/apply.rs @@ -7,11 +7,13 @@ use std::collections::HashSet; /// Apply a session snapshot to the live [`InputState`]. pub fn apply_snapshot(input: &mut InputState, snapshot: SessionSnapshot, options: &SessionOptions) { - apply_snapshot_inner(input, snapshot, options, None); + let measurer = crate::draw::TextMeasurer::default(); + apply_snapshot_inner(input, &measurer, snapshot, options, None); } fn apply_snapshot_inner( input: &mut InputState, + measurer: &crate::draw::TextMeasurer, snapshot: SessionSnapshot, options: &SessionOptions, replacement_board_ids: Option<&HashSet>, @@ -43,7 +45,7 @@ fn apply_snapshot_inner( input.clear_pending_deletes_after_board_generation_change(board_generation_before); if input.boards.has_board(&snapshot.active_board_id) { - input.switch_board_force(&snapshot.active_board_id); + input.switch_board_force_with_measurer(measurer, &snapshot.active_board_id); } else { log::warn!( "Session active board '{}' missing after restore; keeping current board '{}'", @@ -54,7 +56,7 @@ fn apply_snapshot_inner( if options.restore_tool_state { if let Some(tool_state) = snapshot.tool_state { - apply_tool_state_snapshot(input, tool_state); + apply_tool_state_snapshot(input, measurer, tool_state); } else { log::info!("No tool state found in session; skipping tool restore"); } @@ -71,7 +73,11 @@ fn apply_snapshot_inner( /// deliberately absent from the snapshot, so restoring a session leaves the /// configured value in place instead of reinstating a toggle that promised to /// last one run. -pub(crate) fn apply_tool_state_snapshot(input: &mut InputState, tool_state: ToolStateSnapshot) { +pub(crate) fn apply_tool_state_snapshot( + input: &mut InputState, + measurer: &crate::draw::TextMeasurer, + tool_state: ToolStateSnapshot, +) { let marker_opacity = tool_state .marker_opacity .unwrap_or(input.style.marker_opacity); @@ -102,7 +108,7 @@ pub(crate) fn apply_tool_state_snapshot(input: &mut InputState, tool_state: Tool input.preset_slots.clear_active(); input.dirty_tracker.mark_full(); input.sync_highlight_color(); - let _ = input.set_tool_override(tool_state.tool_override); + let _ = input.set_tool_override_with(measurer, tool_state.tool_override); input.set_board_previous_color(tool_state.board_previous_color); input.sync_step_marker_counter(); input.needs_redraw = true; @@ -117,6 +123,7 @@ pub(crate) fn apply_tool_state_snapshot(input: &mut InputState, tool_state: Tool #[allow(dead_code)] pub(crate) fn apply_snapshot_replacing_boards( input: &mut InputState, + measurer: &crate::draw::TextMeasurer, snapshot: SessionSnapshot, options: &SessionOptions, ) -> Result<()> { @@ -138,15 +145,21 @@ pub(crate) fn apply_snapshot_replacing_boards( available_slots )); } - clear_board_pages(input); - apply_snapshot_inner(input, snapshot, options, Some(&replacement_board_ids)); + clear_board_pages(input, measurer); + apply_snapshot_inner( + input, + measurer, + snapshot, + options, + Some(&replacement_board_ids), + ); input.dirty_tracker.mark_full(); input.sync_canvas_pointer_to_current_transform(); Ok(()) } -fn clear_board_pages(input: &mut InputState) { - input.cancel_active_interaction(); +fn clear_board_pages(input: &mut InputState, measurer: &crate::draw::TextMeasurer) { + input.cancel_active_interaction_with(measurer); // Every page is about to be replaced. A wheel adjustment still in flight // belongs to a frame that will not exist afterwards, so record it now // rather than letting the identity guard drop it. diff --git a/src/session/tests/snapshot.rs b/src/session/tests/snapshot.rs index 044e02754..a5c9cbd39 100644 --- a/src/session/tests/snapshot.rs +++ b/src/session/tests/snapshot.rs @@ -102,7 +102,11 @@ fn every_persisted_drawing_style_survives_snapshot_serialization_and_restore() { let decoded: ToolStateSnapshot = serde_json::from_slice(&encoded).expect("deserialize tool snapshot fixture"); let mut restored = dummy_input_state(); - apply_tool_state_snapshot(&mut restored, decoded); + apply_tool_state_snapshot( + &mut restored, + &crate::draw::TextMeasurer::default(), + decoded, + ); let recaptured = ToolStateSnapshot::from_input_state(&restored); assert_eq!( @@ -127,7 +131,11 @@ fn non_default_pen_smoothing_survives_snapshot_serialization_and_restore() { let mut restored = dummy_input_state(); let _ = restored.set_pen_smoothing(1); - apply_tool_state_snapshot(&mut restored, decoded); + apply_tool_state_snapshot( + &mut restored, + &crate::draw::TextMeasurer::default(), + decoded, + ); assert_eq!(restored.style.pen_smoothing, 5); } @@ -148,7 +156,11 @@ fn legacy_snapshot_without_pen_smoothing_preserves_the_configured_level() { let mut restored = dummy_input_state(); let _ = restored.set_pen_smoothing(4); - apply_tool_state_snapshot(&mut restored, decoded); + apply_tool_state_snapshot( + &mut restored, + &crate::draw::TextMeasurer::default(), + decoded, + ); assert_eq!( restored.style.pen_smoothing, 4, diff --git a/src/ui.rs b/src/ui.rs index e8233ce36..ddc69c266 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -90,7 +90,7 @@ pub use status::{ zoom_chip_geometry, }; pub(crate) use status::{ - compute_status_hud_layout_with_engine, compute_zoom_chip_layout_with_engine, + compute_status_hud_layout_with_resources, compute_zoom_chip_layout_with_engine, render_editing_badge_with_engine, render_frozen_badge_with_engine, render_page_badge_with_engine, render_pan_badge_with_engine, render_status_bar_with_resources, render_zoom_badge_with_engine, render_zoom_chip_with_resources, diff --git a/src/ui/board_picker/tests.rs b/src/ui/board_picker/tests.rs index 5095bf454..6b174aa43 100644 --- a/src/ui/board_picker/tests.rs +++ b/src/ui/board_picker/tests.rs @@ -33,7 +33,7 @@ fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layou let measurer = crate::draw::TextMeasurer::default(); let mut caches = crate::draw::RenderCaches::default(); let mut state = crate::input::state::test_support::make_test_input_state(); - state.open_board_picker(); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); for (width, height, density) in [(900, 700, 1), (420, 300, 2), (900, 700, 1)] { let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); diff --git a/src/ui/status/bar.rs b/src/ui/status/bar.rs index eee3e4d72..8ddcd734d 100644 --- a/src/ui/status/bar.rs +++ b/src/ui/status/bar.rs @@ -14,7 +14,7 @@ use crate::config::{Action, StatusPosition, action_display_label}; use crate::input::{BoardBackground, DrawingState, InputState, TextInputMode, Tool}; use crate::label_format::{format_binding_labels, join_binding_labels}; use crate::ui::toolbar::bindings::action_for_tool; -use crate::ui_text::{UiTextEngine, UiTextExtents, UiTextStyle, with_scoped_engine}; +use crate::ui_text::{UiTextEngine, UiTextExtents, UiTextStyle}; mod content; mod helpers; @@ -22,7 +22,7 @@ mod measurement; mod render; pub use content::compute_status_hud_layout; -pub(crate) use content::compute_status_hud_layout_with_engine; +pub(crate) use content::compute_status_hud_layout_with_resources; pub(crate) use render::render_status_bar_with_resources; pub use render::{render_status_bar, render_status_bar_with_theme}; diff --git a/src/ui/status/bar/content.rs b/src/ui/status/bar/content.rs index 55ff526cd..8917f0727 100644 --- a/src/ui/status/bar/content.rs +++ b/src/ui/status/bar/content.rs @@ -68,20 +68,22 @@ pub fn compute_status_hud_layout( screen_width: u32, screen_height: u32, ) -> Option { - with_scoped_engine(|engine| { - compute_status_hud_layout_with_engine( - engine, - input_state, - position, - style, - screen_width, - screen_height, - ) - }) + let engine = UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); + compute_status_hud_layout_with_resources( + &engine, + &measurer, + input_state, + position, + style, + screen_width, + screen_height, + ) } -pub(crate) fn compute_status_hud_layout_with_engine( +pub(crate) fn compute_status_hud_layout_with_resources( engine: &UiTextEngine, + measurer: &crate::draw::TextMeasurer, input_state: &InputState, position: StatusPosition, style: &crate::config::StatusBarStyle, @@ -94,7 +96,7 @@ pub(crate) fn compute_status_hud_layout_with_engine( let sep_advance = sep_extents.x_advance(); let mut pieces = build_cluster_pieces(input_state); - let prefix_text = build_prefix_text(input_state); + let prefix_text = build_prefix_text(input_state, measurer); if pieces.is_empty() && prefix_text.is_none() { return None; } @@ -415,7 +417,10 @@ pub(super) fn build_cluster_pieces(input_state: &InputState) -> Vec Option { +pub(super) fn build_prefix_text( + input_state: &InputState, + measurer: &crate::draw::TextMeasurer, +) -> Option { let mut parts: Vec = Vec::new(); if input_state.ui_visibility.show_active_output_badge && let Some(label) = input_state.active_output_label() @@ -424,7 +429,7 @@ pub(super) fn build_prefix_text(input_state: &InputState) -> Option { parts.push(format!("Output: {label}")); } if input_state.ui_visibility.show_status_selection_info - && let Some(bounds) = input_state.selection_bounds() + && let Some(bounds) = input_state.selection_bounds_with(measurer) { let count = input_state.selected_shape_ids().len(); parts.push(if count == 1 { diff --git a/src/ui/status/bar/tests/width_budget.rs b/src/ui/status/bar/tests/width_budget.rs index 3c6b9028d..a53a558dc 100644 --- a/src/ui/status/bar/tests/width_budget.rs +++ b/src/ui/status/bar/tests/width_budget.rs @@ -44,8 +44,9 @@ fn each_core_content_flag_removes_only_its_segment() { for (item, kind) in cases { let mut state = make_state(); - assert!(state.set_status_bar_item_visible_with_engine( + assert!(state.set_status_bar_item_visible_with_resources( &UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), item, false )); @@ -82,22 +83,30 @@ fn prefix_content_keeps_output_before_selection_and_honors_both_flags() { state.set_selection(vec![shape_id]); assert_eq!( - build_prefix_text(&state).as_deref(), + build_prefix_text(&state, &crate::draw::TextMeasurer::default()).as_deref(), Some("Output: DP-3 · 34×44px") ); - state.set_status_bar_item_visible_with_engine( + state.set_status_bar_item_visible_with_resources( &UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), StatusBarItem::ActiveOutput, false, ); - assert_eq!(build_prefix_text(&state).as_deref(), Some("34×44px")); - state.set_status_bar_item_visible_with_engine( + assert_eq!( + build_prefix_text(&state, &crate::draw::TextMeasurer::default()).as_deref(), + Some("34×44px") + ); + state.set_status_bar_item_visible_with_resources( &UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), StatusBarItem::SelectionInfo, false, ); - assert_eq!(build_prefix_text(&state), None); + assert_eq!( + build_prefix_text(&state, &crate::draw::TextMeasurer::default()), + None + ); } #[test] @@ -111,8 +120,9 @@ fn context_indicator_flag_gates_transient_status_text() { .any(|piece| piece.text.as_deref() == Some(label)) ); - state.set_status_bar_item_visible_with_engine( + state.set_status_bar_item_visible_with_resources( &UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), StatusBarItem::ContextIndicators, false, ); @@ -127,10 +137,16 @@ fn context_indicator_flag_gates_transient_status_text() { fn shedding_the_last_optional_piece_does_not_leave_an_empty_pill() { let mut state = make_state(); for item in StatusBarItem::ALL { - state.set_status_bar_item_visible_with_engine(&UiTextEngine::default(), item, false); + state.set_status_bar_item_visible_with_resources( + &UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), + item, + false, + ); } - state.set_status_bar_item_visible_with_engine( + state.set_status_bar_item_visible_with_resources( &UiTextEngine::default(), + &crate::draw::TextMeasurer::default(), StatusBarItem::About, true, ); diff --git a/src/ui/status/mod.rs b/src/ui/status/mod.rs index 1e8f4ed12..ac26eb6a9 100644 --- a/src/ui/status/mod.rs +++ b/src/ui/status/mod.rs @@ -13,7 +13,7 @@ pub use zoom_chip::{ render_zoom_chip_with_theme, zoom_chip_geometry, }; -pub(crate) use bar::{compute_status_hud_layout_with_engine, render_status_bar_with_resources}; +pub(crate) use bar::{compute_status_hud_layout_with_resources, render_status_bar_with_resources}; pub(crate) use zoom_chip::{compute_zoom_chip_layout_with_engine, render_zoom_chip_with_resources}; #[cfg(test)] diff --git a/src/ui/status/tests.rs b/src/ui/status/tests.rs index 8ebd77d50..62355ab92 100644 --- a/src/ui/status/tests.rs +++ b/src/ui/status/tests.rs @@ -9,8 +9,11 @@ fn state() -> InputState { } fn update(engine: &UiTextEngine, state: &mut InputState, style: &StatusBarStyle, focused: bool) { - state.update_status_hud_layout_for_pointer_with_engine( - engine, + state.update_status_hud_layout_for_pointer_with_resources( + crate::input::state::InputTextResources { + measurer: &crate::draw::TextMeasurer::default(), + ui_engine: engine, + }, StatusPosition::BottomLeft, style, 1280, diff --git a/src/ui/status/zoom_chip.rs b/src/ui/status/zoom_chip.rs index d180898e3..e3e40a0ea 100644 --- a/src/ui/status/zoom_chip.rs +++ b/src/ui/status/zoom_chip.rs @@ -20,7 +20,7 @@ use super::super::primitives::{draw_pill, draw_rounded_rect}; use super::super::theme::{self, overlay}; use crate::config::StatusBarStyle; use crate::input::{BoardBackground, InputState}; -use crate::ui_text::{UiTextEngine, UiTextExtents, UiTextStyle, with_scoped_engine}; +use crate::ui_text::{UiTextEngine, UiTextExtents, UiTextStyle}; // ============================================================================ // UI Layout Constants (not configurable) — mirror the status bar pill so the @@ -258,15 +258,8 @@ pub fn compute_zoom_chip_layout( screen_width: u32, screen_height: u32, ) -> Option { - with_scoped_engine(|engine| { - compute_zoom_chip_layout_with_engine( - engine, - input_state, - style, - screen_width, - screen_height, - ) - }) + let engine = UiTextEngine::default(); + compute_zoom_chip_layout_with_engine(&engine, input_state, style, screen_width, screen_height) } pub(crate) fn compute_zoom_chip_layout_with_engine( diff --git a/src/ui_text.rs b/src/ui_text.rs index 623d18e6d..c38087009 100644 --- a/src/ui_text.rs +++ b/src/ui_text.rs @@ -152,13 +152,6 @@ impl Default for UiTextEngine { } } -/// Run a public convenience operation with an isolated call-local owner. -/// Runtime paths should pass their persistent `UiTextEngine` explicitly. -pub(crate) fn with_scoped_engine(f: impl FnOnce(&UiTextEngine) -> T) -> T { - let engine = UiTextEngine::default(); - f(&engine) -} - impl UiTextEngine { /// Measure before a target exists, using the same layout cache as painting. pub(crate) fn measure( diff --git a/src/ui_text/tests.rs b/src/ui_text/tests.rs index 256dbf009..500d7c017 100644 --- a/src/ui_text/tests.rs +++ b/src/ui_text/tests.rs @@ -252,16 +252,16 @@ fn cache_keys_keep_font_categories_quantized_size_and_wrap_units() { } #[test] -fn scoped_convenience_engines_are_isolated_and_match_an_explicit_owner() { +fn independent_engines_are_isolated_and_produce_matching_extents() { let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 4, 4).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); - let first = - with_scoped_engine(|engine| engine.layout(&ctx, style(14.0), "scoped owner", Some(50.0))); - let second = - with_scoped_engine(|engine| engine.layout(&ctx, style(14.0), "scoped owner", Some(50.0))); + let first_engine = UiTextEngine::default(); + let first = first_engine.layout(&ctx, style(14.0), "first owner", Some(50.0)); + let second_engine = UiTextEngine::default(); + let second = second_engine.layout(&ctx, style(14.0), "first owner", Some(50.0)); assert_ne!(first.layout, second.layout); - let engine = UiTextEngine::default(); - let explicit = engine.layout(&ctx, style(14.0), "scoped owner", Some(50.0)); + let explicit_engine = UiTextEngine::default(); + let explicit = explicit_engine.layout(&ctx, style(14.0), "first owner", Some(50.0)); assert_ne!(first.layout, explicit.layout); assert_ne!(second.layout, explicit.layout); assert_extents_eq(first.ink_extents(), second.ink_extents());