From 310c342c8d6570e8bdb1f4bd3166e42dec84556c Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:40:20 +0200 Subject: [PATCH 01/53] refactor(config): isolate transparent board normalization --- src/config/validate/boards.rs | 42 ++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/config/validate/boards.rs b/src/config/validate/boards.rs index 46f33e27..52b99dbf 100644 --- a/src/config/validate/boards.rs +++ b/src/config/validate/boards.rs @@ -54,24 +54,7 @@ impl Config { boards.items.retain(|item| !item.id.is_empty()); - let transparent_in_range = boards - .items - .iter() - .take(boards.max_count) - .any(|item| item.background.is_transparent()); - if !transparent_in_range { - if let Some(index) = boards - .items - .iter() - .position(|item| item.background.is_transparent()) - { - let item = boards.items.remove(index); - boards.items.insert(0, item); - } else { - warn!("No transparent board defined; adding default overlay board"); - boards.items.insert(0, BoardsConfig::default_overlay_item()); - } - } + ensure_transparent_board_in_range(boards); if boards.items.len() > boards.max_count { warn!( @@ -101,6 +84,29 @@ impl Config { } } +fn ensure_transparent_board_in_range(boards: &mut BoardsConfig) { + let transparent_in_range = boards + .items + .iter() + .take(boards.max_count) + .any(|item| item.background.is_transparent()); + if transparent_in_range { + return; + } + + if let Some(index) = boards + .items + .iter() + .position(|item| item.background.is_transparent()) + { + let item = boards.items.remove(index); + boards.items.insert(0, item); + } else { + warn!("No transparent board defined; adding default overlay board"); + boards.items.insert(0, BoardsConfig::default_overlay_item()); + } +} + fn normalize_background(background: &mut BoardBackgroundConfig, id: &str) { match background { BoardBackgroundConfig::Transparent(value) => { From 2dee0212b7a89b4524707ecdcaa435f536a4befc Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:40:57 +0200 Subject: [PATCH 02/53] refactor(config): isolate highlight color validation --- src/config/validate/ui.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/config/validate/ui.rs b/src/config/validate/ui.rs index c8491458..ca575971 100644 --- a/src/config/validate/ui.rs +++ b/src/config/validate/ui.rs @@ -67,6 +67,12 @@ impl Config { ); } + self.validate_click_highlight_colors(); + + self.validate_input_hud(); + } + + fn validate_click_highlight_colors(&mut self) { for i in 0..4 { if !(0.0..=1.0).contains(&self.ui.click_highlight.fill_color[i]) { log::warn!( @@ -87,8 +93,6 @@ impl Config { self.ui.click_highlight.outline_color[i].clamp(0.0, 1.0); } } - - self.validate_input_hud(); } fn validate_input_hud(&mut self) { From d1b3bb318f5f6734af3072482978459b59ab1a94 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:41:57 +0200 Subject: [PATCH 03/53] refactor(config): split drawing validation stages --- src/config/validate/drawing.rs | 84 +++++++++++++++++++--------------- 1 file changed, 46 insertions(+), 38 deletions(-) diff --git a/src/config/validate/drawing.rs b/src/config/validate/drawing.rs index 0e4e118d..6df2c03d 100644 --- a/src/config/validate/drawing.rs +++ b/src/config/validate/drawing.rs @@ -5,36 +5,7 @@ use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; impl Config { pub(super) fn validate_drawing(&mut self) { - // Thickness: 1.0 - 50.0 - if !(MIN_STROKE_THICKNESS..=MAX_STROKE_THICKNESS).contains(&self.drawing.default_thickness) - { - log::warn!( - "Invalid default_thickness {:.1}, clamping to {:.1}-{:.1} range", - self.drawing.default_thickness, - MIN_STROKE_THICKNESS, - MAX_STROKE_THICKNESS - ); - self.drawing.default_thickness = self - .drawing - .default_thickness - .clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); - } - - // Eraser size: 1.0 - 50.0 - if !(MIN_STROKE_THICKNESS..=MAX_STROKE_THICKNESS) - .contains(&self.drawing.default_eraser_size) - { - log::warn!( - "Invalid default_eraser_size {:.1}, clamping to {:.1}-{:.1} range", - self.drawing.default_eraser_size, - MIN_STROKE_THICKNESS, - MAX_STROKE_THICKNESS - ); - self.drawing.default_eraser_size = self - .drawing - .default_eraser_size - .clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); - } + self.validate_stroke_sizes(); // Marker opacity: 0.05 - 0.9 // Font cycle: drop blank entries and repeats, keeping the given order. @@ -102,6 +73,51 @@ impl Config { .clamp(REGULAR_POLYGON_MIN_SIDES, REGULAR_POLYGON_MAX_SIDES); } + self.validate_hit_test_settings(); + + if !(10..=1000).contains(&self.drawing.undo_stack_limit) { + log::warn!( + "Invalid undo_stack_limit {}, clamping to 10-1000 range", + self.drawing.undo_stack_limit + ); + self.drawing.undo_stack_limit = self.drawing.undo_stack_limit.clamp(10, 1000); + } + } + + fn validate_stroke_sizes(&mut self) { + // Thickness: 1.0 - 50.0 + if !(MIN_STROKE_THICKNESS..=MAX_STROKE_THICKNESS).contains(&self.drawing.default_thickness) + { + log::warn!( + "Invalid default_thickness {:.1}, clamping to {:.1}-{:.1} range", + self.drawing.default_thickness, + MIN_STROKE_THICKNESS, + MAX_STROKE_THICKNESS + ); + self.drawing.default_thickness = self + .drawing + .default_thickness + .clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); + } + + // Eraser size: 1.0 - 50.0 + if !(MIN_STROKE_THICKNESS..=MAX_STROKE_THICKNESS) + .contains(&self.drawing.default_eraser_size) + { + log::warn!( + "Invalid default_eraser_size {:.1}, clamping to {:.1}-{:.1} range", + self.drawing.default_eraser_size, + MIN_STROKE_THICKNESS, + MAX_STROKE_THICKNESS + ); + self.drawing.default_eraser_size = self + .drawing + .default_eraser_size + .clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); + } + } + + fn validate_hit_test_settings(&mut self) { if !self.drawing.hit_test_tolerance.is_finite() { log::warn!( "Invalid non-finite hit_test_tolerance; resetting to default {:.1}", @@ -120,13 +136,5 @@ impl Config { log::warn!("hit_test_linear_threshold must be at least 1; using default 400"); self.drawing.hit_test_linear_threshold = 400; } - - if !(10..=1000).contains(&self.drawing.undo_stack_limit) { - log::warn!( - "Invalid undo_stack_limit {}, clamping to 10-1000 range", - self.drawing.undo_stack_limit - ); - self.drawing.undo_stack_limit = self.drawing.undo_stack_limit.clamp(10, 1000); - } } } From d2b180f153687abfbbdf61bbc4de0a39abdc807a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:43:00 +0200 Subject: [PATCH 04/53] refactor(config): split render profile normalization --- src/config/validate/render_profiles.rs | 154 +++++++++++++++---------- 1 file changed, 90 insertions(+), 64 deletions(-) diff --git a/src/config/validate/render_profiles.rs b/src/config/validate/render_profiles.rs index f91fa3b1..eea7f1b8 100644 --- a/src/config/validate/render_profiles.rs +++ b/src/config/validate/render_profiles.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use super::Config; -use crate::config::RenderProfileExportMode; +use crate::config::{RenderProfileConfig, RenderProfileExportMode}; use crate::render_profiles::{format_hex_rgb, normalize_profile_id, parse_hex_rgb}; use log::warn; @@ -9,71 +9,14 @@ impl Config { pub(super) fn validate_render_profiles(&mut self) { let mut seen_ids = HashSet::new(); for (index, profile) in self.render_profiles.profiles.iter_mut().enumerate() { - let raw_id = profile.id.clone(); - let mut id = normalize_profile_id(&raw_id); - if id.is_empty() { - id = format!("profile-{}", index + 1); - warn!("Render profile id was empty; using '{}'", id); - } else if id != raw_id.trim() { - warn!("Render profile id '{}' normalized to '{}'", raw_id, id); - } - - let base = id.clone(); - let mut suffix = 2; - while seen_ids.contains(&id) { - id = format!("{base}-{suffix}"); - suffix += 1; - } - if id != base { - warn!("Render profile id '{}' deduplicated to '{}'", base, id); - } - seen_ids.insert(id.clone()); - profile.id = id; - - if profile.name.trim().is_empty() { - profile.name = format!("Profile {}", index + 1); - warn!( - "Render profile '{}' had empty name; using '{}'", - profile.id, profile.name - ); - } else { - profile.name = profile.name.trim().to_string(); - } - - let mut seen_sources = HashSet::new(); - let mut normalized = Vec::with_capacity(profile.mappings.len()); - for mapping in profile.mappings.iter().rev() { - let Some(from) = parse_hex_rgb(&mapping.from) else { - warn!( - "Render profile '{}' has invalid source color '{}'; dropping mapping", - profile.id, mapping.from - ); - continue; - }; - let Some(to) = parse_hex_rgb(&mapping.to) else { - warn!( - "Render profile '{}' has invalid target color '{}'; dropping mapping", - profile.id, mapping.to - ); - continue; - }; - if !seen_sources.insert(from) { - warn!( - "Render profile '{}' has duplicate source color {}; keeping the last mapping", - profile.id, - format_hex_rgb(from) - ); - continue; - } - normalized.push(crate::config::RenderColorMappingConfig { - from: format_hex_rgb(from), - to: format_hex_rgb(to), - }); - } - normalized.reverse(); - profile.mappings = normalized; + normalize_profile(profile, index, &mut seen_ids); } + self.validate_active_render_profile(); + self.validate_export_render_profile(); + } + + fn validate_active_render_profile(&mut self) { if let Some(active) = self.render_profiles.active.as_mut() { *active = normalize_profile_id(active); if active.is_empty() @@ -90,7 +33,9 @@ impl Config { self.render_profiles.active = None; } } + } + fn validate_export_render_profile(&mut self) { if matches!( self.render_profiles.export, RenderProfileExportMode::Profile @@ -125,3 +70,84 @@ impl Config { } } } + +fn normalize_profile( + profile: &mut RenderProfileConfig, + index: usize, + seen_ids: &mut HashSet, +) { + normalize_profile_identity(profile, index, seen_ids); + normalize_profile_mappings(profile); +} + +fn normalize_profile_identity( + profile: &mut RenderProfileConfig, + index: usize, + seen_ids: &mut HashSet, +) { + let raw_id = profile.id.clone(); + let mut id = normalize_profile_id(&raw_id); + if id.is_empty() { + id = format!("profile-{}", index + 1); + warn!("Render profile id was empty; using '{}'", id); + } else if id != raw_id.trim() { + warn!("Render profile id '{}' normalized to '{}'", raw_id, id); + } + + let base = id.clone(); + let mut suffix = 2; + while seen_ids.contains(&id) { + id = format!("{base}-{suffix}"); + suffix += 1; + } + if id != base { + warn!("Render profile id '{}' deduplicated to '{}'", base, id); + } + seen_ids.insert(id.clone()); + profile.id = id; + + if profile.name.trim().is_empty() { + profile.name = format!("Profile {}", index + 1); + warn!( + "Render profile '{}' had empty name; using '{}'", + profile.id, profile.name + ); + } else { + profile.name = profile.name.trim().to_string(); + } +} + +fn normalize_profile_mappings(profile: &mut RenderProfileConfig) { + let mut seen_sources = HashSet::new(); + let mut normalized = Vec::with_capacity(profile.mappings.len()); + for mapping in profile.mappings.iter().rev() { + let Some(from) = parse_hex_rgb(&mapping.from) else { + warn!( + "Render profile '{}' has invalid source color '{}'; dropping mapping", + profile.id, mapping.from + ); + continue; + }; + let Some(to) = parse_hex_rgb(&mapping.to) else { + warn!( + "Render profile '{}' has invalid target color '{}'; dropping mapping", + profile.id, mapping.to + ); + continue; + }; + if !seen_sources.insert(from) { + warn!( + "Render profile '{}' has duplicate source color {}; keeping the last mapping", + profile.id, + format_hex_rgb(from) + ); + continue; + } + normalized.push(crate::config::RenderColorMappingConfig { + from: format_hex_rgb(from), + to: format_hex_rgb(to), + }); + } + normalized.reverse(); + profile.mappings = normalized; +} From 286b205dddaed7d6d9008a348565e8cc40f9a8a0 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:44:23 +0200 Subject: [PATCH 05/53] refactor(app): split session command handlers --- src/app/session.rs | 87 +++++++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/src/app/session.rs b/src/app/session.rs index 5f57cc25..bf21a357 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -2,23 +2,7 @@ use crate::cli::Cli; use crate::env_vars::WAYLAND_DISPLAY_ENV; pub(crate) fn run_session_cli_commands(cli: &Cli) -> anyhow::Result<()> { - if let Some(display_name) = cli.rename_session.as_deref() { - let raw_path = cli - .session_file - .as_ref() - .ok_or_else(|| anyhow::anyhow!("--rename-session requires --session-file"))?; - let raw = raw_path - .to_str() - .ok_or_else(|| anyhow::anyhow!("--session-file path must be valid UTF-8"))?; - let path = crate::session::normalize_named_session_file_arg(raw); - match crate::session::catalog::rename_session_display_name_by_path(&path, display_name)? { - Some(entry) => { - println!("Renamed session to {}.", entry.display_name); - } - None => { - anyhow::bail!("session is not in the named-session catalog"); - } - } + if rename_session(cli)? { return Ok(()); } @@ -62,29 +46,7 @@ pub(crate) fn run_session_cli_commands(cli: &Cli) -> anyhow::Result<()> { }; if cli.clear_session { - let outcome = crate::session::clear_session(&options)?; - println!("Session file: {}", options.session_file_path().display()); - if outcome.removed_session { - println!(" Removed session file"); - } else { - println!(" No session file present"); - } - if outcome.removed_backup { - println!(" Removed backup file"); - } - if outcome.removed_recovery { - println!(" Removed recovery file"); - } - if outcome.removed_lock { - println!(" Removed lock file"); - } - if !outcome.removed_session - && !outcome.removed_backup - && !outcome.removed_recovery - && !outcome.removed_lock - { - println!(" No session artefacts found"); - } + clear_session(&options)?; return Ok(()); } @@ -189,3 +151,48 @@ pub(crate) fn run_session_cli_commands(cli: &Cli) -> anyhow::Result<()> { Ok(()) } + +fn rename_session(cli: &Cli) -> anyhow::Result { + let Some(display_name) = cli.rename_session.as_deref() else { + return Ok(false); + }; + let raw_path = cli + .session_file + .as_ref() + .ok_or_else(|| anyhow::anyhow!("--rename-session requires --session-file"))?; + let raw = raw_path + .to_str() + .ok_or_else(|| anyhow::anyhow!("--session-file path must be valid UTF-8"))?; + let path = crate::session::normalize_named_session_file_arg(raw); + let entry = crate::session::catalog::rename_session_display_name_by_path(&path, display_name)? + .ok_or_else(|| anyhow::anyhow!("session is not in the named-session catalog"))?; + println!("Renamed session to {}.", entry.display_name); + Ok(true) +} + +fn clear_session(options: &crate::session::SessionOptions) -> anyhow::Result<()> { + let outcome = crate::session::clear_session(options)?; + println!("Session file: {}", options.session_file_path().display()); + if outcome.removed_session { + println!(" Removed session file"); + } else { + println!(" No session file present"); + } + if outcome.removed_backup { + println!(" Removed backup file"); + } + if outcome.removed_recovery { + println!(" Removed recovery file"); + } + if outcome.removed_lock { + println!(" Removed lock file"); + } + if !outcome.removed_session + && !outcome.removed_backup + && !outcome.removed_recovery + && !outcome.removed_lock + { + println!(" No session artefacts found"); + } + Ok(()) +} From bf3b21178b674f1801c035ea40507ec691283eed Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:45:41 +0200 Subject: [PATCH 06/53] refactor(app): split overlay control logging --- src/app/usage.rs | 58 +++++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/src/app/usage.rs b/src/app/usage.rs index a5a07660..6f34e613 100644 --- a/src/app/usage.rs +++ b/src/app/usage.rs @@ -62,6 +62,16 @@ pub(crate) fn log_overlay_controls(freeze: bool) { log::info!("Starting annotation overlay..."); log::info!("Controls:"); let bindings = default_action_bindings(); + log_drawing_controls(&bindings); + log_canvas_controls(&bindings); + log_ui_controls(&bindings); + if freeze { + log::info!("Starting frozen mode (freeze-on-start requested)"); + } + log::info!(""); +} + +fn log_drawing_controls(bindings: &HashMap>) { log::info!( " - {}: Just drag", action_display_label(Action::SelectPenTool) @@ -85,77 +95,79 @@ pub(crate) fn log_overlay_controls(freeze: bool) { log::info!( " - {}: {}, click to position, type, press Enter", action_display_label(Action::EnterTextMode), - action_binding_label(&bindings, Action::EnterTextMode) + action_binding_label(bindings, Action::EnterTextMode) ); - log::info!(" - Colors: {}", color_binding_labels(&bindings)); + log::info!(" - Colors: {}", color_binding_labels(bindings)); +} + +fn log_canvas_controls(bindings: &HashMap>) { log::info!( " - {} / {}: {} / {}", action_display_label(Action::Undo), action_display_label(Action::Redo), - action_binding_label(&bindings, Action::Undo), - action_binding_label(&bindings, Action::Redo) + action_binding_label(bindings, Action::Undo), + action_binding_label(bindings, Action::Redo) ); log::info!( " - {}: {}", action_display_label(Action::ClearCanvas), - action_binding_label(&bindings, Action::ClearCanvas) + action_binding_label(bindings, Action::ClearCanvas) ); log::info!( " - {}: {} or scroll down", action_display_label(Action::IncreaseThickness), - action_binding_label(&bindings, Action::IncreaseThickness) + action_binding_label(bindings, Action::IncreaseThickness) ); log::info!( " - {}: {} or scroll up", action_display_label(Action::DecreaseThickness), - action_binding_label(&bindings, Action::DecreaseThickness) + action_binding_label(bindings, Action::DecreaseThickness) ); log::info!( " - {}: {} (toggle frozen background)", action_display_label(Action::ToggleFrozenMode), - action_binding_label(&bindings, Action::ToggleFrozenMode) + action_binding_label(bindings, Action::ToggleFrozenMode) ); log::info!( " - {} / {}: {} / {} (Ctrl+Alt + scroll)", action_display_label(Action::ZoomIn), action_display_label(Action::ZoomOut), - action_binding_label(&bindings, Action::ZoomIn), - action_binding_label(&bindings, Action::ZoomOut) + action_binding_label(bindings, Action::ZoomIn), + action_binding_label(bindings, Action::ZoomOut) ); log::info!( " - {}: {} • {}: {} • Pan view: middle drag/arrow keys", action_display_label(Action::ResetZoom), - action_binding_label(&bindings, Action::ResetZoom), + action_binding_label(bindings, Action::ResetZoom), action_display_label(Action::ToggleZoomLock), - action_binding_label(&bindings, Action::ToggleZoomLock) + action_binding_label(bindings, Action::ToggleZoomLock) ); +} + +fn log_ui_controls(bindings: &HashMap>) { log::info!( " - {}: Right Click or {}", action_display_label(Action::OpenContextMenu), - action_binding_label(&bindings, Action::OpenContextMenu) + action_binding_label(bindings, Action::OpenContextMenu) ); log::info!( " - {}: {} • {}: {} • {}: {} • {}: {} • {}: {}", action_display_label(Action::ToggleHelp), - action_binding_label(&bindings, Action::ToggleHelp), + action_binding_label(bindings, Action::ToggleHelp), action_display_label(Action::ToggleToolbar), - action_binding_label(&bindings, Action::ToggleToolbar), + action_binding_label(bindings, Action::ToggleToolbar), action_display_label(Action::TogglePresenterMode), - action_binding_label(&bindings, Action::TogglePresenterMode), + action_binding_label(bindings, Action::TogglePresenterMode), action_display_label(Action::OpenConfigurator), - action_binding_label(&bindings, Action::OpenConfigurator), + action_binding_label(bindings, Action::OpenConfigurator), action_display_label(Action::ToggleStatusBar), - action_binding_label(&bindings, Action::ToggleStatusBar) + action_binding_label(bindings, Action::ToggleStatusBar) ); log::info!( " - {}: {}", action_display_label(Action::Exit), - action_binding_label(&bindings, Action::Exit) + action_binding_label(bindings, Action::Exit) ); - if freeze { - log::info!("Starting frozen mode (freeze-on-start requested)"); - } - log::info!(""); } pub(crate) fn print_usage() { From 07c623c4cc11a412d391282d345d001e1f65858c Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:48:16 +0200 Subject: [PATCH 07/53] refactor(input): split selection action dispatch --- src/input/state/actions/action_selection.rs | 232 ++++++++++---------- 1 file changed, 116 insertions(+), 116 deletions(-) diff --git a/src/input/state/actions/action_selection.rs b/src/input/state/actions/action_selection.rs index 2b6f47c9..c2fdc269 100644 --- a/src/input/state/actions/action_selection.rs +++ b/src/input/state/actions/action_selection.rs @@ -10,54 +10,14 @@ const KEYBOARD_NUDGE_LARGE: i32 = 32; impl InputState { pub(in crate::input::state) fn handle_selection_action(&mut self, action: Action) -> bool { match action { - Action::CopySelection => { - let copied = self.copy_selection(); - if copied > 0 { - info!("Copied selection ({} shape(s))", copied); - } else if self.has_selection() { - self.push_toast( - ToastPriority::Info, - "selection", - Toast::warning("No unlocked shapes to copy; clipboard unchanged."), - ); - } else { - self.push_toast( - ToastPriority::Info, - "selection", - Toast::warning("No selection to copy; clipboard unchanged."), - ); - } - true + Action::CopySelection | Action::SelectAll => { + self.handle_selection_content_action(action) } Action::PasteSelection => { self.request_clipboard_paste(); info!("Requested clipboard paste"); true } - 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; - } - true - } Action::DuplicateSelection => { if self.duplicate_selection() { info!("Duplicated selection"); @@ -76,80 +36,12 @@ impl InputState { } true } - Action::NudgeSelectionUp => { - let step = if self.modifiers.shift { - KEYBOARD_NUDGE_LARGE - } else { - KEYBOARD_NUDGE_SMALL - }; - if self.translate_selection_with_undo(0, -step) { - self.last_selection_axis = Some(SelectionAxis::Vertical); - info!("Moved selection up by {} px", step); - } else if self.has_selection() { - self.last_selection_axis = Some(SelectionAxis::Vertical); - } - true - } - Action::NudgeSelectionDown => { - let step = if self.modifiers.shift { - KEYBOARD_NUDGE_LARGE - } else { - KEYBOARD_NUDGE_SMALL - }; - if self.translate_selection_with_undo(0, step) { - self.last_selection_axis = Some(SelectionAxis::Vertical); - info!("Moved selection down by {} px", step); - } else if self.has_selection() { - self.last_selection_axis = Some(SelectionAxis::Vertical); - } - true - } - Action::NudgeSelectionLeft => { - let step = if self.modifiers.shift { - KEYBOARD_NUDGE_LARGE - } else { - KEYBOARD_NUDGE_SMALL - }; - if self.translate_selection_with_undo(-step, 0) { - self.last_selection_axis = Some(SelectionAxis::Horizontal); - info!("Moved selection left by {} px", step); - } else if self.has_selection() { - self.last_selection_axis = Some(SelectionAxis::Horizontal); - } - true - } - Action::NudgeSelectionRight => { - let step = if self.modifiers.shift { - KEYBOARD_NUDGE_LARGE - } else { - KEYBOARD_NUDGE_SMALL - }; - if self.translate_selection_with_undo(step, 0) { - self.last_selection_axis = Some(SelectionAxis::Horizontal); - info!("Moved selection right by {} px", step); - } else if self.has_selection() { - self.last_selection_axis = Some(SelectionAxis::Horizontal); - } - true - } - Action::NudgeSelectionUpLarge => { - if self.translate_selection_with_undo(0, -KEYBOARD_NUDGE_LARGE) { - self.last_selection_axis = Some(SelectionAxis::Vertical); - info!("Moved selection up by {} px", KEYBOARD_NUDGE_LARGE); - } else if self.has_selection() { - self.last_selection_axis = Some(SelectionAxis::Vertical); - } - true - } - Action::NudgeSelectionDownLarge => { - if self.translate_selection_with_undo(0, KEYBOARD_NUDGE_LARGE) { - self.last_selection_axis = Some(SelectionAxis::Vertical); - info!("Moved selection down by {} px", KEYBOARD_NUDGE_LARGE); - } else if self.has_selection() { - self.last_selection_axis = Some(SelectionAxis::Vertical); - } - true - } + Action::NudgeSelectionUp + | Action::NudgeSelectionDown + | Action::NudgeSelectionLeft + | Action::NudgeSelectionRight + | Action::NudgeSelectionUpLarge + | Action::NudgeSelectionDownLarge => self.handle_selection_nudge_action(action), Action::MoveSelectionToStart => { if self.move_selection_to_horizontal_edge(true) { info!("Moved selection to start"); @@ -183,4 +75,112 @@ impl InputState { _ => false, } } + + fn handle_selection_content_action(&mut self, action: Action) -> bool { + match action { + Action::CopySelection => { + let copied = self.copy_selection(); + if copied > 0 { + info!("Copied selection ({} shape(s))", copied); + } else if self.has_selection() { + self.push_toast( + ToastPriority::Info, + "selection", + Toast::warning("No unlocked shapes to copy; clipboard unchanged."), + ); + } else { + self.push_toast( + ToastPriority::Info, + "selection", + Toast::warning("No selection to copy; clipboard unchanged."), + ); + } + } + 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; + } + } + _ => unreachable!("selection content dispatcher called with {action:?}"), + } + true + } + + fn handle_selection_nudge_action(&mut self, action: Action) -> bool { + let shifted_step = if self.modifiers.shift { + KEYBOARD_NUDGE_LARGE + } else { + KEYBOARD_NUDGE_SMALL + }; + let (dx, dy, axis, direction, step) = match action { + Action::NudgeSelectionUp => ( + 0, + -shifted_step, + SelectionAxis::Vertical, + "up", + shifted_step, + ), + Action::NudgeSelectionDown => ( + 0, + shifted_step, + SelectionAxis::Vertical, + "down", + shifted_step, + ), + Action::NudgeSelectionLeft => ( + -shifted_step, + 0, + SelectionAxis::Horizontal, + "left", + shifted_step, + ), + Action::NudgeSelectionRight => ( + shifted_step, + 0, + SelectionAxis::Horizontal, + "right", + shifted_step, + ), + Action::NudgeSelectionUpLarge => ( + 0, + -KEYBOARD_NUDGE_LARGE, + SelectionAxis::Vertical, + "up", + KEYBOARD_NUDGE_LARGE, + ), + Action::NudgeSelectionDownLarge => ( + 0, + KEYBOARD_NUDGE_LARGE, + SelectionAxis::Vertical, + "down", + KEYBOARD_NUDGE_LARGE, + ), + _ => unreachable!("selection nudge dispatcher called with {action:?}"), + }; + if self.translate_selection_with_undo(dx, dy) { + self.last_selection_axis = Some(axis); + info!("Moved selection {} by {} px", direction, step); + } else if self.has_selection() { + self.last_selection_axis = Some(axis); + } + true + } } From 01e52009c43784503155e6e919cacf54a872dbb9 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:49:25 +0200 Subject: [PATCH 08/53] refactor(input): isolate preset shape settings --- src/input/state/core/tool_controls/presets.rs | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/src/input/state/core/tool_controls/presets.rs b/src/input/state/core/tool_controls/presets.rs index 87653d89..980da761 100644 --- a/src/input/state/core/tool_controls/presets.rs +++ b/src/input/state/core/tool_controls/presets.rs @@ -85,6 +85,31 @@ impl InputState { self.needs_redraw = true; self.mark_session_dirty(); } + self.apply_preset_shape_settings(&preset); + // Redraw only: the bar's visibility is a this-run preference the + // session snapshot does not carry, unlike every tool value above it. + if let Some(show_status_bar) = preset.show_status_bar + && !(self.presenter_mode && self.presenter_mode_config.hide_status_bar) + && self.set_status_bar_visibility_preserving_focus(show_status_bar) + { + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + if let Some(drag_tools) = preset.drag_tools.as_ref() { + let left_defaults = self.drag_tool_bindings.to_config().left; + let drag_tools = drag_tools + .clone() + .resolve_with_left_defaults(&left_defaults); + let _ = self + .set_drag_tool_bindings(crate::input::DragToolBindings::from_config(&drag_tools)); + } + + self.active_preset_slot = Some(slot); + self.set_preset_feedback(slot, PresetFeedbackKind::Apply); + true + } + + fn apply_preset_shape_settings(&mut self, preset: &ToolPresetConfig) { if let Some(length) = preset.arrow_length { let clamped = length.clamp(5.0, 50.0); if (self.arrow_length - clamped).abs() > f64::EPSILON { @@ -114,27 +139,6 @@ impl InputState { if let Some(polygon_sides) = preset.polygon_sides { let _ = self.set_polygon_sides(polygon_sides); } - // Redraw only: the bar's visibility is a this-run preference the - // session snapshot does not carry, unlike every tool value above it. - if let Some(show_status_bar) = preset.show_status_bar - && !(self.presenter_mode && self.presenter_mode_config.hide_status_bar) - && self.set_status_bar_visibility_preserving_focus(show_status_bar) - { - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - } - if let Some(drag_tools) = preset.drag_tools.as_ref() { - let left_defaults = self.drag_tool_bindings.to_config().left; - let drag_tools = drag_tools - .clone() - .resolve_with_left_defaults(&left_defaults); - let _ = self - .set_drag_tool_bindings(crate::input::DragToolBindings::from_config(&drag_tools)); - } - - self.active_preset_slot = Some(slot); - self.set_preset_feedback(slot, PresetFeedbackKind::Apply); - true } pub fn save_preset(&mut self, slot: usize) -> bool { From 2a7b54c900b07481beffdcb73aee5f2917c097a6 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:50:19 +0200 Subject: [PATCH 09/53] refactor(input): separate presenter mode transitions --- .../state/core/utility/presenter_mode.rs | 91 ++++++++++--------- 1 file changed, 50 insertions(+), 41 deletions(-) diff --git a/src/input/state/core/utility/presenter_mode.rs b/src/input/state/core/utility/presenter_mode.rs index 83d0bf07..2042112c 100644 --- a/src/input/state/core/utility/presenter_mode.rs +++ b/src/input/state/core/utility/presenter_mode.rs @@ -14,52 +14,61 @@ use crate::input::tool::Tool; impl InputState { pub(crate) fn toggle_presenter_mode(&mut self) -> bool { - let config = self.presenter_mode_config.clone(); if self.presenter_mode { - self.presenter_mode = false; - if let Some(restore) = self.presenter_restore.take() { - if let Some(value) = restore.show_status_bar { - self.show_status_bar = value; - } - if let Some(value) = restore.show_tool_preview { - self.show_tool_preview = value; - } - if let Some(value) = restore.toolbar_visible { - self.toolbar_visible = value; - } - if let Some(value) = restore.toolbar_top_visible { - self.toolbar_top_visible = value; - } - if let Some(value) = restore.toolbar_top_display_mode { - self.toolbar_top_display_mode = value; - } - if let Some(value) = restore.toolbar_top_minimized { - self.toolbar_top_minimized = value; - } - if let Some(value) = restore.tool_override { - self.set_tool_override(value); - } - if let Some(value) = restore.click_highlight_enabled - && self.click_highlight_enabled() != value - { - self.toggle_click_highlight(); - } - if let Some(value) = restore.input_hud_enabled { - self.set_input_hud_enabled(value); - } + self.stop_presenter_mode() + } else { + self.start_presenter_mode() + } + } + + fn stop_presenter_mode(&mut self) -> bool { + let config = self.presenter_mode_config.clone(); + self.presenter_mode = false; + if let Some(restore) = self.presenter_restore.take() { + if let Some(value) = restore.show_status_bar { + self.show_status_bar = value; + } + if let Some(value) = restore.show_tool_preview { + self.show_tool_preview = value; + } + if let Some(value) = restore.toolbar_visible { + self.toolbar_visible = value; } - if config.show_toast { - self.push_toast( - ToastPriority::Info, - "presenter", - Toast::info("Stopping Presenter Mode"), - ); + if let Some(value) = restore.toolbar_top_visible { + self.toolbar_top_visible = value; } - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - return self.presenter_mode; + if let Some(value) = restore.toolbar_top_display_mode { + self.toolbar_top_display_mode = value; + } + if let Some(value) = restore.toolbar_top_minimized { + self.toolbar_top_minimized = value; + } + if let Some(value) = restore.tool_override { + self.set_tool_override(value); + } + if let Some(value) = restore.click_highlight_enabled + && self.click_highlight_enabled() != value + { + self.toggle_click_highlight(); + } + if let Some(value) = restore.input_hud_enabled { + self.set_input_hud_enabled(value); + } + } + if config.show_toast { + self.push_toast( + ToastPriority::Info, + "presenter", + Toast::info("Stopping Presenter Mode"), + ); } + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + self.presenter_mode + } + fn start_presenter_mode(&mut self) -> bool { + let config = self.presenter_mode_config.clone(); if self.light_mode { self.exit_light_mode(); } From 97d7344966ad1e42b49c6d319876d852c99fe6d4 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:52:01 +0200 Subject: [PATCH 10/53] refactor(input): isolate page-panel navigation --- src/input/state/actions/key_press/panels.rs | 152 ++++++++++---------- 1 file changed, 74 insertions(+), 78 deletions(-) diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs index 6ad738f5..25b9ecef 100644 --- a/src/input/state/actions/key_press/panels.rs +++ b/src/input/state/actions/key_press/panels.rs @@ -237,22 +237,7 @@ impl InputState { } fn handle_board_picker_page_panel_key(&mut self, key: Key) -> bool { - let layout = self.board_picker_layout; - let page_cols = layout.map(|l| l.page_cols.max(1)).unwrap_or(1); - let page_count = self - .board_picker_page_panel_board_index() - .and_then(|bi| self.boards.board_states().get(bi)) - .map(|b| b.pages.page_count()) - .unwrap_or(0); - let current = self - .board_picker_page_focus_page_index() - .unwrap_or_else(|| { - self.board_picker_page_panel_board_index() - .and_then(|bi| self.boards.board_states().get(bi)) - .map_or(0, |board| board.pages.active_index()) - }) - .min(page_count.saturating_sub(1)); - + let (page_count, current) = self.board_picker_page_panel_position(); match key { Key::Escape => { if !self.board_picker_clear_search() { @@ -264,68 +249,15 @@ impl InputState { self.board_picker_set_focus(BoardPickerFocus::BoardList); true } - Key::Left => { - if current == 0 { - self.board_picker_set_focus(BoardPickerFocus::BoardList); - } else if page_count > 0 { - self.board_picker_set_page_focus_page_index(current.saturating_sub(1)); - } - true - } - Key::Right => { - if page_count > 0 { - let next = current.saturating_add(1).min(page_count.saturating_sub(1)); - self.board_picker_set_page_focus_page_index(next); - } - true - } - Key::Up => { - if page_count > 0 { - let next = current.saturating_sub(page_cols); - self.board_picker_set_page_focus_page_index(next); - } - true - } - Key::Down => { - if page_count > 0 { - let next = current - .saturating_add(page_cols) - .min(page_count.saturating_sub(1)); - self.board_picker_set_page_focus_page_index(next); - } - true - } - Key::Home => { - if page_count > 0 { - self.board_picker_set_page_focus_page_index(0); - } - true - } - Key::End => { - if page_count > 0 { - self.board_picker_set_page_focus_page_index(page_count.saturating_sub(1)); - } - true - } - Key::PageUp => { - if page_count > 0 { - let step = layout - .map(|l| l.page_visible_slots.max(page_cols)) - .unwrap_or(page_cols); - self.board_picker_set_page_focus_page_index(current.saturating_sub(step)); - } - true - } - Key::PageDown => { - if page_count > 0 { - let step = layout - .map(|l| l.page_visible_slots.max(page_cols)) - .unwrap_or(page_cols); - let next = current - .saturating_add(step) - .min(page_count.saturating_sub(1)); - self.board_picker_set_page_focus_page_index(next); - } + Key::Left + | Key::Right + | Key::Up + | Key::Down + | Key::Home + | Key::End + | Key::PageUp + | Key::PageDown => { + self.navigate_board_picker_page_panel(key, page_count, current); true } Key::Return | Key::Space => { @@ -393,6 +325,70 @@ impl InputState { } } + fn board_picker_page_panel_position(&self) -> (usize, usize) { + let page_count = self + .board_picker_page_panel_board_index() + .and_then(|bi| self.boards.board_states().get(bi)) + .map(|b| b.pages.page_count()) + .unwrap_or(0); + let current = self + .board_picker_page_focus_page_index() + .unwrap_or_else(|| { + self.board_picker_page_panel_board_index() + .and_then(|bi| self.boards.board_states().get(bi)) + .map_or(0, |board| board.pages.active_index()) + }) + .min(page_count.saturating_sub(1)); + (page_count, current) + } + + fn navigate_board_picker_page_panel(&mut self, key: Key, page_count: usize, current: usize) { + let layout = self.board_picker_layout; + let page_cols = layout.map(|l| l.page_cols.max(1)).unwrap_or(1); + + match key { + Key::Left if current == 0 => { + self.board_picker_set_focus(BoardPickerFocus::BoardList); + } + Key::Left if page_count > 0 => { + self.board_picker_set_page_focus_page_index(current.saturating_sub(1)); + } + Key::Right if page_count > 0 => { + let next = current.saturating_add(1).min(page_count.saturating_sub(1)); + self.board_picker_set_page_focus_page_index(next); + } + Key::Up if page_count > 0 => { + self.board_picker_set_page_focus_page_index(current.saturating_sub(page_cols)); + } + Key::Down if page_count > 0 => { + let next = current + .saturating_add(page_cols) + .min(page_count.saturating_sub(1)); + self.board_picker_set_page_focus_page_index(next); + } + Key::Home if page_count > 0 => self.board_picker_set_page_focus_page_index(0), + Key::End if page_count > 0 => { + self.board_picker_set_page_focus_page_index(page_count.saturating_sub(1)); + } + Key::PageUp if page_count > 0 => { + let step = layout + .map(|l| l.page_visible_slots.max(page_cols)) + .unwrap_or(page_cols); + self.board_picker_set_page_focus_page_index(current.saturating_sub(step)); + } + Key::PageDown if page_count > 0 => { + let step = layout + .map(|l| l.page_visible_slots.max(page_cols)) + .unwrap_or(page_cols); + let next = current + .saturating_add(step) + .min(page_count.saturating_sub(1)); + self.board_picker_set_page_focus_page_index(next); + } + _ => {} + } + } + pub(in crate::input::state) fn handle_properties_panel_key(&mut self, key: Key) -> bool { let adjust_step = if self.modifiers.shift { PROPERTIES_PANEL_COARSE_STEP From e32417ca205925e561f10bb9f13edb3b4730c8c7 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:52:45 +0200 Subject: [PATCH 11/53] refactor(input): centralize context submenu anchors --- src/input/state/core/menus/commands.rs | 57 ++++++++------------------ 1 file changed, 17 insertions(+), 40 deletions(-) diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index c4edd2f1..e43e9d6b 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -32,6 +32,19 @@ impl InputState { self.board_picker_page_panel_board_index() == Some(board_index) } + fn context_submenu_anchor(&self) -> (i32, i32) { + if let Some(layout) = self.context_menu_layout { + ( + (layout.origin_x + layout.width + 8.0).round() as i32, + layout.origin_y.round() as i32, + ) + } else if let ContextMenuState::Open { anchor, .. } = &self.context_menu_state { + *anchor + } else { + self.last_pointer_position + } + } + pub fn execute_menu_command(&mut self, command: MenuCommand) { match command { MenuCommand::Copy => { @@ -118,16 +131,7 @@ impl InputState { self.close_context_menu(); } MenuCommand::OpenZoomMenu => { - let anchor = if let Some(layout) = self.context_menu_layout { - ( - (layout.origin_x + layout.width + 8.0).round() as i32, - layout.origin_y.round() as i32, - ) - } else if let ContextMenuState::Open { anchor, .. } = &self.context_menu_state { - *anchor - } else { - self.last_pointer_position - }; + let anchor = self.context_submenu_anchor(); self.open_context_menu(anchor, Vec::new(), ContextMenuKind::Zoom, None); self.pending_menu_hover_recalc = false; self.set_context_menu_focus(None); @@ -155,16 +159,7 @@ impl InputState { self.close_context_menu(); } MenuCommand::OpenPagesMenu => { - let anchor = if let Some(layout) = self.context_menu_layout { - ( - (layout.origin_x + layout.width + 8.0).round() as i32, - layout.origin_y.round() as i32, - ) - } else if let ContextMenuState::Open { anchor, .. } = &self.context_menu_state { - *anchor - } else { - self.last_pointer_position - }; + let anchor = self.context_submenu_anchor(); self.open_context_menu(anchor, Vec::new(), ContextMenuKind::Pages, None); self.pending_menu_hover_recalc = false; self.set_context_menu_focus(None); @@ -174,16 +169,7 @@ impl InputState { self.needs_redraw = true; } MenuCommand::OpenBoardsMenu => { - let anchor = if let Some(layout) = self.context_menu_layout { - ( - (layout.origin_x + layout.width + 8.0).round() as i32, - layout.origin_y.round() as i32, - ) - } else if let ContextMenuState::Open { anchor, .. } = &self.context_menu_state { - *anchor - } else { - self.last_pointer_position - }; + let anchor = self.context_submenu_anchor(); self.open_context_menu(anchor, Vec::new(), ContextMenuKind::Boards, None); self.pending_menu_hover_recalc = false; self.set_context_menu_focus(None); @@ -193,16 +179,7 @@ impl InputState { self.needs_redraw = true; } MenuCommand::OpenPageMoveMenu => { - let anchor = if let Some(layout) = self.context_menu_layout { - ( - (layout.origin_x + layout.width + 8.0).round() as i32, - layout.origin_y.round() as i32, - ) - } else if let ContextMenuState::Open { anchor, .. } = &self.context_menu_state { - *anchor - } else { - self.last_pointer_position - }; + let anchor = self.context_submenu_anchor(); let target = self.context_menu_page_target; self.open_context_menu(anchor, Vec::new(), ContextMenuKind::PageMove, None); self.context_menu_page_target = target; From e5497866736b78cc0777a2083807e761e0eeeb39 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:54:11 +0200 Subject: [PATCH 12/53] refactor(session): isolate snapshot size rejection --- src/session/snapshot/load.rs | 79 +++++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 29 deletions(-) diff --git a/src/session/snapshot/load.rs b/src/session/snapshot/load.rs index cc1f5ae8..8c71cb2c 100644 --- a/src/session/snapshot/load.rs +++ b/src/session/snapshot/load.rs @@ -367,35 +367,14 @@ fn load_snapshot_path_with_outcome( options.per_output, options.output_identity() ); - if enforce_configured_file_size && metadata.len() > options.max_file_size_bytes { - warn!( - "Session file {} is {} bytes which exceeds the configured limit ({} bytes); refusing to load", - session_path.display(), - metadata.len(), - options.max_file_size_bytes - ); - return Ok(LoadSnapshotOutcome::Empty); - } else if !enforce_configured_file_size { - if metadata.len() > max_expanded_size { - warn!( - "Session recovery file {} is {} bytes which exceeds the expanded load safety limit ({} bytes); refusing to read", - session_path.display(), - metadata.len(), - max_expanded_size - ); - return Ok(LoadSnapshotOutcome::ExpandedTooLarge { - path: session_path.to_path_buf(), - max_expanded_size, - }); - } - if metadata.len() > options.max_file_size_bytes { - info!( - "Session recovery file {} is {} bytes, above configured normal session limit {}; loading with expanded safety cap only", - session_path.display(), - metadata.len(), - options.max_file_size_bytes - ); - } + if let Some(outcome) = reject_oversized_snapshot( + session_path, + metadata.len(), + options, + max_expanded_size, + enforce_configured_file_size, + ) { + return Ok(outcome); } let lock_path = options.lock_file_path(); @@ -501,6 +480,48 @@ fn load_snapshot_path_with_outcome( } } +fn reject_oversized_snapshot( + session_path: &Path, + file_size: u64, + options: &SessionOptions, + max_expanded_size: u64, + enforce_configured_file_size: bool, +) -> Option { + if enforce_configured_file_size && file_size > options.max_file_size_bytes { + warn!( + "Session file {} is {} bytes which exceeds the configured limit ({} bytes); refusing to load", + session_path.display(), + file_size, + options.max_file_size_bytes + ); + return Some(LoadSnapshotOutcome::Empty); + } + if enforce_configured_file_size { + return None; + } + if file_size > max_expanded_size { + warn!( + "Session recovery file {} is {} bytes which exceeds the expanded load safety limit ({} bytes); refusing to read", + session_path.display(), + file_size, + max_expanded_size + ); + return Some(LoadSnapshotOutcome::ExpandedTooLarge { + path: session_path.to_path_buf(), + max_expanded_size, + }); + } + if file_size > options.max_file_size_bytes { + info!( + "Session recovery file {} is {} bytes, above configured normal session limit {}; loading with expanded safety cap only", + session_path.display(), + file_size, + options.max_file_size_bytes + ); + } + None +} + fn record_named_session_opened_for_outcome( options: &SessionOptions, outcome: &LoadSnapshotOutcome, From 93be74f8bb28d7222432b632fe61fbc6c924d5db Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:54:47 +0200 Subject: [PATCH 13/53] refactor(session): isolate post-save artifact cleanup --- src/session/snapshot/save.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/session/snapshot/save.rs b/src/session/snapshot/save.rs index 0b98fac0..d3c210b2 100644 --- a/src/session/snapshot/save.rs +++ b/src/session/snapshot/save.rs @@ -535,6 +535,23 @@ fn save_snapshot_inner( compressed, }; log_near_limit(&report); + cleanup_snapshot_artifacts_after_save( + options, + snapshot_has_board_data, + contentless_clear_boundary, + had_session_file, + preserves_recoverable_recovery, + ); + Ok(Some(report)) +} + +fn cleanup_snapshot_artifacts_after_save( + options: &SessionOptions, + snapshot_has_board_data: bool, + contentless_clear_boundary: bool, + had_session_file: bool, + preserves_recoverable_recovery: bool, +) { if snapshot_has_board_data { if remove_recoverable_artifacts_suppressed_by_clear_marker(options) { remove_clear_marker_file(options); @@ -556,5 +573,4 @@ fn save_snapshot_inner( remove_recovery_file(options); remove_recovery_recoverable_marker_file(options); } - Ok(Some(report)) } From a24bee467b008bd8ede48af2662e5f7310b91dd4 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:57:07 +0200 Subject: [PATCH 14/53] refactor(daemon): split startup service setup --- src/daemon/core.rs | 190 +++++++++++++++++++++++++-------------------- 1 file changed, 105 insertions(+), 85 deletions(-) diff --git a/src/daemon/core.rs b/src/daemon/core.rs index 26d114e5..11ae05fe 100644 --- a/src/daemon/core.rs +++ b/src/daemon/core.rs @@ -36,8 +36,8 @@ use super::tray::start_system_tray; #[cfg(feature = "tray")] use super::types::TrayStatusShared; use super::types::{ - AlreadyRunningError, BackendRunner, DaemonControlEvent, OverlayActionIntents, OverlayState, - VisibilityIntents, + AlreadyRunningError, BackendRunner, DaemonControlEvent, OverlayActionIntents, + OverlayActionPublisher, OverlayState, VisibilityIntents, VisibilityPublisher, }; use super::update_watch::start_update_watch; @@ -242,6 +242,106 @@ impl Daemon { } } + fn clear_stale_runtime_files() { + if let Err(err) = crate::daemon::clear_daemon_pid_file() { + warn!("Failed to clear stale daemon pid file on startup: {}", err); + } + if let Err(err) = crate::daemon::clear_daemon_toggle_request_file() { + warn!( + "Failed to clear stale daemon toggle request on startup: {}", + err + ); + } + } + + fn start_tray( + &mut self, + visibility: VisibilityPublisher, + action: OverlayActionPublisher, + quit_event: DaemonControlEvent, + ) { + if !self.tray_enabled { + info!("System tray disabled; running daemon without tray"); + return; + } + + let tray_overlay_active = self.overlay_active.clone(); + #[cfg(feature = "tray")] + let tray_status = self.tray_status.clone(); + #[cfg(not(feature = "tray"))] + let tray_status = (); + match start_system_tray( + visibility, + action, + quit_event, + tray_overlay_active, + tray_status, + ) { + Ok(tray_handle) => self.tray_thread = Some(tray_handle), + Err(err) => { + warn!("System tray unavailable: {}", err); + warn!( + "Continuing without system tray; use --no-tray or {NO_TRAY_ENV}=1 to silence this warning" + ); + } + } + } + + #[cfg(unix)] + fn install_signal_listener( + &mut self, + daemon_wake: &RuntimeWakeSource, + visibility: &VisibilityPublisher, + quit_event: &DaemonControlEvent, + ) -> Result<()> { + let listener_wake = daemon_wake.handle(); + let signal_visibility = visibility.clone(); + let signal_quit = quit_event.clone(); + self.signal_listener = Some( + crate::unix_signals::spawn_listener( + &DAEMON_SIGNALS, + move |sig| { + if signal_quit.is_raised() { + return; + } + match sig { + libc::SIGUSR1 => { + info!("Received SIGUSR1 - toggling overlay"); + if let Err(error) = signal_visibility.publish( + None, + true, + "SIGUSR1 visibility publication", + ) { + warn!("Failed to wake daemon after SIGUSR1: {error}"); + } + } + libc::SIGTERM | libc::SIGINT => { + info!( + "Received {} - initiating graceful shutdown", + if sig == libc::SIGTERM { + "SIGTERM" + } else { + "SIGINT" + } + ); + if let Err(error) = signal_quit.raise("daemon shutdown signal") { + warn!("Failed to wake daemon after shutdown signal: {error}"); + } + } + _ => warn!("Received unexpected signal: {sig}"), + } + }, + move || { + if let Err(err) = listener_wake.wake() { + warn!("Failed to wake daemon after signal publication: {err}"); + } + }, + ) + .context("Failed to register signal handler")?, + ); + Ok(()) + } + /// Run daemon with signal handling pub fn run(&mut self) -> Result<()> { info!("Starting wayscriber daemon"); @@ -254,15 +354,7 @@ impl Daemon { info!("Legacy raw SIGUSR1 toggle still works, but cannot carry launch args"); self.acquire_daemon_lock()?; - if let Err(err) = crate::daemon::clear_daemon_pid_file() { - warn!("Failed to clear stale daemon pid file on startup: {}", err); - } - if let Err(err) = crate::daemon::clear_daemon_toggle_request_file() { - warn!( - "Failed to clear stale daemon toggle request on startup: {}", - err - ); - } + Self::clear_stale_runtime_files(); let daemon_wake = RuntimeWakeSource::new().context("Failed to create daemon control wake descriptor")?; @@ -271,53 +363,7 @@ impl Daemon { let quit_event = DaemonControlEvent::new(self.should_quit.clone(), daemon_wake.handle()); #[cfg(unix)] - { - let listener_wake = daemon_wake.handle(); - let signal_visibility = visibility.clone(); - let signal_quit = quit_event.clone(); - self.signal_listener = Some( - crate::unix_signals::spawn_listener( - &DAEMON_SIGNALS, - move |sig| { - if signal_quit.is_raised() { - return; - } - match sig { - libc::SIGUSR1 => { - info!("Received SIGUSR1 - toggling overlay"); - if let Err(error) = signal_visibility.publish( - None, - true, - "SIGUSR1 visibility publication", - ) { - warn!("Failed to wake daemon after SIGUSR1: {error}"); - } - } - libc::SIGTERM | libc::SIGINT => { - info!( - "Received {} - initiating graceful shutdown", - if sig == libc::SIGTERM { - "SIGTERM" - } else { - "SIGINT" - } - ); - if let Err(error) = signal_quit.raise("daemon shutdown signal") { - warn!("Failed to wake daemon after shutdown signal: {error}"); - } - } - _ => warn!("Received unexpected signal: {sig}"), - } - }, - move || { - if let Err(err) = listener_wake.wake() { - warn!("Failed to wake daemon after signal publication: {err}"); - } - }, - ) - .context("Failed to register signal handler")?, - ); - } + self.install_signal_listener(&daemon_wake, &visibility, &quit_event)?; // Only publish the pid after SIGUSR1 is handled. A racing // `--daemon-toggle` sends SIGUSR1 to this pid, and the default action @@ -367,33 +413,7 @@ impl Daemon { return Err(err); } - // Start system tray (optional) - if self.tray_enabled { - let tray_overlay_active = self.overlay_active.clone(); - #[cfg(feature = "tray")] - let tray_status = self.tray_status.clone(); - #[cfg(not(feature = "tray"))] - let tray_status = (); - match start_system_tray( - visibility.clone(), - action, - quit_event.clone(), - tray_overlay_active, - tray_status, - ) { - Ok(tray_handle) => { - self.tray_thread = Some(tray_handle); - } - Err(err) => { - warn!("System tray unavailable: {}", err); - warn!( - "Continuing without system tray; use --no-tray or {NO_TRAY_ENV}=1 to silence this warning" - ); - } - } - } else { - info!("System tray disabled; running daemon without tray"); - } + self.start_tray(visibility.clone(), action, quit_event.clone()); // Update notices are independent of the tray: without it the answer // still reaches the desktop notification and the About window. From b5546030b77a650746846063ae1b0508e1accc72 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:58:03 +0200 Subject: [PATCH 15/53] refactor(daemon): centralize worker thread joins --- src/daemon/core.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/daemon/core.rs b/src/daemon/core.rs index 11ae05fe..404c99f0 100644 --- a/src/daemon/core.rs +++ b/src/daemon/core.rs @@ -823,18 +823,8 @@ impl Daemon { if let Some(listener) = self.global_shortcuts_listener.as_mut() { listener.request_shutdown(); } - if let Some(handle) = self.tray_thread.take() { - match handle.join() { - Ok(()) => info!("System tray thread joined"), - Err(err) => warn!("System tray thread panicked: {:?}", err), - } - } - if let Some(handle) = self.update_watch_thread.take() { - match handle.join() { - Ok(()) => info!("Update watcher thread joined"), - Err(err) => warn!("Update watcher thread panicked: {:?}", err), - } - } + join_daemon_thread(self.tray_thread.take(), "System tray thread"); + join_daemon_thread(self.update_watch_thread.take(), "Update watcher thread"); if let Some(listener) = self.global_shortcuts_listener.take() { match listener.join() { Ok(()) => info!("Global shortcuts listener thread joined"), @@ -894,6 +884,15 @@ impl Daemon { } } +fn join_daemon_thread(handle: Option>, label: &str) { + if let Some(handle) = handle { + match handle.join() { + Ok(()) => info!("{label} joined"), + Err(err) => warn!("{label} panicked: {err:?}"), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct DaemonLifecycleReadiness { command_queue: bool, From 297bc31131de7d9970b7c0215b06ca03478a199a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:58:55 +0200 Subject: [PATCH 16/53] refactor(daemon): isolate portal version reporting --- src/daemon/global_shortcuts.rs | 43 +++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/daemon/global_shortcuts.rs b/src/daemon/global_shortcuts.rs index 89091208..72377a04 100644 --- a/src/daemon/global_shortcuts.rs +++ b/src/daemon/global_shortcuts.rs @@ -216,29 +216,13 @@ async fn run_listener( ) .await? .context("org.freedesktop.portal.GlobalShortcuts unavailable")?; - match await_portal_operation( + let version = await_portal_operation( proxy.version(), &mut shutdown, "GlobalShortcuts version lookup", ) - .await? - { - Ok(version) => { - debug!("GlobalShortcuts portal interface version {}", version); - if version < 2 { - warn!( - "GlobalShortcuts portal version {} lacks reliable activation token support; overlay focus may require notification click", - version - ); - } - } - Err(err) => { - warn!( - "Failed to read GlobalShortcuts portal interface version: {}", - err - ); - } - } + .await?; + log_global_shortcuts_version(version); let session_handle = create_global_shortcuts_session(&connection, &proxy, &portal_app_id, &mut shutdown).await?; @@ -298,6 +282,27 @@ async fn run_listener( } } +#[cfg(feature = "portal")] +fn log_global_shortcuts_version(version: zbus::Result) { + match version { + Ok(version) => { + debug!("GlobalShortcuts portal interface version {}", version); + if version < 2 { + warn!( + "GlobalShortcuts portal version {} lacks reliable activation token support; overlay focus may require notification click", + version + ); + } + } + Err(err) => { + warn!( + "Failed to read GlobalShortcuts portal interface version: {}", + err + ); + } + } +} + #[cfg(all(test, feature = "portal"))] mod tests { use super::*; From dd1c5500b2892fe717a08fec7bb679b897973246 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:00:11 +0200 Subject: [PATCH 17/53] refactor(onboarding): isolate progress side effects --- src/backend/wayland/state/onboarding/first_run.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs index 7f19936a..3df68708 100644 --- a/src/backend/wayland/state/onboarding/first_run.rs +++ b/src/backend/wayland/state/onboarding/first_run.rs @@ -356,6 +356,15 @@ impl WaylandState { } } + self.finish_first_run_progress(changed, first_run_ui_changed, completed_now); + } + + fn finish_first_run_progress( + &mut self, + changed: bool, + first_run_ui_changed: bool, + completed_now: bool, + ) { if changed { self.save_onboarding_state(); } From 3c6439eb3d699d7f916eecd13a7eef66054d330c Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:00:54 +0200 Subject: [PATCH 18/53] refactor(wayland): isolate help overlay wheel handling --- src/backend/wayland/handlers/pointer/axis.rs | 43 ++++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index c831df20..b7ba6072 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -173,23 +173,7 @@ impl WaylandState { return; } - if self.input_state.show_help { - if scroll_direction != 0 { - let delta = if scroll_direction > 0 { 1.0 } else { -1.0 }; - let scroll_step = 48.0; - let max_scroll = self.input_state.help_overlay_scroll_max; - let mut next = self.input_state.help_overlay_scroll + delta * scroll_step; - if max_scroll > 0.0 { - next = next.clamp(0.0, max_scroll); - } else { - next = next.max(0.0); - } - if (next - self.input_state.help_overlay_scroll).abs() > f64::EPSILON { - self.input_state.help_overlay_scroll = next; - self.input_state.dirty_tracker.mark_full(); - self.input_state.needs_redraw = true; - } - } + if self.try_handle_help_axis(scroll_direction) { return; } if try_handle_board_picker_page_panel_axis( @@ -276,6 +260,31 @@ impl WaylandState { } } + fn try_handle_help_axis(&mut self, scroll_direction: i32) -> bool { + if !self.input_state.show_help { + return false; + } + if scroll_direction == 0 { + return true; + } + + let delta = if scroll_direction > 0 { 1.0 } else { -1.0 }; + let scroll_step = 48.0; + let max_scroll = self.input_state.help_overlay_scroll_max; + let mut next = self.input_state.help_overlay_scroll + delta * scroll_step; + if max_scroll > 0.0 { + next = next.clamp(0.0, max_scroll); + } else { + next = next.max(0.0); + } + if (next - self.input_state.help_overlay_scroll).abs() > f64::EPSILON { + self.input_state.help_overlay_scroll = next; + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + } + true + } + fn adjust_active_tool_thickness(&mut self, delta: f64, radial_menu_path: bool) { let eraser_active = self.input_state.active_tool() == Tool::Eraser; #[cfg(feature = "tablet-input")] From a9660f05f98fc8161643869afbd7e3f4346fb307 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:01:49 +0200 Subject: [PATCH 19/53] refactor(wayland): match seat capabilities once --- src/backend/wayland/handlers/seat.rs | 107 ++++++++++++++------------- 1 file changed, 54 insertions(+), 53 deletions(-) diff --git a/src/backend/wayland/handlers/seat.rs b/src/backend/wayland/handlers/seat.rs index 1823d072..252986c0 100644 --- a/src/backend/wayland/handlers/seat.rs +++ b/src/backend/wayland/handlers/seat.rs @@ -22,65 +22,66 @@ impl SeatHandler for WaylandState { seat: wl_seat::WlSeat, capability: Capability, ) { - if capability == Capability::Keyboard { - info!("Keyboard capability available"); - self.set_current_seat(Some(seat.clone())); - if self.seat_state.get_keyboard(qh, &seat, None).is_ok() { - debug!("Keyboard initialized"); - } - // IME: create the single supported text-input object alongside the - // first physical keyboard seat. Driven by enable()/disable() - // reconcile; see the explicit single-seat scope in text_input.rs. - if self.text_input.is_none() - && let Some(manager) = &self.text_input_manager - { - self.text_input = Some(manager.get_text_input(&seat, qh, ())); - self.text_input_seat = Some(seat.clone()); - self.text_input_focused = false; - self.text_input_enabled = false; - self.text_input_serial = 0; - self.text_input_cursor_update_pending = false; - self.text_input_external_change_pending = false; - self.text_input_cursor_update_blocked_until = None; - debug!("text-input-v3 object created for seat"); - } - } - - if capability == Capability::Pointer { - info!("Pointer capability available"); - match self.seat_state.get_pointer_with_theme( - qh, - &seat, - self.shm.wl_shm(), - self.compositor_state.create_surface(qh), - ThemeSpec::default(), - ) { - Ok(pointer) => { - debug!("Pointer initialized with theme"); - self.themed_pointer = Some(pointer); - self.current_pointer_shape = None; - self.cursor_hidden = false; + match capability { + Capability::Keyboard => { + info!("Keyboard capability available"); + self.set_current_seat(Some(seat.clone())); + if self.seat_state.get_keyboard(qh, &seat, None).is_ok() { + debug!("Keyboard initialized"); } - Err(err) => { - warn!("Pointer initialized without theme: {}", err); - if self.seat_state.get_pointer(qh, &seat).is_ok() { - debug!("Pointer initialized without theme fallback"); - } + // IME: create the single supported text-input object alongside the + // first physical keyboard seat. Driven by enable()/disable() + // reconcile; see the explicit single-seat scope in text_input.rs. + if self.text_input.is_none() + && let Some(manager) = &self.text_input_manager + { + self.text_input = Some(manager.get_text_input(&seat, qh, ())); + self.text_input_seat = Some(seat.clone()); + self.text_input_focused = false; + self.text_input_enabled = false; + self.text_input_serial = 0; + self.text_input_cursor_update_pending = false; + self.text_input_external_change_pending = false; + self.text_input_cursor_update_blocked_until = None; + debug!("text-input-v3 object created for seat"); } } - } - - if capability == Capability::Touch { - info!("Touch capability available"); - match self.seat_state.get_touch(qh, &seat) { - Ok(touch) => { - debug!("Touch initialized"); - self.touch = Some(touch); + Capability::Pointer => { + info!("Pointer capability available"); + match self.seat_state.get_pointer_with_theme( + qh, + &seat, + self.shm.wl_shm(), + self.compositor_state.create_surface(qh), + ThemeSpec::default(), + ) { + Ok(pointer) => { + debug!("Pointer initialized with theme"); + self.themed_pointer = Some(pointer); + self.current_pointer_shape = None; + self.cursor_hidden = false; + } + Err(err) => { + warn!("Pointer initialized without theme: {}", err); + if self.seat_state.get_pointer(qh, &seat).is_ok() { + debug!("Pointer initialized without theme fallback"); + } + } } - Err(err) => { - warn!("Touch initialization failed: {}", err); + } + Capability::Touch => { + info!("Touch capability available"); + match self.seat_state.get_touch(qh, &seat) { + Ok(touch) => { + debug!("Touch initialized"); + self.touch = Some(touch); + } + Err(err) => { + warn!("Touch initialization failed: {}", err); + } } } + _ => {} } #[cfg(feature = "tablet-input")] From 3b4bff8ee9d60f3d78b606d4ec51fe6c9c049a36 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:02:55 +0200 Subject: [PATCH 20/53] refactor(toolbar): split settings menu sections --- src/backend/wayland/toolbar/view/top/menus.rs | 104 +++++++++++------- 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/src/backend/wayland/toolbar/view/top/menus.rs b/src/backend/wayland/toolbar/view/top/menus.rs index d8b70d04..af53dc53 100644 --- a/src/backend/wayland/toolbar/view/top/menus.rs +++ b/src/backend/wayland/toolbar/view/top/menus.rs @@ -390,34 +390,7 @@ fn settings_menu_content(snapshot: &ToolbarSnapshot) -> Option> let mut nodes = Vec::new(); let mut y = 0.0; - if !customizing { - let control = model::layout_mode_control(snapshot.layout_mode); - if let model::ToolbarControlKind::Segmented(segmented) = &control.kind { - let segments = segmented.segments(); - if !segments.is_empty() { - let active = segmented.active_segment(); - let segment_w = row_item_width(MENU_CONTENT_W, segments.len(), MENU_ITEM_GAP); - for (index, segment) in segments.iter().enumerate() { - nodes.push(text_button( - format!("top.menu.settings.mode.{index}"), - ( - index as f64 * (segment_w + MENU_ITEM_GAP), - y, - segment_w, - MENU_SEGMENT_H, - ), - LabelSpec::new(segment.label.as_ref(), MENU_LABEL_FONT, true), - ButtonStyle::active(active == Some(segment.id)), - Some(Interaction::click( - segment.activation.clone(), - segment.tooltip.as_string(), - )), - )); - } - y += MENU_SEGMENT_H + MENU_TOGGLE_GAP; - } - } - } + append_layout_mode_control(snapshot, customizing, &mut nodes, &mut y); let toggle_w = row_item_width(MENU_CONTENT_W, 2, MENU_TOGGLE_GAP); let mut toggle_index = 0usize; @@ -446,20 +419,7 @@ fn settings_menu_content(snapshot: &ToolbarSnapshot) -> Option> y += MENU_TOGGLE_H + MENU_TOGGLE_GAP; } - for (index, notice) in model.notices().iter().enumerate() { - let bold = matches!( - notice.severity, - model::ToolbarSettingsNoticeSeverity::Warning - | model::ToolbarSettingsNoticeSeverity::Error - ); - let notice_h = settings_notice_height(notice.text.as_ref(), bold); - nodes.push(WidgetNode::decor( - format!("top.menu.settings.notice.{index}"), - (0.0, y, MENU_CONTENT_W, notice_h), - WidgetKind::Label(LabelSpec::new(notice.text.as_ref(), MENU_META_FONT, bold).wrapped()), - )); - y += notice_h + MENU_TOGGLE_GAP; - } + append_settings_notices(&model, &mut nodes, &mut y); let buttons = model.buttons(); if !buttons.is_empty() { @@ -643,6 +603,66 @@ fn settings_menu_content(snapshot: &ToolbarSnapshot) -> Option> Some(nodes) } +fn append_layout_mode_control( + snapshot: &ToolbarSnapshot, + customizing: bool, + nodes: &mut Vec, + y: &mut f64, +) { + if customizing { + return; + } + let control = model::layout_mode_control(snapshot.layout_mode); + let model::ToolbarControlKind::Segmented(segmented) = &control.kind else { + return; + }; + let segments = segmented.segments(); + if segments.is_empty() { + return; + } + let active = segmented.active_segment(); + let segment_w = row_item_width(MENU_CONTENT_W, segments.len(), MENU_ITEM_GAP); + for (index, segment) in segments.iter().enumerate() { + nodes.push(text_button( + format!("top.menu.settings.mode.{index}"), + ( + index as f64 * (segment_w + MENU_ITEM_GAP), + *y, + segment_w, + MENU_SEGMENT_H, + ), + LabelSpec::new(segment.label.as_ref(), MENU_LABEL_FONT, true), + ButtonStyle::active(active == Some(segment.id)), + Some(Interaction::click( + segment.activation.clone(), + segment.tooltip.as_string(), + )), + )); + } + *y += MENU_SEGMENT_H + MENU_TOGGLE_GAP; +} + +fn append_settings_notices( + model: &model::ToolbarSettingsModel, + nodes: &mut Vec, + y: &mut f64, +) { + for (index, notice) in model.notices().iter().enumerate() { + let bold = matches!( + notice.severity, + model::ToolbarSettingsNoticeSeverity::Warning + | model::ToolbarSettingsNoticeSeverity::Error + ); + let notice_h = settings_notice_height(notice.text.as_ref(), bold); + nodes.push(WidgetNode::decor( + format!("top.menu.settings.notice.{index}"), + (0.0, *y, MENU_CONTENT_W, notice_h), + WidgetKind::Label(LabelSpec::new(notice.text.as_ref(), MENU_META_FONT, bold).wrapped()), + )); + *y += notice_h + MENU_TOGGLE_GAP; + } +} + fn settings_notice_height(text: &str, bold: bool) -> f64 { let style = UiTextStyle { family: crate::ui::theme::toolbar::FONT_FAMILY_DEFAULT, From 4237dcb216b30c22df636f2b67a97bbbad4881d3 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:03:55 +0200 Subject: [PATCH 21/53] refactor(wayland): isolate skipped render handling --- .../wayland/backend/event_loop/render.rs | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/render.rs b/src/backend/wayland/backend/event_loop/render.rs index 22747156..555fb15d 100644 --- a/src/backend/wayland/backend/event_loop/render.rs +++ b/src/backend/wayland/backend/event_loop/render.rs @@ -160,36 +160,40 @@ pub(super) fn maybe_render( } } } else { - let skip_reason = if !state.surface.is_configured() { - Some(PerfRenderSkipReason::SurfaceUnconfigured) - } else if !state.input_state.needs_redraw { - Some(PerfRenderSkipReason::NoRedraw) - } else if vsync_enabled && state.surface.frame_callback_pending() { - Some(PerfRenderSkipReason::FrameCallbackPending) - } else if !vsync_enabled && !frame_time_ok { - Some(PerfRenderSkipReason::FpsCap) - } else { - None - }; - if let Some(reason) = skip_reason { - state.record_perf_render_skip(reason); - } - state.render_layer_toolbars_if_needed(); - if state.input_state.needs_redraw { - if vsync_enabled && state.surface.frame_callback_pending() { - debug!("Main loop: Skipping render - frame callback already pending"); - } else if !vsync_enabled && !frame_time_ok { - debug!( - "Main loop: Skipping render - frame rate cap ({} FPS)", - state.config.performance.max_fps_no_vsync - ); - } - } + record_skipped_render(state, vsync_enabled, frame_time_ok); } None } +fn record_skipped_render(state: &mut WaylandState, vsync_enabled: bool, frame_time_ok: bool) { + let skip_reason = if !state.surface.is_configured() { + Some(PerfRenderSkipReason::SurfaceUnconfigured) + } else if !state.input_state.needs_redraw { + Some(PerfRenderSkipReason::NoRedraw) + } else if vsync_enabled && state.surface.frame_callback_pending() { + Some(PerfRenderSkipReason::FrameCallbackPending) + } else if !vsync_enabled && !frame_time_ok { + Some(PerfRenderSkipReason::FpsCap) + } else { + None + }; + if let Some(reason) = skip_reason { + state.record_perf_render_skip(reason); + } + state.render_layer_toolbars_if_needed(); + if state.input_state.needs_redraw { + if vsync_enabled && state.surface.frame_callback_pending() { + debug!("Main loop: Skipping render - frame callback already pending"); + } else if !vsync_enabled && !frame_time_ok { + debug!( + "Main loop: Skipping render - frame rate cap ({} FPS)", + state.config.performance.max_fps_no_vsync + ); + } + } +} + #[cfg(test)] mod tests { use super::*; From cbcbd06aad3bfc1424266284c8def7254826e181 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:05:14 +0200 Subject: [PATCH 22/53] refactor(wayland): split global protocol binding --- src/backend/wayland/backend/setup.rs | 124 ++++++++++++++++----------- 1 file changed, 76 insertions(+), 48 deletions(-) diff --git a/src/backend/wayland/backend/setup.rs b/src/backend/wayland/backend/setup.rs index 5a6d8aa8..012049a2 100644 --- a/src/backend/wayland/backend/setup.rs +++ b/src/backend/wayland/backend/setup.rs @@ -46,22 +46,17 @@ pub(super) struct WaylandSetup { pub(super) layer_shell_available: bool, } -pub(super) fn setup_wayland() -> Result { - // Connect to Wayland compositor - let conn = Connection::connect_to_env().context("Failed to connect to Wayland compositor")?; - debug!("Connected to Wayland display"); - - // Initialize registry and event queue - let (globals, event_queue) = - registry_queue_init(&conn).context("Failed to initialize Wayland registry")?; - let qh = event_queue.handle(); - - // Bind global interfaces - let compositor_state = - CompositorState::bind(&globals, &qh).context("wl_compositor not available")?; - debug!("Bound compositor"); - - let layer_shell = match LayerShell::bind(&globals, &qh) { +type ShellGlobals = ( + Option, + Option, + Option, +); + +fn bind_shell_globals( + globals: &wayland_client::globals::GlobalList, + qh: &wayland_client::QueueHandle, +) -> Result { + let layer_shell = match LayerShell::bind(globals, qh) { Ok(shell) => { debug!("Bound layer shell"); Some(shell) @@ -78,8 +73,7 @@ pub(super) fn setup_wayland() -> Result { None } }; - - let xdg_shell = match XdgShell::bind(&globals, &qh) { + let xdg_shell = match XdgShell::bind(globals, qh) { Ok(shell) => { debug!("Bound xdg-shell"); Some(shell) @@ -89,8 +83,7 @@ pub(super) fn setup_wayland() -> Result { None } }; - - let activation = match ActivationState::bind(&globals, &qh) { + let activation = match ActivationState::bind(globals, qh) { Ok(state) => { debug!("Bound xdg-activation"); Some(state) @@ -100,33 +93,23 @@ pub(super) fn setup_wayland() -> Result { None } }; - if layer_shell.is_none() && xdg_shell.is_none() { return Err(anyhow::anyhow!( "Wayland compositor does not expose layer-shell or xdg-shell protocols" )); } + Ok((layer_shell, xdg_shell, activation)) +} - let shm = Shm::bind(&globals, &qh).context("wl_shm not available")?; - debug!("Bound shared memory"); - - let output_state = OutputState::new(&globals, &qh); - debug!("Initialized output state"); - - let seat_state = SeatState::new(&globals, &qh); - debug!("Initialized seat state"); - - let registry_state = RegistryState::new(&globals); - let pointer_constraints_state = PointerConstraintsState::bind(&globals, &qh); - let relative_pointer_state = RelativePointerState::bind(&globals, &qh); - if pointer_constraints_state.bound_global().is_ok() { - debug!("Pointer constraints global available"); - } else { - debug!("Pointer constraints global not available"); - } - +fn bind_capture_globals( + globals: &wayland_client::globals::GlobalList, + qh: &wayland_client::QueueHandle, +) -> ( + Option, + Option, +) { let screencopy_manager = match globals.bind::( - &qh, + qh, 1..=MAX_SHM_SCREENCOPY_VERSION, (), ) { @@ -142,12 +125,11 @@ pub(super) fn setup_wayland() -> Result { None } }; - let ext_image_copy_manager = globals - .bind::(&qh, 1..=1, ()) + .bind::(qh, 1..=1, ()) .ok(); let ext_output_source_manager = globals - .bind::(&qh, 1..=1, ()) + .bind::(qh, 1..=1, ()) .ok(); let ext_image_copy_managers = match (ext_image_copy_manager, ext_output_source_manager) { (Some(capture), Some(output_source)) => { @@ -163,11 +145,14 @@ pub(super) fn setup_wayland() -> Result { None } }; + (screencopy_manager, ext_image_copy_managers) +} - // IME / text-input-v3 for the text and sticky-note tools. Optional: when - // the compositor lacks it, editing falls back to the raw keysym path - // (single-key characters only). - let text_input_manager = match globals.bind::(&qh, 1..=1, ()) { +fn bind_text_input_manager( + globals: &wayland_client::globals::GlobalList, + qh: &wayland_client::QueueHandle, +) -> Option { + match globals.bind::(qh, 1..=1, ()) { Ok(manager) => { debug!("Bound zwp_text_input_manager_v3"); Some(manager) @@ -179,7 +164,50 @@ pub(super) fn setup_wayland() -> Result { ); None } - }; + } +} + +pub(super) fn setup_wayland() -> Result { + // Connect to Wayland compositor + let conn = Connection::connect_to_env().context("Failed to connect to Wayland compositor")?; + debug!("Connected to Wayland display"); + + // Initialize registry and event queue + let (globals, event_queue) = + registry_queue_init(&conn).context("Failed to initialize Wayland registry")?; + let qh = event_queue.handle(); + + // Bind global interfaces + let compositor_state = + CompositorState::bind(&globals, &qh).context("wl_compositor not available")?; + debug!("Bound compositor"); + + let (layer_shell, xdg_shell, activation) = bind_shell_globals(&globals, &qh)?; + + let shm = Shm::bind(&globals, &qh).context("wl_shm not available")?; + debug!("Bound shared memory"); + + let output_state = OutputState::new(&globals, &qh); + debug!("Initialized output state"); + + let seat_state = SeatState::new(&globals, &qh); + debug!("Initialized seat state"); + + let registry_state = RegistryState::new(&globals); + let pointer_constraints_state = PointerConstraintsState::bind(&globals, &qh); + let relative_pointer_state = RelativePointerState::bind(&globals, &qh); + if pointer_constraints_state.bound_global().is_ok() { + debug!("Pointer constraints global available"); + } else { + debug!("Pointer constraints global not available"); + } + + let (screencopy_manager, ext_image_copy_managers) = bind_capture_globals(&globals, &qh); + + // IME / text-input-v3 for the text and sticky-note tools. Optional: when + // the compositor lacks it, editing falls back to the raw keysym path + // (single-key characters only). + let text_input_manager = bind_text_input_manager(&globals, &qh); let layer_shell_available = layer_shell.is_some(); From ac87544a6b77186a1688062a1aeed7c860450791 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:07:12 +0200 Subject: [PATCH 23/53] refactor(wayland): split modal pointer motion --- .../wayland/handlers/pointer/motion.rs | 82 ++++++++++++------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs index c145327c..365fb366 100644 --- a/src/backend/wayland/handlers/pointer/motion.rs +++ b/src/backend/wayland/handlers/pointer/motion.rs @@ -16,38 +16,11 @@ impl WaylandState { on_toolbar: bool, inline_active: bool, ) { - if self.input_state.region_is_active() { - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let Some((x, y)) = screen_position { - self.set_current_mouse(x.round() as i32, y.round() as i32); - self.update_region_selection(RegionInputSource::Pointer, x, y); - } - self.update_pointer_cursor(on_toolbar || self.pointer_over_toolbar(), conn); + if self.try_handle_region_pointer_motion(conn, event, on_toolbar) { return; } - if self.input_state.eyedropper_is_active() { - let inline_hover = !on_toolbar - && self.inline_toolbars_active() - && self.toolbar.is_visible() - && self.inline_toolbar_motion(event.position); - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let Some((x, y)) = screen_position { - self.set_current_mouse(x.round() as i32, y.round() as i32); - self.update_eyedropper_hover(x, y); - } - self.update_pointer_cursor( - on_toolbar || inline_hover || self.pointer_over_toolbar(), - conn, - ); + if self.try_handle_eyedropper_pointer_motion(conn, event, on_toolbar) { return; } @@ -277,4 +250,55 @@ impl WaylandState { false, ); } + + fn try_handle_region_pointer_motion( + &mut self, + conn: &Connection, + event: &PointerEvent, + on_toolbar: bool, + ) -> bool { + if !self.input_state.region_is_active() { + return false; + } + let screen_position = if on_toolbar { + self.toolbar_surface_screen_coords(&event.surface, event.position) + } else { + Some(event.position) + }; + if let Some((x, y)) = screen_position { + self.set_current_mouse(x.round() as i32, y.round() as i32); + self.update_region_selection(RegionInputSource::Pointer, x, y); + } + self.update_pointer_cursor(on_toolbar || self.pointer_over_toolbar(), conn); + true + } + + fn try_handle_eyedropper_pointer_motion( + &mut self, + conn: &Connection, + event: &PointerEvent, + on_toolbar: bool, + ) -> bool { + if !self.input_state.eyedropper_is_active() { + return false; + } + let inline_hover = !on_toolbar + && self.inline_toolbars_active() + && self.toolbar.is_visible() + && self.inline_toolbar_motion(event.position); + let screen_position = if on_toolbar { + self.toolbar_surface_screen_coords(&event.surface, event.position) + } else { + Some(event.position) + }; + if let Some((x, y)) = screen_position { + self.set_current_mouse(x.round() as i32, y.round() as i32); + self.update_eyedropper_hover(x, y); + } + self.update_pointer_cursor( + on_toolbar || inline_hover || self.pointer_over_toolbar(), + conn, + ); + true + } } From c5693159defadbba5b33250de1ffff73d8783803 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:10:58 +0200 Subject: [PATCH 24/53] refactor(capture): split completion routing --- .../wayland/backend/event_loop/capture.rs | 133 ++++++++++-------- 1 file changed, 78 insertions(+), 55 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 9afa6fd1..d1f38750 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -8,7 +8,7 @@ use std::time::{Duration, Instant}; use super::super::super::state::{OverlaySuppression, WaylandState}; use super::super::helpers::friendly_capture_error; use crate::capture::file::{FileSaveConfig, expand_tilde}; -use crate::capture::{CaptureOutcome, CapturePoll, ImageOperationKind}; +use crate::capture::{CaptureOutcome, CapturePoll, CaptureRequestId, ImageOperationKind}; use crate::config::Action; use crate::input::state::PendingBackendAction; use crate::notification; @@ -317,58 +317,38 @@ fn handle_frozen_toggle(state: &mut WaylandState) { } } -fn handle_capture_results(state: &mut WaylandState) { - let (id, operation, outcome) = match state.capture.manager_mut().poll() { - CapturePoll::Idle | CapturePoll::Pending { .. } => return, - CapturePoll::Ready { - id, - operation, - outcome, - } => (id, operation, outcome), - CapturePoll::WorkerFailed { - active_id, - operation, - error, - } => { - let pending_board = - active_id.and_then(|id| state.capture.take_pending_board_paste_for(id)); - if let Some(id) = active_id { - let _ = state.capture.consume_accepted(id); - } - if pending_board.is_some() { - state.capture.finish_capture_lifecycle(); - let message = operation - .filter(|operation| *operation == ImageOperationKind::Screenshot) - .map(|_| friendly_capture_error(&error)) - .unwrap_or_else(|| "Capture services stopped unexpectedly.".to_string()); - warn!("Board region capture worker failed: {error}"); - state.input_state.push_toast( - ToastPriority::Critical, - "capture", - Toast::error(message), - ); - return; - } - handle_capture_manager_failure(state, operation, &error); - return; - } - }; - - if !state.capture.consume_accepted(id) { - let expected = state.capture.accepted_id(); - state.capture.manager_mut().mark_unhealthy(); - handle_capture_manager_failure( - state, - Some(operation), - &format!("capture completion {id} did not match accepted identity {expected:?}"), - ); +fn handle_capture_worker_failure( + state: &mut WaylandState, + active_id: Option, + operation: Option, + error: &str, +) { + let pending_board = active_id.and_then(|id| state.capture.take_pending_board_paste_for(id)); + if let Some(id) = active_id { + let _ = state.capture.consume_accepted(id); + } + if pending_board.is_some() { + state.capture.finish_capture_lifecycle(); + let message = operation + .filter(|operation| *operation == ImageOperationKind::Screenshot) + .map(|_| friendly_capture_error(error)) + .unwrap_or_else(|| "Capture services stopped unexpectedly.".to_string()); + warn!("Board region capture worker failed: {error}"); + state + .input_state + .push_toast(ToastPriority::Critical, "capture", Toast::error(message)); return; } + handle_capture_manager_failure(state, operation, error); +} - info!("Capture completed"); - +fn resolve_board_capture_outcome( + state: &mut WaylandState, + id: CaptureRequestId, + outcome: CaptureOutcome, +) -> Option { let pending_board = state.capture.take_pending_board_paste_for(id); - let outcome = match (outcome, pending_board) { + match (outcome, pending_board) { (CaptureOutcome::RenderedImageReady(image), Some(pending)) => { state.capture.finish_capture_lifecycle(); let embedded = crate::draw::EmbeddedImage { @@ -380,7 +360,7 @@ fn handle_capture_results(state: &mut WaylandState) { state .input_state .insert_captured_image(embedded, &pending.target); - return; + None } (CaptureOutcome::RenderedImageReady(_), None) => { state.capture.finish_capture_lifecycle(); @@ -390,7 +370,7 @@ fn handle_capture_results(state: &mut WaylandState) { "capture.region.board", Toast::error("Region was not added to the board."), ); - return; + None } (CaptureOutcome::Failed { operation, message }, Some(_)) => { state.capture.finish_capture_lifecycle(); @@ -405,12 +385,12 @@ fn handle_capture_results(state: &mut WaylandState) { "capture", Toast::error(friendly_error), ); - return; + None } (CaptureOutcome::Cancelled { operation, reason }, Some(_)) => { state.capture.finish_capture_lifecycle(); info!("{} cancelled: {}", operation.saved_log_label(), reason); - return; + None } (unexpected, Some(_)) => { state.capture.finish_capture_lifecycle(); @@ -420,9 +400,21 @@ fn handle_capture_results(state: &mut WaylandState) { "capture.region.board", Toast::error("Region was not added to the board."), ); - return; + None } - (outcome, None) => outcome, + (outcome, None) => Some(outcome), + } +} + +fn handle_capture_results(state: &mut WaylandState) { + let Some((id, outcome)) = poll_accepted_capture(state) else { + return; + }; + + info!("Capture completed"); + + let Some(outcome) = resolve_board_capture_outcome(state, id, outcome) else { + return; }; // Restore overlay. @@ -620,6 +612,37 @@ fn handle_capture_results(state: &mut WaylandState) { } } +fn poll_accepted_capture(state: &mut WaylandState) -> Option<(CaptureRequestId, CaptureOutcome)> { + let (id, operation, outcome) = match state.capture.manager_mut().poll() { + CapturePoll::Idle | CapturePoll::Pending { .. } => return None, + CapturePoll::Ready { + id, + operation, + outcome, + } => (id, operation, outcome), + CapturePoll::WorkerFailed { + active_id, + operation, + error, + } => { + handle_capture_worker_failure(state, active_id, operation, &error); + return None; + } + }; + if state.capture.consume_accepted(id) { + return Some((id, outcome)); + } + + let expected = state.capture.accepted_id(); + state.capture.manager_mut().mark_unhealthy(); + handle_capture_manager_failure( + state, + Some(operation), + &format!("capture completion {id} did not match accepted identity {expected:?}"), + ); + None +} + fn handle_capture_manager_failure( state: &mut WaylandState, operation: Option, From 7ad1c90be42c3fc02dc37816400f9a3470818df7 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:12:53 +0200 Subject: [PATCH 25/53] refactor(wayland): split modal keyboard routing --- src/backend/wayland/handlers/keyboard/mod.rs | 205 ++++++++++--------- 1 file changed, 106 insertions(+), 99 deletions(-) diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index 64fafcf6..fc5ce358 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -149,107 +149,10 @@ impl KeyboardHandler for WaylandState { // A finished scan card is transient chrome: the next interaction of any // kind takes it away rather than making the user wait it out. self.input_state.dismiss_ocr_scan_result(); - if self.input_state.region_is_engaged() { - if matches!(key, Key::Space) && self.toggle_region_window_snap() { - self.update_pointer_cursor(false, conn); - return; - } - if let Some(direction) = - region_window_snap_direction(key, self.input_state.modifiers.logo) - && self.navigate_region_window_snap(direction) - { - return; - } - if matches!(key, Key::Return) && self.choose_hovered_region_window() { - return; - } - let action = self.input_state.action_for_key(key); - if let Some(action) = action { - if self - .input_state - .refuse_region_capture_while_screen_modal_engaged(action) - { - return; - } - if self - .input_state - .refuse_measure_mode_while_screen_modal_engaged(action) - { - return; - } - if action == Action::MeasureMode { - self.handle_measure_mode_action(); - return; - } - if self - .input_state - .capture_region_action_reaches_backend(action) - { - // Route the opening action directly while its picker owns - // the modal. Going through InputState would clear held - // modifiers before the backend can distinguish same-action - // cancellation from a different-action refusal. - self.handle_capture_action(action); - return; - } - if action == Action::CopyTextFromScreen - && self.input_state.region_state().purpose() - == Some(crate::input::state::RegionPurposeTag::Ocr) - { - self.cancel_ocr(); - return; - } - } - if self.input_state.region_state().is_review() - && let Some(review_action) = region_review_key_action( - key, - self.input_state.modifiers.ctrl, - self.input_state.modifiers.shift, - ) - { - match review_action { - RegionReviewKeyAction::Nudge(dx, dy) => { - self.nudge_region_review(dx, dy); - } - RegionReviewKeyAction::Submit(action) => { - self.submit_region_review_action(action); - } - } - return; - } - if region_select_all_pressed( - self.input_state.region_is_active(), - self.input_state.region_state().purpose(), - self.input_state.modifiers.ctrl, - key, - ) { - self.submit_whole_region_capture(); - return; - } - // Every other shortcut is swallowed while the selector is up so a - // key cannot change the active tool mid-drag. - if matches!(key, Key::Escape) { - self.cancel_active_region_selector(); - } + if self.try_handle_region_key(conn, key) { return; } - if self.input_state.eyedropper_is_engaged() { - let action = self.input_state.action_for_key(key); - if action.is_some_and(|action| { - self.input_state - .refuse_region_capture_while_screen_modal_engaged(action) - }) { - return; - } - if action.is_some_and(|action| { - self.input_state - .refuse_measure_mode_while_screen_modal_engaged(action) - }) { - return; - } - if matches!(key, Key::Escape) || action == Some(Action::PickScreenColor) { - self.cancel_eyedropper(); - } + if self.try_handle_eyedropper_key(key) { return; } if matches!(key, Key::Escape) @@ -436,6 +339,110 @@ impl WaylandState { self.key_repeat_next_tick = None; } + fn try_handle_region_key(&mut self, conn: &Connection, key: Key) -> bool { + if !self.input_state.region_is_engaged() { + return false; + } + if matches!(key, Key::Space) && self.toggle_region_window_snap() { + self.update_pointer_cursor(false, conn); + return true; + } + if let Some(direction) = region_window_snap_direction(key, self.input_state.modifiers.logo) + && self.navigate_region_window_snap(direction) + { + return true; + } + if matches!(key, Key::Return) && self.choose_hovered_region_window() { + return true; + } + let action = self.input_state.action_for_key(key); + if let Some(action) = action { + if self + .input_state + .refuse_region_capture_while_screen_modal_engaged(action) + { + return true; + } + if self + .input_state + .refuse_measure_mode_while_screen_modal_engaged(action) + { + return true; + } + if action == Action::MeasureMode { + self.handle_measure_mode_action(); + return true; + } + if self + .input_state + .capture_region_action_reaches_backend(action) + { + self.handle_capture_action(action); + return true; + } + if action == Action::CopyTextFromScreen + && self.input_state.region_state().purpose() + == Some(crate::input::state::RegionPurposeTag::Ocr) + { + self.cancel_ocr(); + return true; + } + } + if self.input_state.region_state().is_review() + && let Some(review_action) = region_review_key_action( + key, + self.input_state.modifiers.ctrl, + self.input_state.modifiers.shift, + ) + { + match review_action { + RegionReviewKeyAction::Nudge(dx, dy) => { + self.nudge_region_review(dx, dy); + } + RegionReviewKeyAction::Submit(action) => { + self.submit_region_review_action(action); + } + } + return true; + } + if region_select_all_pressed( + self.input_state.region_is_active(), + self.input_state.region_state().purpose(), + self.input_state.modifiers.ctrl, + key, + ) { + self.submit_whole_region_capture(); + return true; + } + if matches!(key, Key::Escape) { + self.cancel_active_region_selector(); + } + true + } + + fn try_handle_eyedropper_key(&mut self, key: Key) -> bool { + if !self.input_state.eyedropper_is_engaged() { + return false; + } + let action = self.input_state.action_for_key(key); + if action.is_some_and(|action| { + self.input_state + .refuse_region_capture_while_screen_modal_engaged(action) + }) { + return true; + } + if action.is_some_and(|action| { + self.input_state + .refuse_measure_mode_while_screen_modal_engaged(action) + }) { + return true; + } + if matches!(key, Key::Escape) || action == Some(Action::PickScreenColor) { + self.cancel_eyedropper(); + } + true + } + /// Duration until the next repeat fires, for the event-loop timeout. The /// loop otherwise sleeps until a real event and would never wake to /// repeat a held key. From f77594c0d4208cefd6ed0b1d0da3104472081ff0 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:17:02 +0200 Subject: [PATCH 26/53] refactor(wayland): split pointer press routing --- src/backend/wayland/handlers/pointer/press.rs | 441 ++++++++++-------- 1 file changed, 240 insertions(+), 201 deletions(-) diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index dd87f20e..2d44765a 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -46,110 +46,15 @@ impl WaylandState { .clear_help_overlay_press_for(help_press_source); } - if self.input_state.region_is_active() { - if on_toolbar || self.pointer_over_toolbar() { - // A toolbar interaction ends the region first, then runs - // normally; the click never lands on the selector. - self.cancel_region_for_toolbar_interaction(); - } else { - match button { - BTN_LEFT => { - if let Some(action) = self.region_review_action_at(event.position) { - self.submit_region_review_action(action); - // The toggle keeps Review active, so its release is - // consumed by the active-region branch. Arming the - // generic post-modal latch here would swallow an - // unrelated canvas release after a keyboard exit. - if review_action_suppresses_next_release(action) { - self.suppress_next_release_from(RegionInputSource::Pointer); - } - return; - } - if self.region_review_bar_contains(event.position) { - return; - } - self.begin_region_selection( - RegionInputSource::Pointer, - event.position.0, - event.position.1, - ); - } - BTN_RIGHT => { - self.cancel_active_region_selector(); - self.suppress_next_release_from(RegionInputSource::Pointer); - } - _ => {} - } - return; - } - } - - if self.input_state.eyedropper_is_active() { - if on_toolbar || self.pointer_over_toolbar() { - self.cancel_eyedropper(); - } else { - match button { - BTN_LEFT => { - self.sample_eyedropper(event.position.0, event.position.1); - self.suppress_next_release_from(RegionInputSource::Pointer); - } - BTN_RIGHT => { - self.cancel_eyedropper(); - self.suppress_next_release_from(RegionInputSource::Pointer); - } - _ => {} - } - return; - } - } - - // Block pointer input when tour is active - if self.input_state.tour_active { + if self.handle_region_pointer_press(event, on_toolbar, button) { return; } - // The help overlay is modal for pointer input: swallow the press so a - // click never starts a stroke on the canvas beneath it, but record the - // help target under the press. The release enforces a same-target - // contract before it runs a row (or dismisses), so a press that starts - // on bare chrome (or outside) and drags onto a clickable row — notably - // the destructive Clear row — never executes it. Toolbar surfaces report - // toolbar-local coordinates, so convert to screen space just like the - // toast/status-HUD press guards below. - if self.input_state.show_help { - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - match screen_position { - Some((sx, sy)) => self.input_state.note_help_overlay_press( - help_press_source, - sx.round() as i32, - sy.round() as i32, - ), - None => { - self.input_state - .clear_help_overlay_press_for(help_press_source); - } - } + if self.handle_eyedropper_pointer_press(event, on_toolbar, button) { return; } - // Handle command palette clicks - if self.input_state.command_palette_is_engaged() { - if button == BTN_LEFT { - let screen_width = self.surface.width(); - let screen_height = self.surface.height(); - if self.input_state.handle_command_palette_click( - event.position.0 as i32, - event.position.1 as i32, - screen_width, - screen_height, - ) { - self.suppress_next_release_from(RegionInputSource::Pointer); - } - } + if self.handle_modal_pointer_press(event, on_toolbar, button, help_press_source) { return; } @@ -168,83 +73,11 @@ impl WaylandState { self.is_move_dragging() ); } - if inline_active { - // Inline bars share the canvas surface, so their swatch recolor - // gesture is resolved here rather than in the `on_toolbar` branch. - if button == BTN_RIGHT - && self.inline_toolbar_secondary_press(event.position, Some(conn), Some(qh)) - { - self.refresh_keyboard_interactivity(); - return; - } - if button == BTN_LEFT && self.inline_toolbar_press(event.position, Some(conn), Some(qh)) - { - drag_log(|| { - format!( - "pointer press: inline handled, drag_active={}, pos=({:.3}, {:.3}), surface={}", - self.toolbar_dragging(), - event.position.0, - event.position.1, - surface_id(&event.surface) - ) - }); - if self.is_move_dragging() { - self.lock_pointer_for_drag(qh, &event.surface); - } - return; - } - if self.pointer_over_toolbar() { - if button == BTN_LEFT { - self.dismiss_top_toolbar_menus(); - } - return; - } + if inline_active && self.handle_inline_pointer_press(conn, qh, event, button) { + return; } if on_toolbar { - // Secondary click on a quick-color swatch opens the picker bound to - // that palette slot. Every other toolbar control ignores the button, - // so the press is consumed only when it lands on a swatch. - if button == BTN_RIGHT - && let Some(index) = self - .toolbar - .quick_color_slot_at(&event.surface, event.position) - { - self.handle_toolbar_event( - ToolbarEvent::EditQuickColor { index }, - Some(conn), - Some(qh), - ); - self.toolbar.mark_dirty(); - self.input_state.needs_redraw = true; - self.refresh_keyboard_interactivity(); - return; - } - let mut handled = false; - if button == BTN_LEFT - && let Some((intent, drag)) = - self.toolbar.pointer_press(&event.surface, event.position) - { - handled = true; - let toolbar_event = intent_to_event(intent, self.toolbar.last_snapshot()); - if matches!(toolbar_event, ToolbarEvent::MoveTopToolbar { .. }) && drag { - self.lock_pointer_for_drag(qh, &event.surface); - } - log::info!( - "toolbar press: drag_start={}, surface={}, seat={:?}, inline_active={}", - drag, - surface_id(&event.surface), - self.current_seat_id(), - self.inline_toolbars_active() - ); - self.set_toolbar_dragging(drag); - self.handle_toolbar_event(toolbar_event, Some(conn), Some(qh)); - self.toolbar.mark_dirty(); - self.input_state.needs_redraw = true; - self.refresh_keyboard_interactivity(); - } - if button == BTN_LEFT && !handled { - self.dismiss_top_toolbar_menus(); - } + self.handle_toolbar_pointer_press(conn, qh, event, button); return; } else if self.pointer_over_toolbar() { self.finish_toolbar_item_drag(false); @@ -256,34 +89,8 @@ impl WaylandState { return; } - if button == BTN_LEFT { - let screen_x = event.position.0.round() as i32; - let screen_y = event.position.1.round() as i32; - self.set_pending_toast_press(None); - if let Some(pressed) = self.input_state.toast_press_at(screen_x, screen_y) { - self.set_pending_toast_press(Some(pressed)); - return; - } - // Interactive status HUD: report the hit on press without side - // effects; the matching surface opens on release-inside. - self.set_pending_status_hud_press(false); - if self.input_state.status_hud_contains(screen_x, screen_y) { - self.set_pending_status_hud_press(true); - return; - } - // Interactive zoom chip: same press→release contract. Any press - // inside the pill is swallowed here and recorded as `Passive` (the - // `NN%` readout / inter-piece gap) or `Button(kind)`, so its release - // stays consumed either way; a `Button` release dispatches its zoom - // action only when it lands on the SAME button. The cached layout - // only exists while the chip is visible, so `zoom_chip_contains` is - // false when it is hidden and the press falls through. - self.set_pending_zoom_chip_press(ZoomChipPress::None); - if self.input_state.zoom_chip_contains(screen_x, screen_y) { - let pressed = self.input_state.zoom_chip_press_at(screen_x, screen_y); - self.set_pending_zoom_chip_press(pressed); - return; - } + if button == BTN_LEFT && self.handle_overlay_pointer_press(event.position) { + return; } debug!( @@ -317,6 +124,238 @@ impl WaylandState { self.input_state.needs_redraw = true; } + fn handle_region_pointer_press( + &mut self, + event: &PointerEvent, + on_toolbar: bool, + button: u32, + ) -> bool { + if !self.input_state.region_is_active() { + return false; + } + if on_toolbar || self.pointer_over_toolbar() { + // A toolbar interaction ends the region first, then runs normally; + // the click never lands on the selector. + self.cancel_region_for_toolbar_interaction(); + return false; + } + match button { + BTN_LEFT => { + if let Some(action) = self.region_review_action_at(event.position) { + self.submit_region_review_action(action); + // The toggle keeps Review active, so its release is consumed + // by the active-region branch. Arming the generic post-modal + // latch would swallow an unrelated canvas release later. + if review_action_suppresses_next_release(action) { + self.suppress_next_release_from(RegionInputSource::Pointer); + } + } else if !self.region_review_bar_contains(event.position) { + self.begin_region_selection( + RegionInputSource::Pointer, + event.position.0, + event.position.1, + ); + } + } + BTN_RIGHT => { + self.cancel_active_region_selector(); + self.suppress_next_release_from(RegionInputSource::Pointer); + } + _ => {} + } + true + } + + fn handle_eyedropper_pointer_press( + &mut self, + event: &PointerEvent, + on_toolbar: bool, + button: u32, + ) -> bool { + if !self.input_state.eyedropper_is_active() { + return false; + } + if on_toolbar || self.pointer_over_toolbar() { + self.cancel_eyedropper(); + return false; + } + match button { + BTN_LEFT => { + self.sample_eyedropper(event.position.0, event.position.1); + self.suppress_next_release_from(RegionInputSource::Pointer); + } + BTN_RIGHT => { + self.cancel_eyedropper(); + self.suppress_next_release_from(RegionInputSource::Pointer); + } + _ => {} + } + true + } + + fn handle_modal_pointer_press( + &mut self, + event: &PointerEvent, + on_toolbar: bool, + button: u32, + help_press_source: HelpOverlayPressSource, + ) -> bool { + if self.input_state.tour_active { + return true; + } + // Help is modal: remember the target so release can require the same row. + if self.input_state.show_help { + let screen_position = if on_toolbar { + self.toolbar_surface_screen_coords(&event.surface, event.position) + } else { + Some(event.position) + }; + match screen_position { + Some((sx, sy)) => self.input_state.note_help_overlay_press( + help_press_source, + sx.round() as i32, + sy.round() as i32, + ), + None => { + self.input_state + .clear_help_overlay_press_for(help_press_source); + } + } + return true; + } + if !self.input_state.command_palette_is_engaged() { + 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(), + ); + if handled { + self.suppress_next_release_from(RegionInputSource::Pointer); + } + } + true + } + + fn handle_inline_pointer_press( + &mut self, + conn: &wayland_client::Connection, + qh: &QueueHandle, + event: &PointerEvent, + button: u32, + ) -> bool { + if button == BTN_RIGHT + && self.inline_toolbar_secondary_press(event.position, Some(conn), Some(qh)) + { + self.refresh_keyboard_interactivity(); + return true; + } + if button == BTN_LEFT && self.inline_toolbar_press(event.position, Some(conn), Some(qh)) { + drag_log(|| { + format!( + "pointer press: inline handled, drag_active={}, pos=({:.3}, {:.3}), surface={}", + self.toolbar_dragging(), + event.position.0, + event.position.1, + surface_id(&event.surface) + ) + }); + if self.is_move_dragging() { + self.lock_pointer_for_drag(qh, &event.surface); + } + return true; + } + if !self.pointer_over_toolbar() { + return false; + } + if button == BTN_LEFT { + self.dismiss_top_toolbar_menus(); + } + true + } + + fn handle_toolbar_pointer_press( + &mut self, + conn: &wayland_client::Connection, + qh: &QueueHandle, + event: &PointerEvent, + button: u32, + ) { + if button == BTN_RIGHT + && let Some(index) = self + .toolbar + .quick_color_slot_at(&event.surface, event.position) + { + self.handle_toolbar_event(ToolbarEvent::EditQuickColor { index }, Some(conn), Some(qh)); + self.toolbar.mark_dirty(); + self.input_state.needs_redraw = true; + self.refresh_keyboard_interactivity(); + return; + } + let handled = if button == BTN_LEFT { + self.handle_primary_toolbar_pointer_press(conn, qh, event) + } else { + false + }; + if button == BTN_LEFT && !handled { + self.dismiss_top_toolbar_menus(); + } + } + + fn handle_primary_toolbar_pointer_press( + &mut self, + conn: &wayland_client::Connection, + qh: &QueueHandle, + event: &PointerEvent, + ) -> bool { + let Some((intent, drag)) = self.toolbar.pointer_press(&event.surface, event.position) + else { + return false; + }; + let toolbar_event = intent_to_event(intent, self.toolbar.last_snapshot()); + if matches!(toolbar_event, ToolbarEvent::MoveTopToolbar { .. }) && drag { + self.lock_pointer_for_drag(qh, &event.surface); + } + log::info!( + "toolbar press: drag_start={}, surface={}, seat={:?}, inline_active={}", + drag, + surface_id(&event.surface), + self.current_seat_id(), + self.inline_toolbars_active() + ); + self.set_toolbar_dragging(drag); + self.handle_toolbar_event(toolbar_event, Some(conn), Some(qh)); + self.toolbar.mark_dirty(); + self.input_state.needs_redraw = true; + self.refresh_keyboard_interactivity(); + true + } + + fn handle_overlay_pointer_press(&mut self, position: (f64, f64)) -> bool { + let screen_x = position.0.round() as i32; + let screen_y = position.1.round() as i32; + self.set_pending_toast_press(None); + if let Some(pressed) = self.input_state.toast_press_at(screen_x, screen_y) { + self.set_pending_toast_press(Some(pressed)); + return true; + } + self.set_pending_status_hud_press(false); + if self.input_state.status_hud_contains(screen_x, screen_y) { + self.set_pending_status_hud_press(true); + return true; + } + self.set_pending_zoom_chip_press(ZoomChipPress::None); + if !self.input_state.zoom_chip_contains(screen_x, screen_y) { + return false; + } + let pressed = self.input_state.zoom_chip_press_at(screen_x, screen_y); + self.set_pending_zoom_chip_press(pressed); + true + } + fn try_dispatch_pointer_shortcut(&mut self, button: u32) -> bool { let Some(pointer) = crate::config::keybindings::linux::pointer_button(button) else { return false; From 9ac6276fc3e35954875e8d7fc8b40c2a07a92e25 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:19:47 +0200 Subject: [PATCH 27/53] refactor(wayland): split pointer release routing --- .../wayland/handlers/pointer/release.rs | 441 ++++++++++-------- 1 file changed, 256 insertions(+), 185 deletions(-) diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index eedd8311..a79d1023 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -23,22 +23,7 @@ impl WaylandState { return; } - if self.input_state.region_is_active() { - if button == BTN_LEFT { - if self - .take_suppressed_release_from(crate::input::state::RegionInputSource::Pointer) - { - return; - } - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let Some((x, y)) = screen_position { - self.finish_region_selection(RegionInputSource::Pointer, x, y); - } - } + if self.handle_region_pointer_release(event, on_toolbar, button) { return; } @@ -48,9 +33,7 @@ impl WaylandState { // Swallow releases after modal clicks (e.g., palette dismiss) if self.take_suppressed_release_from(crate::input::state::RegionInputSource::Pointer) { - self.set_pending_toast_press(None); - self.set_pending_status_hud_press(false); - self.set_pending_zoom_chip_press(ZoomChipPress::None); + self.clear_pending_overlay_presses(); return; } @@ -58,107 +41,22 @@ impl WaylandState { // swallowed by help must not leak its release into a newly opened // popup. Conversely, a press that preceded help has no owner and falls // through to finish its original gesture. - let source = HelpOverlayPressSource::Pointer(button); - let help_owned_release = if button == BTN_LEFT { - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - match screen_position { - Some((sx, sy)) => { - self.handle_help_overlay_release(source, sx.round() as i32, sy.round() as i32) - } - None => self.input_state.clear_help_overlay_press_for(source), - } - } else { - // Non-left help presses are modal-owned but never resolve rows. - self.input_state.clear_help_overlay_press_for(source) - }; - if help_owned_release { - self.set_pending_toast_press(None); - self.set_pending_status_hud_press(false); - self.set_pending_zoom_chip_press(ZoomChipPress::None); + if self.handle_help_pointer_release(event, on_toolbar, button) { + self.clear_pending_overlay_presses(); return; } // Block pointer input when modal overlays are active if self.input_state.command_palette_open || self.input_state.tour_active { // For command palette, press handles the click - release is a no-op - self.set_pending_toast_press(None); - self.set_pending_status_hud_press(false); - self.set_pending_zoom_chip_press(ZoomChipPress::None); + self.clear_pending_overlay_presses(); return; } - if button == BTN_LEFT - && let Some(pressed) = self.take_pending_toast_press() - { - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let Some((screen_x, screen_y)) = screen_position { - let (hit, action) = self.input_state.resolve_toast_release( - pressed, - screen_x.round() as i32, - screen_y.round() as i32, - ); - if hit && let Some(command) = action { - self.handle_toast_command(command); - } - } - return; - } - - if button == BTN_LEFT && self.take_pending_status_hud_press() { - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let Some((screen_x, screen_y)) = screen_position { - // Segment activations open their surfaces directly; help - // comes back as an action to dispatch. - let (hit, action) = self - .input_state - .check_status_hud_click(screen_x.round() as i32, screen_y.round() as i32); - if hit && let Some(action) = action { - self.dispatch_input_action(action); - } - } + if self.handle_pending_overlay_release(event, on_toolbar, button) { return; } - if button == BTN_LEFT { - let pressed = self.take_pending_zoom_chip_press(); - if pressed.is_pending() { - // Any pending chip press (`Passive` or `Button`) consumes its - // release here. Only a `Button` resolves to a zoom action; the - // action fires only when the release lands on the SAME button. - // dispatch_input_action drains the resulting pending zoom action. - if let ZoomChipPress::Button(kind) = pressed { - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let Some((screen_x, screen_y)) = screen_position { - let (_, action) = self.input_state.check_zoom_chip_click( - kind, - screen_x.round() as i32, - screen_y.round() as i32, - ); - if let Some(action) = action { - self.dispatch_input_action(action); - } - } - } - return; - } - } - if debug_toolbar_drag_logging_enabled() { debug!( "pointer release: button={}, on_toolbar={}, inline_active={}, drag_active={}, toolbar_dragging={}, pointer_over_toolbar={}", @@ -175,90 +73,19 @@ impl WaylandState { // still commit (or cancel) instead of being swallowed by the toolbar // gates below. The radial release router consumes every button while // the menu is open, so nothing leaks through to canvas handling. - if self.input_state.is_radial_menu_open() - && !self.is_move_dragging() - && !self.toolbar_dragging() - { - let mb = match button { - BTN_LEFT => Some(MouseButton::Left), - BTN_MIDDLE => Some(MouseButton::Middle), - BTN_RIGHT => Some(MouseButton::Right), - _ => None, - }; - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let (Some(mb), Some((sx, sy))) = (mb, screen_position) { - let screen_x = sx.round() as i32; - let screen_y = sy.round() as i32; - let (wx, wy) = self.zoomed_world_coords(sx, sy); - self.input_state - .on_mouse_release_with_canvas(mb, screen_x, screen_y, wx, wy); - self.input_state.needs_redraw = true; - return; - } + if self.handle_radial_pointer_release(event, on_toolbar, button) { + return; } - if inline_active { - if button == BTN_LEFT && self.inline_toolbar_release(event.position) { - drag_log(|| { - format!( - "pointer release: inline handled, pos=({:.3}, {:.3}), drag_active={}, pointer_over_toolbar={}", - event.position.0, - event.position.1, - self.is_move_dragging(), - self.pointer_over_toolbar() - ) - }); - self.unlock_pointer(); - return; - } - if self.pointer_over_toolbar() || self.toolbar_dragging() { - drag_log(|| { - format!( - "pointer release: inline end drag, pos=({:.3}, {:.3}), drag_active={}, pointer_over_toolbar={}", - event.position.0, - event.position.1, - self.is_move_dragging(), - self.pointer_over_toolbar() - ) - }); - self.end_toolbar_move_drag(); - self.unlock_pointer(); - return; - } + if inline_active && self.handle_inline_pointer_release(event, button) { + return; } if on_toolbar || self.pointer_over_toolbar() { - if button == BTN_LEFT { - self.finish_toolbar_item_drag(true); - self.set_toolbar_dragging(false); - } - drag_log(|| { - format!( - "pointer release: toolbar end drag, pos=({:.3}, {:.3}), drag_active={}, pointer_over_toolbar={}", - event.position.0, - event.position.1, - self.is_move_dragging(), - self.pointer_over_toolbar() - ) - }); - self.end_toolbar_move_drag(); - self.unlock_pointer(); + self.handle_toolbar_pointer_release(event, button); return; } // End move drag if released on the main surface if button == BTN_LEFT && self.is_move_dragging() { - self.finish_toolbar_item_drag(true); - self.set_toolbar_dragging(false); - drag_log(|| { - format!( - "pointer release: main surface end drag, pos=({:.3}, {:.3})", - event.position.0, event.position.1 - ) - }); - self.end_toolbar_move_drag(); - self.unlock_pointer(); + self.finish_main_surface_drag(event); return; } debug!("Button {} released", button); @@ -291,6 +118,250 @@ impl WaylandState { self.input_state.needs_redraw = true; } + fn handle_region_pointer_release( + &mut self, + event: &PointerEvent, + on_toolbar: bool, + button: u32, + ) -> bool { + if !self.input_state.region_is_active() { + return false; + } + if button != BTN_LEFT { + return true; + } + if self.take_suppressed_release_from(RegionInputSource::Pointer) { + return true; + } + let screen_position = if on_toolbar { + self.toolbar_surface_screen_coords(&event.surface, event.position) + } else { + Some(event.position) + }; + if let Some((x, y)) = screen_position { + self.finish_region_selection(RegionInputSource::Pointer, x, y); + } + true + } + + fn clear_pending_overlay_presses(&mut self) { + self.set_pending_toast_press(None); + self.set_pending_status_hud_press(false); + self.set_pending_zoom_chip_press(ZoomChipPress::None); + } + + fn handle_help_pointer_release( + &mut self, + event: &PointerEvent, + on_toolbar: bool, + button: u32, + ) -> bool { + let source = HelpOverlayPressSource::Pointer(button); + if button != BTN_LEFT { + // Non-left help presses are modal-owned but never resolve rows. + return self.input_state.clear_help_overlay_press_for(source); + } + let screen_position = if on_toolbar { + self.toolbar_surface_screen_coords(&event.surface, event.position) + } else { + Some(event.position) + }; + match screen_position { + Some((sx, sy)) => { + self.handle_help_overlay_release(source, sx.round() as i32, sy.round() as i32) + } + None => self.input_state.clear_help_overlay_press_for(source), + } + } + + fn handle_pending_overlay_release( + &mut self, + event: &PointerEvent, + on_toolbar: bool, + button: u32, + ) -> bool { + if button != BTN_LEFT { + return false; + } + if let Some(pressed) = self.take_pending_toast_press() { + self.resolve_toast_pointer_release(event, on_toolbar, pressed); + return true; + } + if self.take_pending_status_hud_press() { + self.resolve_status_hud_pointer_release(event, on_toolbar); + return true; + } + let pressed = self.take_pending_zoom_chip_press(); + if !pressed.is_pending() { + return false; + } + if let ZoomChipPress::Button(kind) = pressed { + self.resolve_zoom_chip_pointer_release(event, on_toolbar, kind); + } + true + } + + fn resolve_toast_pointer_release( + &mut self, + event: &PointerEvent, + on_toolbar: bool, + pressed: crate::input::state::ToastPress, + ) { + let Some((screen_x, screen_y)) = self.pointer_release_screen_position(event, on_toolbar) + else { + return; + }; + let (hit, action) = self.input_state.resolve_toast_release( + pressed, + screen_x.round() as i32, + screen_y.round() as i32, + ); + if hit && let Some(command) = action { + self.handle_toast_command(command); + } + } + + fn resolve_status_hud_pointer_release(&mut self, event: &PointerEvent, on_toolbar: bool) { + let Some((screen_x, screen_y)) = self.pointer_release_screen_position(event, on_toolbar) + else { + return; + }; + let (hit, action) = self + .input_state + .check_status_hud_click(screen_x.round() as i32, screen_y.round() as i32); + if hit && let Some(action) = action { + self.dispatch_input_action(action); + } + } + + fn resolve_zoom_chip_pointer_release( + &mut self, + event: &PointerEvent, + on_toolbar: bool, + kind: crate::ui::ZoomChipButtonKind, + ) { + let Some((screen_x, screen_y)) = self.pointer_release_screen_position(event, on_toolbar) + else { + return; + }; + let (_, action) = self.input_state.check_zoom_chip_click( + kind, + screen_x.round() as i32, + screen_y.round() as i32, + ); + if let Some(action) = action { + self.dispatch_input_action(action); + } + } + + fn pointer_release_screen_position( + &self, + event: &PointerEvent, + on_toolbar: bool, + ) -> Option<(f64, f64)> { + if on_toolbar { + self.toolbar_surface_screen_coords(&event.surface, event.position) + } else { + Some(event.position) + } + } + + fn handle_radial_pointer_release( + &mut self, + event: &PointerEvent, + on_toolbar: bool, + button: u32, + ) -> bool { + if !self.input_state.is_radial_menu_open() + || self.is_move_dragging() + || self.toolbar_dragging() + { + return false; + } + let mb = match button { + BTN_LEFT => Some(MouseButton::Left), + BTN_MIDDLE => Some(MouseButton::Middle), + BTN_RIGHT => Some(MouseButton::Right), + _ => None, + }; + let screen_position = self.pointer_release_screen_position(event, on_toolbar); + let (Some(mb), Some((sx, sy))) = (mb, screen_position) else { + return false; + }; + let (wx, wy) = self.zoomed_world_coords(sx, sy); + self.input_state.on_mouse_release_with_canvas( + mb, + sx.round() as i32, + sy.round() as i32, + wx, + wy, + ); + self.input_state.needs_redraw = true; + true + } + + fn handle_inline_pointer_release(&mut self, event: &PointerEvent, button: u32) -> bool { + if button == BTN_LEFT && self.inline_toolbar_release(event.position) { + drag_log(|| { + format!( + "pointer release: inline handled, pos=({:.3}, {:.3}), drag_active={}, pointer_over_toolbar={}", + event.position.0, + event.position.1, + self.is_move_dragging(), + self.pointer_over_toolbar() + ) + }); + self.unlock_pointer(); + return true; + } + if !self.pointer_over_toolbar() && !self.toolbar_dragging() { + return false; + } + drag_log(|| { + format!( + "pointer release: inline end drag, pos=({:.3}, {:.3}), drag_active={}, pointer_over_toolbar={}", + event.position.0, + event.position.1, + self.is_move_dragging(), + self.pointer_over_toolbar() + ) + }); + self.end_toolbar_move_drag(); + self.unlock_pointer(); + true + } + + fn handle_toolbar_pointer_release(&mut self, event: &PointerEvent, button: u32) { + if button == BTN_LEFT { + self.finish_toolbar_item_drag(true); + self.set_toolbar_dragging(false); + } + drag_log(|| { + format!( + "pointer release: toolbar end drag, pos=({:.3}, {:.3}), drag_active={}, pointer_over_toolbar={}", + event.position.0, + event.position.1, + self.is_move_dragging(), + self.pointer_over_toolbar() + ) + }); + self.end_toolbar_move_drag(); + self.unlock_pointer(); + } + + fn finish_main_surface_drag(&mut self, event: &PointerEvent) { + self.finish_toolbar_item_drag(true); + self.set_toolbar_dragging(false); + drag_log(|| { + format!( + "pointer release: main surface end drag, pos=({:.3}, {:.3})", + event.position.0, event.position.1 + ) + }); + self.end_toolbar_move_drag(); + self.unlock_pointer(); + } + /// Resolve a left release inside the open help overlay against the target /// recorded on press, enforcing the same-target contract: run a row only /// when press and release land on the SAME row, dismiss only when both fall From 4f350743747142e1981df138c57d003d587686bd Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:21:39 +0200 Subject: [PATCH 28/53] refactor(runtime-ui): split live toolbar state updates --- .../wayland/runtime_ui_state/live_state.rs | 81 +++++++++++++------ 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/src/backend/wayland/runtime_ui_state/live_state.rs b/src/backend/wayland/runtime_ui_state/live_state.rs index 4f29f6b5..2d5eac44 100644 --- a/src/backend/wayland/runtime_ui_state/live_state.rs +++ b/src/backend/wayland/runtime_ui_state/live_state.rs @@ -70,10 +70,24 @@ pub(super) fn apply_live_toolbar_state( live: &RuntimeUiLiveState, include: impl Fn(&InteractionSeedTarget) -> bool, ) { - let bool_value = |target| match live.get(&target) { + apply_live_display_flags(input, live, &include); + apply_live_toolbar_preferences(input, live, &include); + apply_live_overlay_flags(input, live, &include); + apply_live_toolbar_structure(input, live, &include); +} + +fn live_bool(live: &RuntimeUiLiveState, target: InteractionSeedTarget) -> Option { + match live.get(&target) { Some(InteractionSeedValue::Bool(value)) => Some(*value), _ => None, - }; + } +} + +fn apply_live_display_flags( + input: &mut InputState, + live: &RuntimeUiLiveState, + include: &impl Fn(&InteractionSeedTarget) -> bool, +) { // Applied before `TopMinimized` so that entering micro (which clears the // minimized flag) cannot drop a minimized state that is also being // restored in the same pass. @@ -84,90 +98,111 @@ pub(super) fn apply_live_toolbar_state( apply_persisted_top_display_mode(input, *mode); } if include(&InteractionSeedTarget::StatusBar) - && let Some(value) = bool_value(InteractionSeedTarget::StatusBar) + && let Some(value) = live_bool(live, InteractionSeedTarget::StatusBar) { input.show_status_bar = value; } if include(&InteractionSeedTarget::StatusBoardBadge) - && let Some(value) = bool_value(InteractionSeedTarget::StatusBoardBadge) + && let Some(value) = live_bool(live, InteractionSeedTarget::StatusBoardBadge) { input.show_status_board_badge = value; } if include(&InteractionSeedTarget::StatusPageBadge) - && let Some(value) = bool_value(InteractionSeedTarget::StatusPageBadge) + && let Some(value) = live_bool(live, InteractionSeedTarget::StatusPageBadge) { input.show_status_page_badge = value; } if include(&InteractionSeedTarget::FloatingBadgeAlways) - && let Some(value) = bool_value(InteractionSeedTarget::FloatingBadgeAlways) + && let Some(value) = live_bool(live, InteractionSeedTarget::FloatingBadgeAlways) { input.show_floating_badge_always = value; } +} + +fn apply_live_toolbar_preferences( + input: &mut InputState, + live: &RuntimeUiLiveState, + include: &impl Fn(&InteractionSeedTarget) -> bool, +) { if include(&InteractionSeedTarget::ToolbarIcons) - && let Some(value) = bool_value(InteractionSeedTarget::ToolbarIcons) + && let Some(value) = live_bool(live, InteractionSeedTarget::ToolbarIcons) { input.toolbar_use_icons = value; } if include(&InteractionSeedTarget::ToolbarMoreColors) - && let Some(value) = bool_value(InteractionSeedTarget::ToolbarMoreColors) + && let Some(value) = live_bool(live, InteractionSeedTarget::ToolbarMoreColors) { input.show_more_colors = value; } if include(&InteractionSeedTarget::ToolbarContextAwareUi) - && let Some(value) = bool_value(InteractionSeedTarget::ToolbarContextAwareUi) + && let Some(value) = live_bool(live, InteractionSeedTarget::ToolbarContextAwareUi) { input.context_aware_ui = value; } if include(&InteractionSeedTarget::ToolbarPresetToasts) - && let Some(value) = bool_value(InteractionSeedTarget::ToolbarPresetToasts) + && let Some(value) = live_bool(live, InteractionSeedTarget::ToolbarPresetToasts) { input.show_preset_toasts = value; } if include(&InteractionSeedTarget::ToolbarIdleFade) - && let Some(value) = bool_value(InteractionSeedTarget::ToolbarIdleFade) + && let Some(value) = live_bool(live, InteractionSeedTarget::ToolbarIdleFade) { input.idle_fade = value; } if include(&InteractionSeedTarget::ToolbarToolPreview) - && let Some(value) = bool_value(InteractionSeedTarget::ToolbarToolPreview) + && let Some(value) = live_bool(live, InteractionSeedTarget::ToolbarToolPreview) { input.show_tool_preview = value; } if include(&InteractionSeedTarget::ToolbarDelaySliders) - && let Some(value) = bool_value(InteractionSeedTarget::ToolbarDelaySliders) + && let Some(value) = live_bool(live, InteractionSeedTarget::ToolbarDelaySliders) { input.show_delay_sliders = value; } if include(&InteractionSeedTarget::HistoryCustomSection) - && let Some(value) = bool_value(InteractionSeedTarget::HistoryCustomSection) + && let Some(value) = live_bool(live, InteractionSeedTarget::HistoryCustomSection) { input.custom_section_enabled = value; } +} + +fn apply_live_overlay_flags( + input: &mut InputState, + live: &RuntimeUiLiveState, + include: &impl Fn(&InteractionSeedTarget) -> bool, +) { if include(&InteractionSeedTarget::InputHud) - && let Some(value) = bool_value(InteractionSeedTarget::InputHud) + && let Some(value) = live_bool(live, InteractionSeedTarget::InputHud) { input.set_input_hud_enabled(value); } if include(&InteractionSeedTarget::FloatingBadge) - && let Some(value) = bool_value(InteractionSeedTarget::FloatingBadge) + && let Some(value) = live_bool(live, InteractionSeedTarget::FloatingBadge) { input.show_floating_badge = value; } if include(&InteractionSeedTarget::ZoomChip) - && let Some(value) = bool_value(InteractionSeedTarget::ZoomChip) + && let Some(value) = live_bool(live, InteractionSeedTarget::ZoomChip) { input.show_zoom_chip = value; } if include(&InteractionSeedTarget::ClickHighlight) - && let Some(value) = bool_value(InteractionSeedTarget::ClickHighlight) + && let Some(value) = live_bool(live, InteractionSeedTarget::ClickHighlight) { input.set_click_highlight_enabled(value); } if include(&InteractionSeedTarget::ClickHighlightToolRing) - && let Some(value) = bool_value(InteractionSeedTarget::ClickHighlightToolRing) + && let Some(value) = live_bool(live, InteractionSeedTarget::ClickHighlightToolRing) { input.set_highlight_tool_ring_enabled(value); } +} + +fn apply_live_toolbar_structure( + input: &mut InputState, + live: &RuntimeUiLiveState, + include: &impl Fn(&InteractionSeedTarget) -> bool, +) { if include(&InteractionSeedTarget::ToolbarLayoutMode) && let Some(InteractionSeedValue::LayoutMode(mode)) = live.get(&InteractionSeedTarget::ToolbarLayoutMode) @@ -183,24 +218,24 @@ pub(super) fn apply_live_toolbar_state( } } if include(&InteractionSeedTarget::StatusBarInteractive) - && let Some(value) = bool_value(InteractionSeedTarget::StatusBarInteractive) + && let Some(value) = live_bool(live, InteractionSeedTarget::StatusBarInteractive) { input.status_bar_interactive = value; } for item in crate::config::StatusBarItem::ALL { if include(&InteractionSeedTarget::StatusBarItem(item)) - && let Some(value) = bool_value(InteractionSeedTarget::StatusBarItem(item)) + && let Some(value) = live_bool(live, InteractionSeedTarget::StatusBarItem(item)) { input.set_status_bar_item_visible(item, value); } } if include(&InteractionSeedTarget::TopPinned) - && let Some(value) = bool_value(InteractionSeedTarget::TopPinned) + && let Some(value) = live_bool(live, InteractionSeedTarget::TopPinned) { input.toolbar_top_pinned = value; } if include(&InteractionSeedTarget::TopMinimized) - && let Some(value) = bool_value(InteractionSeedTarget::TopMinimized) + && let Some(value) = live_bool(live, InteractionSeedTarget::TopMinimized) { input.apply_toolbar_set_top_minimized(value); } From 8184ff4b96758d41f73f25fa84ccb88c437ab921 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:24:27 +0200 Subject: [PATCH 29/53] refactor(input): split UI action handlers --- src/input/state/actions/action_ui.rs | 386 ++++++++++++++------------- 1 file changed, 200 insertions(+), 186 deletions(-) diff --git a/src/input/state/actions/action_ui.rs b/src/input/state/actions/action_ui.rs index e4172e25..2ad05bc0 100644 --- a/src/input/state/actions/action_ui.rs +++ b/src/input/state/actions/action_ui.rs @@ -20,194 +20,35 @@ impl InputState { true } Action::ToggleFocusMode => { - // Presenter mode already owns chrome visibility and restores - // it on exit; a second snapshot layer would fight it. - if self.presenter_mode { - return true; - } - self.toggle_focus_mode(); - info!( - "Focus mode {}", - if self.focus_mode_active() { - "on" - } else { - "off" - } - ); + self.handle_toggle_focus_mode(); true } Action::ToggleStatusBar => { - if self.presenter_mode && self.presenter_mode_config.hide_status_bar { - return true; - } - self.break_focus_mode(); - self.queue_toolbar_persistence(PendingToolbarPersistence::StatusBar { - previous: self.show_status_bar, - }); - self.show_status_bar = !self.show_status_bar; - // Redraw only. Status-bar visibility is a this-run preference - // that no longer enters `ToolStateSnapshot`, so marking the - // session dirty here would claim a change the save cannot - // carry — and that false claim is what lets an oversized - // session that failed to restore be replaced, since its - // protection is exactly "nothing persisted changed". - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - if !self.show_status_bar { - self.warn_if_all_chrome_hidden(); - } + self.handle_toggle_status_bar(); true } Action::ToggleFloatingBadge => { - self.break_focus_mode(); - self.queue_toolbar_persistence(PendingToolbarPersistence::FloatingBadge { - previous: self.show_floating_badge, - }); - self.show_floating_badge = !self.show_floating_badge; - info!( - "Floating board/page badge {}", - if self.show_floating_badge { - "shown" - } else { - "hidden" - } - ); - self.dirty_tracker.mark_full(); - self.needs_redraw = true; + self.handle_toggle_floating_badge(); true } Action::ToggleZoomChip => { - self.break_focus_mode(); - self.queue_toolbar_persistence(PendingToolbarPersistence::ZoomChip { - previous: self.show_zoom_chip, - }); - self.show_zoom_chip = !self.show_zoom_chip; - info!( - "Zoom chip {}", - if self.show_zoom_chip { - "shown" - } else { - "hidden" - } - ); - self.dirty_tracker.mark_full(); - self.needs_redraw = true; + self.handle_toggle_zoom_chip(); true } Action::ToggleClickHighlight => { - if self.presenter_mode && self.presenter_mode_config.enable_click_highlight { - return true; - } - let previous_enabled = self.click_highlight_enabled(); - let previous_tool_ring = self.highlight_tool_ring_enabled(); - let enabled = self.toggle_click_highlight(); - self.queue_toolbar_persistence(PendingToolbarPersistence::ClickHighlight { - previous_enabled, - previous_tool_ring, - }); - let message = if enabled { - "Click highlight enabled" - } else { - "Click highlight disabled" - }; - info!("{}", message); + self.handle_toggle_click_highlight(); true } Action::ToggleInputHud => { - // Presenter mode owns the HUD while it forces it on; a manual - // toggle must not fight it (same gate as ToggleClickHighlight). - if self.presenter_mode && self.presenter_mode_config.enable_input_hud { - return true; - } - let previous = self.input_hud_enabled(); - let enabled = self.toggle_input_hud(); - self.queue_toolbar_persistence(PendingToolbarPersistence::InputHud { previous }); - if enabled { - // The effective source is only known after the backend - // reconciles the reader thread; the enable announcement is - // pushed there (`sync_input_monitor`), so it can never - // claim "overlay input only" right before system-wide - // capture starts. - info!("Input HUD enabled"); - } else { - let message = "Input HUD disabled"; - self.push_toast(ToastPriority::Info, "ui", Toast::info(message)); - info!("{}", message); - } + self.handle_toggle_input_hud(); true } Action::ToggleToolbar => { - if self.presenter_mode && self.presenter_mode_config.hide_toolbars { - return true; - } - self.break_focus_mode(); - let now_visible = !self.toolbar_visible(); - let changed = self.set_toolbar_visible(now_visible); - if changed { - // The toggle is durable by driving the pin overrides — the - // startup source of visibility — so a restart shows - // exactly what this press left on screen. Implicit hides - // (focus mode, presenter mode) bypass this path on purpose - // and stay run-only. - let previous_top_pinned = self.toolbar_top_pinned; - self.toolbar_top_pinned = now_visible; - // Persist only when the pin actually moves. A show from a - // cycle-hidden strip changes no pin: the write would be - // byte-identical, and its only observable effect would be - // a rollback that re-derives pin-true visibility for a - // screen that was effectively hidden before the press. The - // unfold itself stays runtime-only, exactly like F2's - // hidden rung. - if previous_top_pinned != now_visible { - self.queue_toolbar_persistence(PendingToolbarPersistence::Visibility { - previous_top_pinned, - }); - } - self.pending_onboarding_usage.used_toolbar_toggle = true; - info!( - "Toolbar visibility {}", - if now_visible { "enabled" } else { "disabled" } - ); - if !now_visible { - self.warn_if_all_chrome_hidden(); - } - } + self.handle_toggle_toolbar(); true } Action::CycleToolbarDisplay => { - // Same presenter gate as ToggleToolbar: while presenter mode - // owns toolbar visibility, the cycle must not fight it. - if self.presenter_mode && self.presenter_mode_config.hide_toolbars { - return true; - } - self.break_focus_mode(); - let previous_mode = self.toolbar_top_display_mode; - let mode = self.cycle_top_toolbar_display(); - self.pending_onboarding_usage.used_toolbar_toggle = true; - let toast = match mode { - crate::config::TopDisplayMode::Full => Toast::info("Toolbar: full"), - crate::config::TopDisplayMode::Micro => Toast::info("Toolbar: micro"), - crate::config::TopDisplayMode::Hidden => { - // The hidden rung teaches its own way back: another - // cycle press always lands on Full. - let label = - match self.action_binding_primary_label(Action::CycleToolbarDisplay) { - Some(binding) => format!("Show ({binding})"), - None => "Show".to_string(), - }; - Toast::info("Toolbar: hidden").action(label, Action::CycleToolbarDisplay) - } - }; - self.push_toast(ToastPriority::Info, "ui", toast); - if mode == crate::config::TopDisplayMode::Hidden { - self.warn_if_all_chrome_hidden(); - } - self.queue_toolbar_persistence(PendingToolbarPersistence::DisplayMode { - previous: previous_mode, - }); - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - info!("Toolbar display mode cycled to {mode:?}"); + self.handle_cycle_toolbar_display(); true } Action::TogglePresenterMode => { @@ -266,12 +107,7 @@ impl InputState { true } Action::ToggleRadialMenu => { - if self.is_radial_menu_open() { - self.close_radial_menu(); - } else if !self.zoom_active() && matches!(self.state, DrawingState::Idle) { - let (x, y) = self.pointer_position(); - self.open_radial_menu(x as f64, y as f64); - } + self.handle_toggle_radial_menu(); true } Action::OpenContextMenu => { @@ -281,19 +117,7 @@ impl InputState { true } Action::ToggleSelectionProperties => { - if matches!(self.state, DrawingState::Idle) { - if self.properties_panel().is_some() { - self.close_properties_panel(); - } else if self.show_properties_panel() { - self.close_context_menu(); - } else { - self.push_toast( - ToastPriority::Info, - "ui", - Toast::warning("No selection to edit."), - ); - } - } + self.handle_toggle_selection_properties(); true } Action::OpenConfigurator => { @@ -349,4 +173,194 @@ impl InputState { _ => false, } } + + fn handle_toggle_focus_mode(&mut self) { + // Presenter mode already owns chrome visibility and restores it on + // exit; a second snapshot layer would fight it. + if self.presenter_mode { + return; + } + self.toggle_focus_mode(); + info!( + "Focus mode {}", + if self.focus_mode_active() { + "on" + } else { + "off" + } + ); + } + + fn handle_toggle_status_bar(&mut self) { + if self.presenter_mode && self.presenter_mode_config.hide_status_bar { + return; + } + self.break_focus_mode(); + self.queue_toolbar_persistence(PendingToolbarPersistence::StatusBar { + previous: self.show_status_bar, + }); + self.show_status_bar = !self.show_status_bar; + // This run-only preference redraws without claiming persisted changes. + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + if !self.show_status_bar { + self.warn_if_all_chrome_hidden(); + } + } + + fn handle_toggle_floating_badge(&mut self) { + self.break_focus_mode(); + self.queue_toolbar_persistence(PendingToolbarPersistence::FloatingBadge { + previous: self.show_floating_badge, + }); + self.show_floating_badge = !self.show_floating_badge; + info!( + "Floating board/page badge {}", + if self.show_floating_badge { + "shown" + } else { + "hidden" + } + ); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + + fn handle_toggle_zoom_chip(&mut self) { + self.break_focus_mode(); + self.queue_toolbar_persistence(PendingToolbarPersistence::ZoomChip { + previous: self.show_zoom_chip, + }); + self.show_zoom_chip = !self.show_zoom_chip; + info!( + "Zoom chip {}", + if self.show_zoom_chip { + "shown" + } else { + "hidden" + } + ); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + + fn handle_toggle_click_highlight(&mut self) { + if self.presenter_mode && self.presenter_mode_config.enable_click_highlight { + return; + } + let previous_enabled = self.click_highlight_enabled(); + let previous_tool_ring = self.highlight_tool_ring_enabled(); + let enabled = self.toggle_click_highlight(); + self.queue_toolbar_persistence(PendingToolbarPersistence::ClickHighlight { + previous_enabled, + previous_tool_ring, + }); + info!( + "Click highlight {}", + if enabled { "enabled" } else { "disabled" } + ); + } + + fn handle_toggle_input_hud(&mut self) { + if self.presenter_mode && self.presenter_mode_config.enable_input_hud { + return; + } + let previous = self.input_hud_enabled(); + let enabled = self.toggle_input_hud(); + self.queue_toolbar_persistence(PendingToolbarPersistence::InputHud { previous }); + if enabled { + info!("Input HUD enabled"); + } else { + let message = "Input HUD disabled"; + self.push_toast(ToastPriority::Info, "ui", Toast::info(message)); + info!("{}", message); + } + } + + fn handle_toggle_toolbar(&mut self) { + if self.presenter_mode && self.presenter_mode_config.hide_toolbars { + return; + } + self.break_focus_mode(); + let now_visible = !self.toolbar_visible(); + if !self.set_toolbar_visible(now_visible) { + return; + } + let previous_top_pinned = self.toolbar_top_pinned; + self.toolbar_top_pinned = now_visible; + if previous_top_pinned != now_visible { + self.queue_toolbar_persistence(PendingToolbarPersistence::Visibility { + previous_top_pinned, + }); + } + self.pending_onboarding_usage.used_toolbar_toggle = true; + info!( + "Toolbar visibility {}", + if now_visible { "enabled" } else { "disabled" } + ); + if !now_visible { + self.warn_if_all_chrome_hidden(); + } + } + + fn handle_cycle_toolbar_display(&mut self) { + if self.presenter_mode && self.presenter_mode_config.hide_toolbars { + return; + } + self.break_focus_mode(); + let previous_mode = self.toolbar_top_display_mode; + let mode = self.cycle_top_toolbar_display(); + self.pending_onboarding_usage.used_toolbar_toggle = true; + let toast = self.toolbar_display_toast(mode); + self.push_toast(ToastPriority::Info, "ui", toast); + if mode == crate::config::TopDisplayMode::Hidden { + self.warn_if_all_chrome_hidden(); + } + self.queue_toolbar_persistence(PendingToolbarPersistence::DisplayMode { + previous: previous_mode, + }); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + info!("Toolbar display mode cycled to {mode:?}"); + } + + fn toolbar_display_toast(&self, mode: crate::config::TopDisplayMode) -> Toast { + match mode { + crate::config::TopDisplayMode::Full => Toast::info("Toolbar: full"), + crate::config::TopDisplayMode::Micro => Toast::info("Toolbar: micro"), + crate::config::TopDisplayMode::Hidden => { + let label = match self.action_binding_primary_label(Action::CycleToolbarDisplay) { + Some(binding) => format!("Show ({binding})"), + None => "Show".to_string(), + }; + Toast::info("Toolbar: hidden").action(label, Action::CycleToolbarDisplay) + } + } + } + + fn handle_toggle_radial_menu(&mut self) { + if self.is_radial_menu_open() { + self.close_radial_menu(); + } else if !self.zoom_active() && matches!(self.state, DrawingState::Idle) { + let (x, y) = self.pointer_position(); + self.open_radial_menu(x as f64, y as f64); + } + } + + fn handle_toggle_selection_properties(&mut self) { + if !matches!(self.state, DrawingState::Idle) { + return; + } + if self.properties_panel().is_some() { + self.close_properties_panel(); + } else if self.show_properties_panel() { + self.close_context_menu(); + } else { + self.push_toast( + ToastPriority::Info, + "ui", + Toast::warning("No selection to edit."), + ); + } + } } From bc37eeb1a14cc82cef9dd46536f3995bc48cd5c7 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:26:48 +0200 Subject: [PATCH 30/53] refactor(toolbar): split backend event routing --- src/backend/wayland/state/toolbar/events.rs | 184 +++++++++++--------- 1 file changed, 104 insertions(+), 80 deletions(-) diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index 21ce66c2..6a218909 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -194,48 +194,7 @@ impl WaylandState { self.input_state.needs_redraw = true; return; } - // Toolbar actions win over the modal sampler: cancel without sampling, - // then apply the requested toolbar event normally. - if self.input_state.is_precision_entry_open() - && event_dismisses_popover(&event, ToolbarPopover::PrecisionEntry) - && self.input_state.cancel_precision_entry() - { - self.toolbar.mark_dirty(); - } - let dismiss_overflow = self.input_state.toolbar_top_overflow_open - && event_dismisses_popover(&event, ToolbarPopover::TopOverflow); - let dismiss_shapes = self.input_state.toolbar_shapes_expanded - && event_dismisses_popover(&event, ToolbarPopover::ShapePicker); - let dismiss_session = self.input_state.toolbar_session_popover_open - && event_dismisses_popover(&event, ToolbarPopover::Session); - let dismiss_settings = self.input_state.toolbar_settings_popover_open - && event_dismisses_popover(&event, ToolbarPopover::Settings); - let dismiss_canvas = self.input_state.toolbar_canvas_popover_open - && event_dismisses_popover(&event, ToolbarPopover::Canvas); - if dismiss_overflow - || dismiss_shapes - || dismiss_session - || dismiss_settings - || dismiss_canvas - { - if dismiss_overflow { - self.input_state.toolbar_top_overflow_open = false; - } - if dismiss_shapes { - self.input_state.toolbar_shapes_expanded = false; - } - if dismiss_session { - self.input_state.toolbar_session_popover_open = false; - } - if dismiss_settings { - self.input_state.toolbar_settings_popover_open = false; - } - if dismiss_canvas { - self.input_state.toolbar_canvas_popover_open = false; - } - self.toolbar.mark_dirty(); - self.input_state.needs_redraw = true; - } + self.dismiss_popovers_for_toolbar_event(&event); if self.handle_toolbar_session_event(&event, conn, qh) { return; } @@ -256,27 +215,8 @@ impl WaylandState { let policy = ToolbarEventPolicy::for_event(&event); - match (&policy.backend_route, &event) { - (ToolbarBackendRoute::MoveTopToolbar, ToolbarEvent::MoveTopToolbar { x, y }) => { - let inline_active = self.inline_toolbars_active(); - let coord_is_screen = inline_active; - drag_log(|| { - format!( - "toolbar move event: kind=Top, coord=({:.3}, {:.3}), coord_is_screen={}, inline_active={}", - *x, *y, coord_is_screen, inline_active - ) - }); - if !self.begin_toolbar_move_drag(MoveDragKind::Top, (*x, *y), coord_is_screen) { - return; - } - if coord_is_screen { - self.handle_toolbar_move_screen(MoveDragKind::Top, (*x, *y)); - } else { - self.handle_toolbar_move(MoveDragKind::Top, (*x, *y)); - } - return; - } - (ToolbarBackendRoute::ApplyToInput, _) | (ToolbarBackendRoute::MoveTopToolbar, _) => {} + if self.handle_toolbar_move_event(&policy, &event) { + return; } #[cfg(feature = "tablet-input")] @@ -299,21 +239,8 @@ impl WaylandState { }; // Classified before the apply consumes the event; the effective config // is updated from the runtime state the apply leaves behind. - if starts_item_drag { - // The pairing lives in `persistence_for_event`, so a drag-start - // whose policy stopped naming an order group is metadata drift, - // not an impossible state. Refusing the drag keeps the toolbar - // usable; panicking here took the whole overlay down with it. - let Some(ToolbarRuntimeUiPersistenceTarget::ItemOrder(group)) = runtime_target else { - log::error!( - "Ignoring a toolbar item drag: {event:?} starts one but its policy names no \ - order group to persist it under" - ); - return; - }; - if !self.begin_toolbar_item_drag_preview(group) { - return; - } + if starts_item_drag && !self.prepare_toolbar_item_drag(&event, runtime_target) { + return; } let pin_change = ToolbarPinChange::from_event(&event); let prepared_runtime = if starts_item_drag { @@ -365,6 +292,30 @@ impl WaylandState { if pin_confirmation_allowed && let Some(pin_change) = pin_change { pin_change.notify(&mut self.input_state, pin_durability); } + self.drain_pending_toolbar_actions(); + self.drain_clipboard_requests(); + self.refresh_keyboard_interactivity(); + } + + fn prepare_toolbar_item_drag( + &mut self, + event: &ToolbarEvent, + runtime_target: Option, + ) -> bool { + // The pairing lives in `persistence_for_event`, so a drag-start whose + // policy stopped naming an order group is metadata drift. Refusing the + // drag keeps the toolbar usable instead of taking down the overlay. + let Some(ToolbarRuntimeUiPersistenceTarget::ItemOrder(group)) = runtime_target else { + log::error!( + "Ignoring a toolbar item drag: {event:?} starts one but its policy names no order \ + group to persist it under" + ); + return false; + }; + self.begin_toolbar_item_drag_preview(group) + } + + fn drain_pending_toolbar_actions(&mut self) { if let Some(action) = self.input_state.take_pending_preset_action() { self.handle_preset_action(action); } @@ -377,8 +328,81 @@ impl WaylandState { if let Some(target) = self.input_state.take_pending_paste_hex_request() { self.handle_paste_hex_color(target); } - self.drain_clipboard_requests(); - self.refresh_keyboard_interactivity(); + } + + fn dismiss_popovers_for_toolbar_event(&mut self, event: &ToolbarEvent) { + // Toolbar actions win over the modal sampler: cancel without sampling, + // then apply the requested toolbar event normally. + if self.input_state.is_precision_entry_open() + && event_dismisses_popover(event, ToolbarPopover::PrecisionEntry) + && self.input_state.cancel_precision_entry() + { + self.toolbar.mark_dirty(); + } + let dismiss_overflow = self.input_state.toolbar_top_overflow_open + && event_dismisses_popover(event, ToolbarPopover::TopOverflow); + let dismiss_shapes = self.input_state.toolbar_shapes_expanded + && event_dismisses_popover(event, ToolbarPopover::ShapePicker); + let dismiss_session = self.input_state.toolbar_session_popover_open + && event_dismisses_popover(event, ToolbarPopover::Session); + let dismiss_settings = self.input_state.toolbar_settings_popover_open + && event_dismisses_popover(event, ToolbarPopover::Settings); + let dismiss_canvas = self.input_state.toolbar_canvas_popover_open + && event_dismisses_popover(event, ToolbarPopover::Canvas); + if !(dismiss_overflow + || dismiss_shapes + || dismiss_session + || dismiss_settings + || dismiss_canvas) + { + return; + } + if dismiss_overflow { + self.input_state.toolbar_top_overflow_open = false; + } + if dismiss_shapes { + self.input_state.toolbar_shapes_expanded = false; + } + if dismiss_session { + self.input_state.toolbar_session_popover_open = false; + } + if dismiss_settings { + self.input_state.toolbar_settings_popover_open = false; + } + if dismiss_canvas { + self.input_state.toolbar_canvas_popover_open = false; + } + self.toolbar.mark_dirty(); + self.input_state.needs_redraw = true; + } + + fn handle_toolbar_move_event( + &mut self, + policy: &ToolbarEventPolicy, + event: &ToolbarEvent, + ) -> bool { + let (ToolbarBackendRoute::MoveTopToolbar, ToolbarEvent::MoveTopToolbar { x, y }) = + (&policy.backend_route, event) + else { + return false; + }; + let inline_active = self.inline_toolbars_active(); + let coord_is_screen = inline_active; + drag_log(|| { + format!( + "toolbar move event: kind=Top, coord=({:.3}, {:.3}), coord_is_screen={}, inline_active={}", + *x, *y, coord_is_screen, inline_active + ) + }); + if !self.begin_toolbar_move_drag(MoveDragKind::Top, (*x, *y), coord_is_screen) { + return true; + } + if coord_is_screen { + self.handle_toolbar_move_screen(MoveDragKind::Top, (*x, *y)); + } else { + self.handle_toolbar_move(MoveDragKind::Top, (*x, *y)); + } + true } #[cfg(feature = "tablet-input")] From 5a02bd97f2a6b62c0d86ee34b85f4c8790d03869 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:29:57 +0200 Subject: [PATCH 31/53] refactor(render): split UI layer passes --- src/backend/wayland/state/render/ui.rs | 598 +++++++++++++------------ 1 file changed, 300 insertions(+), 298 deletions(-) diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 61c5de3b..7f7e0398 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -23,318 +23,320 @@ impl WaylandState { } fn render_ui_layers(&mut self, ctx: &cairo::Context, width: u32, height: u32, render_ui: bool) { - if render_ui { - let capture_picker = self.capture_picker_chrome_suppressed(); - if capture_picker { - self.render_capture_picker(ctx, width, height); - } - - if !capture_picker && self.mouse_tool_preview_eligible() { - let (cursor_x, cursor_y) = - self.stylus_hover_cursor_position().unwrap_or_else(|| { - let (x, y) = self.current_mouse(); - (x as f64, y as f64) - }); - draw_tool_preview( - ctx, - self.input_state.active_tool(), - self.input_state - .color_for_tool(self.input_state.active_tool()), - self.input_state.thickness_for_active_tool(), - cursor_x, - cursor_y, - width as f64, - height as f64, - ); - } - if !capture_picker { - self.render_shape_measure_badge(ctx, width, height); - } - if let Some((cursor_x, cursor_y)) = self.stylus_hover_cursor_position() - && !capture_picker - && !self.cursor_blocked_by_toolbar() - { - draw_stylus_hover_cursor( - ctx, - self.input_state.active_tool(), - self.input_state - .color_for_tool(self.input_state.active_tool()), - cursor_x, - cursor_y, - ); - } - // Mode badges: when the status HUD is visible they render as - // pills stacked on the HUD (see render_status_bar); the floating - // top-corner badges below cover the hidden-status-bar case only. - // Focus Mode suppresses the whole fallback family; otherwise its - // intentional status/chip hide would make these badges reappear. - let fallback_mode_badges_visible = - !capture_picker && self.input_state.fallback_mode_badges_visible(); - let status_hud_visible = self.input_state.status_hud_effectively_visible(); - if self.input_state.frozen_active() - && !self.zoom.active - && self.config.ui.show_frozen_badge - && !status_hud_visible - && fallback_mode_badges_visible - { - crate::ui::render_frozen_badge(ctx, width, height); - } - // Render a zoom badge when the status bar is hidden. - // Badge renderers return the vertical space they consume (measured - // height plus stacking gap) so stacked badges never overlap. - // - // Reconciliation (M8): the bottom-right zoom chip is the canonical - // zoom indicator/control whenever it is effectively visible, so the - // passive top-corner zoom badge is suppressed then — otherwise the - // chip and this badge would both show the zoom percentage. When the - // chip is absent (zoom actions off or master-hidden), this passive - // badge remains the hidden-status-bar zoom indicator. - let mut top_badge_offset = 0.0; - if self.input_state.zoom_active() - && !status_hud_visible - && !self.zoom_chip_visible() - && fallback_mode_badges_visible - { - top_badge_offset += crate::ui::render_zoom_badge( - ctx, - width, - height, - self.input_state.zoom_scale(), - self.input_state.zoom_locked(), - ); - } - if self.input_state.boards.pan_enabled() - && self.input_state.boards.show_pan_badge() - && !self.input_state.board_is_transparent() - && !status_hud_visible - && fallback_mode_badges_visible - { - top_badge_offset += crate::ui::render_pan_badge( - ctx, - width, - height, - self.input_state.boards.active_frame().view_offset() != (0, 0), - top_badge_offset, - ); - } - // Render editing badge when in text edit mode - if matches!(self.input_state.state, DrawingState::TextInput { .. }) - && self.input_state.text_edit_target.is_some() - && !status_hud_visible - && fallback_mode_badges_visible - { - crate::ui::render_editing_badge(ctx, width, height, top_badge_offset); - } - if !capture_picker && self.input_state.floating_badge_visible() { - let board_count = self.input_state.boards.board_count(); - let page_count = self.input_state.boards.page_count(); - let board_index = self.input_state.boards.active_index(); - let board_name = self.input_state.board_name(); - let page_index = self.input_state.boards.active_page_index(); - crate::ui::render_page_badge( - ctx, - width, - height, - board_index, - board_count, - board_name, - page_index, - page_count, - ); - } - - // Render the status HUD if enabled (layout was cached for this - // frame by collect_ui_effect_damage). - if self.input_state.show_status_bar { - crate::ui::render_status_bar( - ctx, - &self.input_state, - &self.config.ui.status_bar_style, - width, - height, - ); - } - - // Render the interactive bottom-right zoom chip (layout cached for - // this frame by collect_ui_effect_damage). Reuses the status-bar - // style tokens so it reads as the same chrome family. - if !capture_picker && self.zoom_chip_visible() { - crate::ui::render_zoom_chip( - ctx, - &self.input_state, - &self.config.ui.status_bar_style, - width, - height, - ); - } - - // Render the input HUD's chip row (keystrokes and clicks). Like - // the status pills it uses the status-bar style tokens, and it - // lives behind the same `render_ui` gate so overlay suppression - // hides it with the rest of the chrome. - if !capture_picker && self.input_state.input_hud_visible() { - crate::ui::render_input_hud( - ctx, - &self.input_state, - &self.config.ui.status_bar_style, - width, - height, - ); - } - - // Render help overlay if toggled - if !capture_picker && self.input_state.show_help { - let bindings = crate::ui::HelpOverlayBindings::from_input_state(&self.input_state); - let scroll_max = crate::ui::render_help_overlay( - ctx, - &self.config.ui.help_overlay_style, - width, - height, - self.frozen_enabled(), - self.input_state.help_overlay_page, - &bindings, - self.input_state.help_overlay_search.as_str(), - 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_quick_mode, - ); - self.input_state.help_overlay_scroll_max = scroll_max; - self.input_state.help_overlay_scroll = - self.input_state.help_overlay_scroll.clamp(0.0, scroll_max); - } + if !render_ui { + self.input_state.clear_context_menu_layout(); + return; + } + let capture_picker = self.capture_picker_chrome_suppressed(); + if capture_picker { + self.render_capture_picker(ctx, width, height); + } + self.render_cursor_chrome(ctx, width, height, capture_picker); + self.render_mode_badges(ctx, width, height, capture_picker); + self.render_status_surfaces(ctx, width, height, capture_picker); + self.render_help_and_pickers(ctx, width, height, capture_picker); + self.render_radial_menu_and_feedback(ctx, width, height, capture_picker); + self.render_properties_and_context(ctx, width, height, capture_picker); + self.render_inline_and_modal_ui(ctx, width, height, capture_picker); + } - if !capture_picker && self.input_state.is_board_picker_open() { + fn render_cursor_chrome( + &mut self, + ctx: &cairo::Context, + width: u32, + height: u32, + capture_picker: bool, + ) { + if !capture_picker && self.mouse_tool_preview_eligible() { + let (cursor_x, cursor_y) = self.stylus_hover_cursor_position().unwrap_or_else(|| { + let (x, y) = self.current_mouse(); + (x as f64, y as f64) + }); + draw_tool_preview( + ctx, + self.input_state.active_tool(), self.input_state - .update_board_picker_layout(ctx, width, height); - crate::ui::render_board_picker_with_halo( - ctx, - &self.input_state, - width, - height, - self.config.drawing.text_halo_enabled, - ); - } else { - self.input_state.clear_board_picker_layout(); - } - - if !capture_picker && self.input_state.is_color_picker_popup_open() { + .color_for_tool(self.input_state.active_tool()), + self.input_state.thickness_for_active_tool(), + cursor_x, + cursor_y, + width as f64, + height as f64, + ); + } + if !capture_picker { + self.render_shape_measure_badge(ctx, width, height); + } + if let Some((cursor_x, cursor_y)) = self.stylus_hover_cursor_position() + && !capture_picker + && !self.cursor_blocked_by_toolbar() + { + draw_stylus_hover_cursor( + ctx, + self.input_state.active_tool(), self.input_state - .update_color_picker_popup_layout(width, height); - crate::ui::render_color_picker_popup(ctx, &self.input_state, width, height); - } else { - self.input_state.clear_color_picker_popup_layout(); - } - - if !capture_picker && self.input_state.is_font_picker_open() { - crate::ui::render_font_picker(ctx, &self.input_state, width, height); - } - - if !capture_picker && self.input_state.is_precision_entry_open() { - // Anchor under the top strip (the pill is its bottom row): - // the same base position both the inline fallback and the - // layer-shell margins derive from. - let snapshot = self.toolbar_snapshot(); - let (_, top_h) = crate::backend::wayland::toolbar::top_size(&snapshot); - let anchor = ( - self.inline_top_base_x() + self.data.toolbar_top_offset, - self.inline_top_base_y() + self.data.toolbar_top_offset_y + top_h as f64 + 8.0, - ); - crate::ui::render_precision_entry_popup( - ctx, - &self.input_state, - width, - height, - anchor, - ); - } - - if !capture_picker { - self.render_eyedropper_loupe(ctx, width, height); - } - self.render_ocr_selection(ctx, width, height); - - if !capture_picker && self.input_state.is_radial_menu_open() { - // Layout (and with it hit-testing) is live from the moment - // of opening so pre-paint flicks resolve correctly; painting - // waits out the flick window (RADIAL_PAINT_DELAY). - self.input_state.update_radial_menu_layout(width, height); - if self - .input_state - .radial_menu_mark_painted_if_due(std::time::Instant::now()) - { - crate::ui::render_radial_menu(ctx, &self.input_state, width, height); - } - } else { - self.input_state.clear_radial_menu_layout(); - } - - let toast_geometry = crate::ui::render_ui_toast(ctx, &self.input_state, width, height); - self.input_state.ui_toast_bounds = toast_geometry.map(|geometry| geometry.0); - self.input_state.ui_toast_action_bounds = toast_geometry - .map(|geometry| geometry.1) - .unwrap_or([None, None]); - crate::ui::render_preset_toast(ctx, &self.input_state, width, height); - crate::ui::render_blocked_feedback(ctx, &self.input_state, width, height); + .color_for_tool(self.input_state.active_tool()), + cursor_x, + cursor_y, + ); + } + } - if !capture_picker && !self.zoom.active && !self.input_state.is_board_picker_open() { - if self.input_state.is_properties_panel_open() { - self.input_state - .update_properties_panel_layout(ctx, width, height); - } else { - self.input_state.clear_properties_panel_layout(); - } - crate::ui::render_properties_panel(ctx, &self.input_state, width, height); + fn render_mode_badges( + &self, + ctx: &cairo::Context, + width: u32, + height: u32, + capture_picker: bool, + ) { + let fallback_visible = !capture_picker && self.input_state.fallback_mode_badges_visible(); + let status_visible = self.input_state.status_hud_effectively_visible(); + if self.input_state.frozen_active() + && !self.zoom.active + && self.config.ui.show_frozen_badge + && !status_visible + && fallback_visible + { + crate::ui::render_frozen_badge(ctx, width, height); + } + let mut offset = 0.0; + if self.input_state.zoom_active() + && !status_visible + && !self.zoom_chip_visible() + && fallback_visible + { + offset += crate::ui::render_zoom_badge( + ctx, + width, + height, + self.input_state.zoom_scale(), + self.input_state.zoom_locked(), + ); + } + if self.input_state.boards.pan_enabled() + && self.input_state.boards.show_pan_badge() + && !self.input_state.board_is_transparent() + && !status_visible + && fallback_visible + { + offset += crate::ui::render_pan_badge( + ctx, + width, + height, + self.input_state.boards.active_frame().view_offset() != (0, 0), + offset, + ); + } + if matches!(self.input_state.state, DrawingState::TextInput { .. }) + && self.input_state.text_edit_target.is_some() + && !status_visible + && fallback_visible + { + crate::ui::render_editing_badge(ctx, width, height, offset); + } + } - if self.input_state.is_context_menu_open() { - self.input_state - .update_context_menu_layout(ctx, width, height); - } else { - self.input_state.clear_context_menu_layout(); - } + fn render_status_surfaces( + &self, + ctx: &cairo::Context, + width: u32, + height: u32, + capture_picker: bool, + ) { + if !capture_picker && self.input_state.floating_badge_visible() { + crate::ui::render_page_badge( + ctx, + width, + height, + self.input_state.boards.active_index(), + self.input_state.boards.board_count(), + self.input_state.board_name(), + self.input_state.boards.active_page_index(), + self.input_state.boards.page_count(), + ); + } + if self.input_state.show_status_bar { + crate::ui::render_status_bar( + ctx, + &self.input_state, + &self.config.ui.status_bar_style, + width, + height, + ); + } + if !capture_picker && self.zoom_chip_visible() { + crate::ui::render_zoom_chip( + ctx, + &self.input_state, + &self.config.ui.status_bar_style, + width, + height, + ); + } + if !capture_picker && self.input_state.input_hud_visible() { + crate::ui::render_input_hud( + ctx, + &self.input_state, + &self.config.ui.status_bar_style, + width, + height, + ); + } + } - // Render context menu if open - crate::ui::render_context_menu(ctx, &self.input_state, width, height); - } else { - self.input_state.clear_context_menu_layout(); - self.input_state.clear_properties_panel_layout(); - } + fn render_help_and_pickers( + &mut self, + ctx: &cairo::Context, + width: u32, + height: u32, + capture_picker: bool, + ) { + if !capture_picker && self.input_state.show_help { + let bindings = crate::ui::HelpOverlayBindings::from_input_state(&self.input_state); + let scroll_max = crate::ui::render_help_overlay( + ctx, + &self.config.ui.help_overlay_style, + width, + height, + self.frozen_enabled(), + self.input_state.help_overlay_page, + &bindings, + self.input_state.help_overlay_search.as_str(), + 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_quick_mode, + ); + self.input_state.help_overlay_scroll_max = scroll_max; + self.input_state.help_overlay_scroll = + self.input_state.help_overlay_scroll.clamp(0.0, scroll_max); + } + if !capture_picker && self.input_state.is_board_picker_open() { + self.input_state + .update_board_picker_layout(ctx, width, height); + crate::ui::render_board_picker_with_halo( + ctx, + &self.input_state, + width, + height, + self.config.drawing.text_halo_enabled, + ); + } else { + self.input_state.clear_board_picker_layout(); + } + 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); + } else { + self.input_state.clear_color_picker_popup_layout(); + } + if !capture_picker && self.input_state.is_font_picker_open() { + crate::ui::render_font_picker(ctx, &self.input_state, width, height); + } + if !capture_picker && self.input_state.is_precision_entry_open() { + self.render_precision_entry(ctx, width, height); + } + if !capture_picker { + self.render_eyedropper_loupe(ctx, width, height); + } + self.render_ocr_selection(ctx, width, height); + } - // Inline toolbars (xdg fallback or drag preview) render directly into main surface. - if !capture_picker && self.toolbar.is_visible() && self.inline_toolbars_render_active() - { - let snapshot = self.toolbar_snapshot(); - if self.toolbar.update_snapshot(&snapshot) { - self.toolbar.mark_dirty(); - } - self.render_inline_toolbars(ctx, &snapshot); - } + 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 anchor = ( + self.inline_top_base_x() + self.data.toolbar_top_offset, + self.inline_top_base_y() + self.data.toolbar_top_offset_y + top_h as f64 + 8.0, + ); + crate::ui::render_precision_entry_popup(ctx, &self.input_state, width, height, anchor); + } - if self.input_state.region_state().purpose() - == Some(crate::input::state::RegionPurposeTag::Measure) + fn render_radial_menu_and_feedback( + &mut self, + ctx: &cairo::Context, + width: u32, + height: u32, + capture_picker: bool, + ) { + if !capture_picker && self.input_state.is_radial_menu_open() { + self.input_state.update_radial_menu_layout(width, height); + if self + .input_state + .radial_menu_mark_painted_if_due(std::time::Instant::now()) { - // Measure mode is screen chrome over the live annotated - // canvas, not capture suppression. Paint it once, above the - // ordinary Wayscriber UI. - self.render_capture_picker(ctx, width, height); + crate::ui::render_radial_menu(ctx, &self.input_state, width, height); } + } else { + self.input_state.clear_radial_menu_layout(); + } + let toast_geometry = crate::ui::render_ui_toast(ctx, &self.input_state, width, height); + self.input_state.ui_toast_bounds = toast_geometry.map(|geometry| geometry.0); + self.input_state.ui_toast_action_bounds = toast_geometry + .map(|geometry| geometry.1) + .unwrap_or([None, None]); + crate::ui::render_preset_toast(ctx, &self.input_state, width, height); + crate::ui::render_blocked_feedback(ctx, &self.input_state, width, height); + } - // The OCR scan overlay sits over the live view, above the toolbars - // it reports on but below the modal surfaces below. - self.render_ocr_scan(ctx, width, height); - - // Modal overlays render last (on top of everything including toolbars) - if !capture_picker { - 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_tour(ctx, &self.input_state, width, height); - } + fn render_properties_and_context( + &mut self, + ctx: &cairo::Context, + width: u32, + height: u32, + capture_picker: bool, + ) { + if capture_picker || self.zoom.active || self.input_state.is_board_picker_open() { + self.input_state.clear_context_menu_layout(); + self.input_state.clear_properties_panel_layout(); + return; + } + if self.input_state.is_properties_panel_open() { + self.input_state + .update_properties_panel_layout(ctx, width, height); + } else { + self.input_state.clear_properties_panel_layout(); + } + crate::ui::render_properties_panel(ctx, &self.input_state, width, height); + if self.input_state.is_context_menu_open() { + self.input_state + .update_context_menu_layout(ctx, width, height); } else { self.input_state.clear_context_menu_layout(); } + crate::ui::render_context_menu(ctx, &self.input_state, width, height); + } + + fn render_inline_and_modal_ui( + &mut self, + ctx: &cairo::Context, + width: u32, + height: u32, + capture_picker: bool, + ) { + if !capture_picker && self.toolbar.is_visible() && self.inline_toolbars_render_active() { + let snapshot = self.toolbar_snapshot(); + if self.toolbar.update_snapshot(&snapshot) { + self.toolbar.mark_dirty(); + } + self.render_inline_toolbars(ctx, &snapshot); + } + if self.input_state.region_state().purpose() + == Some(crate::input::state::RegionPurposeTag::Measure) + { + self.render_capture_picker(ctx, width, height); + } + self.render_ocr_scan(ctx, width, height); + if capture_picker { + return; + } + 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_tour(ctx, &self.input_state, width, height); } /// The scan band while recognition runs, then the outcome card. The card From ae7d224e4e385ce70cc0a0e4a362acf792bffea1 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:33:40 +0200 Subject: [PATCH 32/53] refactor(tablet): split stylus event routing --- src/backend/wayland/handlers/tablet/tool.rs | 594 +++++++++++--------- 1 file changed, 314 insertions(+), 280 deletions(-) diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs index 8a646fad..93c9665f 100644 --- a/src/backend/wayland/handlers/tablet/tool.rs +++ b/src/backend/wayland/handlers/tablet/tool.rs @@ -119,6 +119,315 @@ fn stylus_cursor_damage_rect(pos: (f64, f64), width: i32, height: i32) -> Option Rect::from_min_max(min_x, min_y, max_x, max_y) } +impl WaylandState { + fn handle_stylus_proximity_in( + &mut self, + proxy: &ZwpTabletToolV2, + surface: wayland_client::protocol::wl_surface::WlSurface, + ) { + let tool_id = proxy.id(); + let tool_type = self.stylus_tool_types.get(&tool_id).copied(); + debug!( + "Tablet proximity in: tool {:?}, type: {:?}", + tool_id, tool_type + ); + let on_overlay = self + .surface + .wl_surface() + .is_some_and(|candidate| candidate.id() == surface.id()); + let on_toolbar = self.toolbar.is_toolbar_surface(&surface); + self.stylus_surface = Some(surface); + self.stylus_on_overlay = on_overlay; + self.stylus_on_toolbar = on_toolbar; + self.finish_toolbar_item_drag(false); + self.set_toolbar_dragging(false); + self.cancel_toolbar_move_drag(); + self.stylus_tip_down = false; + self.stylus_base_thickness = Some(self.input_state.current_thickness); + self.stylus_pressure_thickness = None; + self.stylus_last_pos = None; + self.pending_stylus_frame = Default::default(); + self.auto_switch_physical_eraser(tool_type); + + if on_overlay { + info!("✏️ Stylus ENTERED overlay surface"); + } else if on_toolbar { + debug!("Stylus entered toolbar surface"); + } else { + debug!("Tablet proximity in on non-overlay surface"); + } + } + + fn auto_switch_physical_eraser( + &mut self, + tool_type: Option, + ) { + if !self.config.tablet.auto_eraser_switch + || !tool_type.is_some_and(|tool_type| tool_type.is_eraser()) + || self.input_state.active_tool() == Tool::Eraser + { + return; + } + self.stylus_pre_eraser_tool_override = self.input_state.tool_override(); + self.input_state.set_tool_override(Some(Tool::Eraser)); + self.stylus_auto_switched_to_eraser = true; + info!( + "Auto-switched to eraser (physical eraser detected), saved previous: {:?}", + self.stylus_pre_eraser_tool_override + ); + } + + fn handle_stylus_proximity_out(&mut self, proxy: &ZwpTabletToolV2) { + let tool_id = proxy.id(); + let tool_type = self.stylus_tool_types.get(&tool_id).copied(); + debug!( + "Tablet proximity out: tool {:?}, type: {:?}", + tool_id, tool_type + ); + self.commit_pending_stylus_frame(); + self.cancel_region_selection_from(RegionInputSource::Stylus); + self.take_retired_stylus_contact(); + let hover_cursor_pos = self.stylus_hover_cursor_pos(); + self.stylus_tip_down = false; + self.stylus_on_overlay = false; + self.stylus_on_toolbar = false; + self.finish_toolbar_item_drag(false); + self.set_toolbar_dragging(false); + self.cancel_toolbar_move_drag(); + if let Some(surface) = self.stylus_surface.take() + && self.toolbar.is_toolbar_surface(&surface) + { + self.toolbar.pointer_leave(&surface); + self.toolbar.mark_dirty(); + self.input_state.needs_redraw = true; + } + self.stylus_pressure_thickness = None; + self.stylus_last_pos = None; + self.mark_stylus_hover_cursor_dirty(hover_cursor_pos, None); + self.restore_tool_after_physical_eraser(); + } + + fn restore_tool_after_physical_eraser(&mut self) { + if !self.stylus_auto_switched_to_eraser { + return; + } + let restored_tool = self.stylus_pre_eraser_tool_override; + self.input_state.set_tool_override(restored_tool); + self.stylus_auto_switched_to_eraser = false; + self.stylus_pre_eraser_tool_override = None; + info!( + "Restored previous tool after eraser proximity out: {:?}", + restored_tool + ); + } + + fn handle_stylus_down(&mut self, conn: &Connection, qh: &QueueHandle) { + if self.stylus_contact_retired { + return; + } + self.input_state.dismiss_ocr_scan_result(); + if self.handle_stylus_region_down() || self.handle_stylus_eyedropper_down() { + return; + } + if self.inline_toolbars_active() + && self.toolbar.is_visible() + && self.handle_inline_stylus_down(conn, qh) + { + return; + } + if self.handle_toolbar_stylus_down(conn, qh) || !self.stylus_on_overlay { + return; + } + self.queue_stylus_down(); + } + + fn handle_stylus_region_down(&mut self) -> bool { + if !self.input_state.region_is_active() { + return false; + } + if self.stylus_on_toolbar { + self.cancel_region_for_toolbar_interaction(); + return false; + } + if !self.stylus_on_overlay { + return false; + } + let (x, y) = self.current_or_pending_stylus_position(); + if let Some(action) = self.region_review_action_at((x, y)) { + self.submit_region_review_action(action); + self.retire_stylus_contact(); + } else if self.region_review_bar_contains((x, y)) { + self.retire_stylus_contact(); + } else { + self.begin_region_selection(RegionInputSource::Stylus, x, y); + } + true + } + + fn handle_stylus_eyedropper_down(&mut self) -> bool { + if !self.input_state.eyedropper_is_active() { + return false; + } + if self.stylus_on_toolbar { + self.cancel_eyedropper(); + return false; + } + if !self.stylus_on_overlay { + return false; + } + let (x, y) = self.current_or_pending_stylus_position(); + self.sample_eyedropper(x, y); + true + } + + fn handle_inline_stylus_down(&mut self, conn: &Connection, qh: &QueueHandle) -> bool { + let position = self.current_or_pending_stylus_position(); + if !self.inline_toolbar_press(position, Some(conn), Some(qh)) { + return false; + } + self.stylus_on_toolbar = true; + self.set_toolbar_dragging(self.toolbar_dragging()); + true + } + + fn handle_toolbar_stylus_down(&mut self, conn: &Connection, qh: &QueueHandle) -> bool { + if !self.stylus_on_toolbar { + return false; + } + let (x, y) = self.current_or_pending_stylus_position(); + self.set_current_mouse(x as i32, y as i32); + if let Some(surface) = self.stylus_surface.as_ref() + && let Some((intent, drag)) = self.toolbar.pointer_press(surface, (x, y)) + { + self.set_toolbar_dragging(drag); + let event = intent_to_event(intent, self.toolbar.last_snapshot()); + self.handle_toolbar_event(event, Some(conn), Some(qh)); + self.toolbar.mark_dirty(); + self.input_state.needs_redraw = true; + self.refresh_keyboard_interactivity(); + } + true + } + + fn handle_stylus_up(&mut self) { + let retired_contact = self.take_retired_stylus_contact(); + if self.input_state.region_is_active() { + if self.stylus_on_overlay { + let (x, y) = self.current_or_pending_stylus_position(); + self.finish_region_selection(RegionInputSource::Stylus, x, y); + } else { + self.cancel_region_selection_from(RegionInputSource::Stylus); + } + return; + } + let inline_active = self.inline_toolbars_active() && self.toolbar.is_visible(); + if inline_active && self.stylus_on_toolbar { + let (x, y) = self.current_mouse(); + self.inline_toolbar_release((x as f64, y as f64)); + self.stylus_on_toolbar = false; + self.set_toolbar_dragging(false); + self.end_toolbar_move_drag(); + return; + } + if self.stylus_on_toolbar { + self.finish_toolbar_item_drag(true); + self.set_toolbar_dragging(false); + self.end_toolbar_move_drag(); + return; + } + if self.stylus_on_overlay && !retired_contact { + self.queue_stylus_up(); + } + } + + fn handle_stylus_motion(&mut self, conn: &Connection, qh: &QueueHandle, x: f64, y: f64) { + if self.handle_modal_stylus_motion(x, y) || self.handle_stylus_move_drag(x, y) { + return; + } + let previous_hover = self.stylus_hover_cursor_pos(); + if self.handle_toolbar_stylus_motion(conn, qh, x, y) { + return; + } + if self.inline_toolbars_active() && self.toolbar.is_visible() { + self.stylus_last_pos = Some((x, y)); + if self.inline_toolbar_motion((x, y)) { + self.commit_pending_stylus_frame(); + self.stylus_last_pos = Some((x, y)); + self.stylus_on_toolbar = true; + self.mark_stylus_hover_cursor_dirty(previous_hover, None); + return; + } + self.stylus_on_toolbar = false; + } + if self.stylus_on_overlay { + self.queue_stylus_motion(x, y); + } + } + + fn handle_modal_stylus_motion(&mut self, x: f64, y: f64) -> bool { + if self.input_state.region_is_active() && self.stylus_on_overlay { + self.stylus_last_pos = Some((x, y)); + self.set_current_mouse(x.round() as i32, y.round() as i32); + self.update_region_selection(RegionInputSource::Stylus, x, y); + return true; + } + if self.input_state.eyedropper_is_active() && self.stylus_on_overlay { + self.stylus_last_pos = Some((x, y)); + self.set_current_mouse(x.round() as i32, y.round() as i32); + self.update_eyedropper_hover(x, y); + return true; + } + false + } + + fn handle_stylus_move_drag(&mut self, x: f64, y: f64) -> bool { + if !self.is_move_dragging() { + return false; + } + let Some(kind) = self.active_move_drag_kind() else { + return false; + }; + if self.stylus_on_toolbar { + self.handle_toolbar_move(kind, (x, y)); + } else { + self.handle_toolbar_move_screen(kind, (x, y)); + } + self.toolbar.mark_dirty(); + self.input_state.needs_redraw = true; + self.set_current_mouse(x as i32, y as i32); + true + } + + fn handle_toolbar_stylus_motion( + &mut self, + conn: &Connection, + qh: &QueueHandle, + x: f64, + y: f64, + ) -> bool { + if !self.stylus_on_toolbar { + return false; + } + self.stylus_last_pos = Some((x, y)); + if let Some(surface) = self.stylus_surface.as_ref() { + let event = self.toolbar.pointer_motion(surface, (x, y)); + if self.toolbar_dragging() { + let intent = event.or_else(|| self.move_drag_intent(x, y)); + if let Some(intent) = intent { + let event = intent_to_event(intent, self.toolbar.last_snapshot()); + self.handle_toolbar_event(event, Some(conn), Some(qh)); + } + } else { + self.toolbar.mark_dirty(); + } + self.input_state.needs_redraw = true; + self.refresh_keyboard_interactivity(); + } + self.set_current_mouse(x as i32, y as i32); + true + } +} + impl Dispatch for WaylandState { fn event( state: &mut Self, @@ -131,294 +440,19 @@ impl Dispatch for WaylandState { use wayland_protocols::wp::tablet::zv2::client::zwp_tablet_tool_v2::Event; match event { Event::ProximityIn { surface, .. } => { - let tool_id = _proxy.id(); - let tool_type = state.stylus_tool_types.get(&tool_id).copied(); - debug!( - "Tablet proximity in: tool {:?}, type: {:?}", - tool_id, tool_type - ); - let on_overlay = state - .surface - .wl_surface() - .is_some_and(|s| s.id() == surface.id()); - let on_toolbar = state.toolbar.is_toolbar_surface(&surface); - state.stylus_surface = Some(surface.clone()); - state.stylus_on_overlay = on_overlay; - state.stylus_on_toolbar = on_toolbar; - state.finish_toolbar_item_drag(false); - state.set_toolbar_dragging(false); - state.cancel_toolbar_move_drag(); - state.stylus_tip_down = false; - state.stylus_base_thickness = Some(state.input_state.current_thickness); - state.stylus_pressure_thickness = None; - state.stylus_last_pos = None; - state.pending_stylus_frame = Default::default(); - - // Auto-switch to eraser if physical tool is eraser (and config enables it) - if state.config.tablet.auto_eraser_switch - && let Some(tool_type) = tool_type - && tool_type.is_eraser() - { - // Only auto-switch if not already on eraser - if state.input_state.active_tool() != Tool::Eraser { - // Save the current tool override before switching - state.stylus_pre_eraser_tool_override = state.input_state.tool_override(); - state.input_state.set_tool_override(Some(Tool::Eraser)); - state.stylus_auto_switched_to_eraser = true; - info!( - "Auto-switched to eraser (physical eraser detected), saved previous: {:?}", - state.stylus_pre_eraser_tool_override - ); - } - } - - if on_overlay { - info!("✏️ Stylus ENTERED overlay surface"); - } else if state.toolbar.is_toolbar_surface(&surface) { - debug!("Stylus entered toolbar surface"); - } else { - debug!("Tablet proximity in on non-overlay surface"); - } + state.handle_stylus_proximity_in(_proxy, surface); } Event::ProximityOut => { - let tool_id = _proxy.id(); - let tool_type = state.stylus_tool_types.get(&tool_id).copied(); - debug!( - "Tablet proximity out: tool {:?}, type: {:?}", - tool_id, tool_type - ); - state.commit_pending_stylus_frame(); - // The tip is gone and no Up is coming, so a region drag *this - // pen* started must end here rather than keeping the selector — - // and any OCR-owned freeze — alive until the user cancels by - // hand. A region the mouse or a finger is dragging is not ours - // to withdraw. - state.cancel_region_selection_from(RegionInputSource::Stylus); - // The pen is gone, so the tip-up the latch was waiting for is - // never coming; leaving it armed would swallow a later contact. - state.take_retired_stylus_contact(); - let hover_cursor_pos = state.stylus_hover_cursor_pos(); - state.stylus_tip_down = false; - state.stylus_on_overlay = false; - state.stylus_on_toolbar = false; - state.finish_toolbar_item_drag(false); - state.set_toolbar_dragging(false); - state.cancel_toolbar_move_drag(); - if let Some(surf) = state.stylus_surface.take() - && state.toolbar.is_toolbar_surface(&surf) - { - state.toolbar.pointer_leave(&surf); - state.toolbar.mark_dirty(); - state.input_state.needs_redraw = true; - } - state.stylus_pressure_thickness = None; - state.stylus_last_pos = None; - state.mark_stylus_hover_cursor_dirty(hover_cursor_pos, None); - - // Restore previous tool if we auto-switched to eraser - if state.stylus_auto_switched_to_eraser { - let restored_tool = state.stylus_pre_eraser_tool_override; - state.input_state.set_tool_override(restored_tool); - state.stylus_auto_switched_to_eraser = false; - state.stylus_pre_eraser_tool_override = None; - info!( - "Restored previous tool after eraser proximity out: {:?}", - restored_tool - ); - } - - // Note: We keep the tool type in the map - tools persist across proximity events + state.handle_stylus_proximity_out(_proxy); } Event::Down { .. } => { - // A contact a screen modal disowned is not the user pressing — - // it is the pen that was already down, still reported until it - // lifts. Swallow it whole rather than declining one branch: - // falling through queues the tip-down, and the frame commit - // would start a region (or a stroke) from it one hop later. - // - // Protocol-unreachable today, since the latch only clears on a - // tip-up or proximity-out and a press must follow one of those. - // It is enforced rather than argued because the surrounding - // pressure, motion, and release guards all rely on it. - if state.stylus_contact_retired { - return; - } - // Before any surface-specific routing: a press on the Review - // bar, a toolbar or an eyedropper never reaches the frame - // commit, and the card has to go for all of them. - state.input_state.dismiss_ocr_scan_result(); - if state.input_state.region_is_active() { - if state.stylus_on_toolbar { - state.cancel_region_for_toolbar_interaction(); - } else if state.stylus_on_overlay { - let (x, y) = state.current_or_pending_stylus_position(); - if let Some(action) = state.region_review_action_at((x, y)) { - state.submit_region_review_action(action); - state.retire_stylus_contact(); - return; - } - if state.region_review_bar_contains((x, y)) { - state.retire_stylus_contact(); - return; - } - state.begin_region_selection(RegionInputSource::Stylus, x, y); - return; - } - } - if state.input_state.eyedropper_is_active() { - if state.stylus_on_toolbar { - state.cancel_eyedropper(); - } else if state.stylus_on_overlay { - let (x, y) = state.current_or_pending_stylus_position(); - state.sample_eyedropper(x, y); - return; - } - } - let inline_active = state.inline_toolbars_active() && state.toolbar.is_visible(); - if inline_active { - let (sx, sy) = state.current_or_pending_stylus_position(); - if state.inline_toolbar_press((sx, sy), Some(conn), Some(qh)) { - state.stylus_on_toolbar = true; - state.set_toolbar_dragging(state.toolbar_dragging()); - return; - } - } - if state.stylus_on_toolbar { - let (sx, sy) = state.current_or_pending_stylus_position(); - state.set_current_mouse(sx as i32, sy as i32); - if let Some(surface) = state.stylus_surface.as_ref() - && let Some((intent, drag)) = state.toolbar.pointer_press(surface, (sx, sy)) - { - state.set_toolbar_dragging(drag); - let evt = intent_to_event(intent, state.toolbar.last_snapshot()); - state.handle_toolbar_event(evt, Some(conn), Some(qh)); - state.toolbar.mark_dirty(); - state.input_state.needs_redraw = true; - state.refresh_keyboard_interactivity(); - } - return; - } - if !state.stylus_on_overlay { - return; - } - state.queue_stylus_down(); + state.handle_stylus_down(conn, qh); } Event::Up => { - // Read before the modal branches so the latch is consumed by - // the tip-up that actually ends the disowned contact, whatever - // else this Up does. - let retired_contact = state.take_retired_stylus_contact(); - if state.input_state.region_is_active() { - if state.stylus_on_overlay { - let (x, y) = state.current_or_pending_stylus_position(); - // A no-op unless this pen owns the region, so a retired - // contact lifting cannot submit one the mouse or a - // finger is still drawing. - state.finish_region_selection(RegionInputSource::Stylus, x, y); - } else { - // The tip left the overlay before lifting; there is no - // region of ours to submit, so withdraw only our own. - state.cancel_region_selection_from(RegionInputSource::Stylus); - } - return; - } - let inline_active = state.inline_toolbars_active() && state.toolbar.is_visible(); - if inline_active && state.stylus_on_toolbar { - let (mx, my) = state.current_mouse(); - state.inline_toolbar_release((mx as f64, my as f64)); - state.stylus_on_toolbar = false; - state.set_toolbar_dragging(false); - state.end_toolbar_move_drag(); - return; - } - if state.stylus_on_toolbar { - state.finish_toolbar_item_drag(true); - state.set_toolbar_dragging(false); - state.end_toolbar_move_drag(); - return; - } - if !state.stylus_on_overlay || retired_contact { - return; - } - state.queue_stylus_up(); + state.handle_stylus_up(); } Event::Motion { x, y } => { - if state.input_state.region_is_active() && state.stylus_on_overlay { - state.stylus_last_pos = Some((x, y)); - state.set_current_mouse(x.round() as i32, y.round() as i32); - // Dropped unless this pen owns the region: a pen hovering - // over the overlay, or one whose contact was retired, must - // not drag somebody else's selection around. - state.update_region_selection(RegionInputSource::Stylus, x, y); - return; - } - if state.input_state.eyedropper_is_active() && state.stylus_on_overlay { - state.stylus_last_pos = Some((x, y)); - state.set_current_mouse(x.round() as i32, y.round() as i32); - state.update_eyedropper_hover(x, y); - return; - } - if state.is_move_dragging() - && let Some(kind) = state.active_move_drag_kind() - { - // On toolbar surface: coords are toolbar-local, need conversion - // On main surface: coords are already screen-relative - if state.stylus_on_toolbar { - state.handle_toolbar_move(kind, (x, y)); - } else { - state.handle_toolbar_move_screen(kind, (x, y)); - } - state.toolbar.mark_dirty(); - state.input_state.needs_redraw = true; - state.set_current_mouse(x as i32, y as i32); - return; - } - let previous_hover_cursor_pos = state.stylus_hover_cursor_pos(); - let inline_active = state.inline_toolbars_active() && state.toolbar.is_visible(); - if state.stylus_on_toolbar { - let xf = x; - let yf = y; - state.stylus_last_pos = Some((xf, yf)); - if let Some(surface) = state.stylus_surface.as_ref() { - let evt = state.toolbar.pointer_motion(surface, (xf, yf)); - if state.toolbar_dragging() { - // Use move_drag_intent if pointer_motion didn't return an intent - // This allows dragging to continue when stylus moves outside hit region - let intent = evt.or_else(|| state.move_drag_intent(xf, yf)); - if let Some(intent) = intent { - let evt = intent_to_event(intent, state.toolbar.last_snapshot()); - state.handle_toolbar_event(evt, Some(conn), Some(qh)); - } - } else { - state.toolbar.mark_dirty(); - } - state.input_state.needs_redraw = true; - state.refresh_keyboard_interactivity(); - } - state.set_current_mouse(x as i32, y as i32); - return; - } - if inline_active { - // Toolbar hit-testing is immediate, even though drawing samples are - // committed on tablet frames. Keep the stylus cache current so a - // following Down uses the same toolbar-local position. - state.stylus_last_pos = Some((x, y)); - if state.inline_toolbar_motion((x, y)) { - state.commit_pending_stylus_frame(); - // Flushing pending overlay state can restore an older drawing - // position; toolbar Down handling needs the latest hover point. - state.stylus_last_pos = Some((x, y)); - state.stylus_on_toolbar = true; - state.mark_stylus_hover_cursor_dirty(previous_hover_cursor_pos, None); - return; - } else { - state.stylus_on_toolbar = false; - } - } - if !state.stylus_on_overlay { - return; - } - state.queue_stylus_motion(x, y); + state.handle_stylus_motion(conn, qh, x, y); } Event::Pressure { pressure } => { if drop_stylus_pressure( From 791978822251236e3067d77b2743379547c531ff Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:36:46 +0200 Subject: [PATCH 33/53] refactor(wayland): split event loop maintenance --- src/backend/wayland/backend/event_loop/mod.rs | 356 ++++++++---------- 1 file changed, 161 insertions(+), 195 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 4c7652e6..760d1b13 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -103,86 +103,7 @@ pub(super) fn run_event_loop( || state.frozen.is_in_progress() || state.zoom.is_in_progress() || state.overlay_blocks_event_loop(); - let frame_callback_pending = state.surface.frame_callback_pending(); - let vsync_enabled = state.config.performance.enable_vsync; - - // Calculate timeout for dispatch: - // - If capture active, not configured, or waiting for VSync: block until a - // producer wakes us or a real operation deadline expires - // - If VSync disabled and needs_redraw: use frame rate cap timeout - // - Otherwise: use animation timeout - let should_block = capture_active - || !state.surface.is_configured() - || (vsync_enabled && frame_callback_pending); - let now = Instant::now(); - // Transient toolbar-fade animation shares the animation timeout slot: - // a fade in flight (or a pending 4s idle dim) wakes the loop, and a - // settled fade contributes nothing. - let animation_timeout = min_timeout( - min_timeout( - min_timeout( - state.ui_animation_timeout(now), - state.top_strip_fade_timeout(now), - ), - state.inline_toolbar_tooltip_timeout(now), - ), - // A still OCR card under reduced motion asks for no frames, so it - // needs one deadline to be taken away on. - state.input_state.ocr_scan_wake_after(now), - ); - let toolbar_handoff_timeout = state.toolbar_drag_handoff_timeout(now); - let autosave_timeout = session_save::autosave_timeout(state, now); - let focus_exit_timeout = state.focus_exit_timeout(now); - let command_palette_repeat_timeout = state.input_state.command_palette_repeat_timeout(now); - let font_picker_repeat_timeout = state.input_state.font_picker_repeat_timeout(now); - let capture_timeout = capture::capture_timeout(state, now); - let interaction_timeout = - interaction::interaction_timeout(state.spotlight_wheel_idle_deadline, now); - let durable_action_timeout = durable_action_retry_timeout(state, now); - // Backend output actions are drained one at a time, and the toolbar - // persistence queue drains on the same pass. If either holds - // drainable work, loop immediately even when VSync is disabled and - // the preceding action's redraw cleared `needs_redraw`. Entries - // deferred behind a runtime-state barrier are not drainable — the - // writer completion that resolves the barrier wakes the loop. - let pending_backend_action_timeout = (state.input_state.has_pending_backend_actions() - || state.toolbar_persistence_drain_ready()) - .then_some(Duration::ZERO); - let timeout = if should_block { - min_timeout(autosave_timeout, focus_exit_timeout) - } else if !vsync_enabled && state.input_state.needs_redraw { - // When VSync is off and we need to redraw, wake up when frame budget allows - let frame_cap_timeout = render::frame_rate_cap_timeout( - state.config.performance.max_fps_no_vsync, - last_render_time, - ); - // Use the shorter of frame cap timeout and animation timeout. - // If unlimited FPS (None) and no animation, use zero to avoid blocking. - let merged = match (frame_cap_timeout, animation_timeout) { - (Some(fc), Some(anim)) => Some(fc.min(anim)), - (Some(fc), None) => Some(fc), - (None, _) => Some(Duration::ZERO), - }; - min_timeout(merged, min_timeout(autosave_timeout, focus_exit_timeout)) - } else { - min_timeout( - animation_timeout, - min_timeout(autosave_timeout, focus_exit_timeout), - ) - }; - let timeout = min_timeout(timeout, toolbar_handoff_timeout); - let timeout = min_timeout(timeout, command_palette_repeat_timeout); - let timeout = min_timeout(timeout, font_picker_repeat_timeout); - let timeout = min_timeout(timeout, capture_timeout); - let timeout = min_timeout(timeout, interaction_timeout); - let timeout = min_timeout(timeout, durable_action_timeout); - let timeout = min_timeout(timeout, pending_backend_action_timeout); - // A radial menu waiting out its paint delay must appear without - // further input events. - let timeout = min_timeout(timeout, state.input_state.radial_menu_paint_timeout(now)); - // A held key must wake the loop to fire its next auto-repeat. - let timeout = min_timeout(timeout, state.key_repeat_timeout(now)); - let timeout = min_timeout(timeout, state.input_state.sequence_timeout(now)); + let timeout = event_loop_timeout(state, capture_active, last_render_time); if let Err(e) = dispatch::dispatch_events(event_queue, state, runtime_wake, signal_state, timeout) { @@ -213,124 +134,11 @@ pub(super) fn run_event_loop( if break_on_requested_exit(state) { break; } - if state.surface.is_xdg_window() - && !state.input_state.should_exit - && !state.has_keyboard_focus() - && !state.desktop_open_in_progress() - && state.focus_exit_suppression_expired(Instant::now()) - { - if state.xdg_focus_loss_exits_overlay() { - warn!("Keyboard focus not restored after clipboard action; exiting overlay"); - state.clear_focus_exit_suppression(); - notification::send_notification_async( - &state.tokio_handle, - "Wayscriber lost focus".to_string(), - "The desktop could not keep the overlay focused, so Wayscriber closed it." - .to_string(), - Some("dialog-warning".to_string()), - ); - state.input_state.should_exit = true; - } else { - warn!( - "Keyboard focus not restored after clipboard action; keeping overlay open (ui.xdg_focus_loss_behavior=stay)" - ); - state.clear_focus_exit_suppression(); - state.set_xdg_close_guard_for(Duration::from_millis(2500)); - state.request_xdg_activation(qh); - } - } + handle_xdg_focus_loss(state, qh); // Adjust keyboard interactivity if toolbar visibility changed. - state.sync_toolbar_visibility(qh); - - if state.finish_toolbar_drag_handoff_if_due(Instant::now()) { - let _ = conn.flush(); - } - - // Advance any delayed history playback (undo/redo with delay). - if state - .input_state - .tick_delayed_history(std::time::Instant::now()) - { - state.toolbar.mark_dirty(); - state.input_state.needs_redraw = true; - } - if state.input_state.has_pending_history() { - state.input_state.needs_redraw = true; - } - - if state - .input_state - .tick_command_palette_repeat(Instant::now()) - { - state.input_state.needs_redraw = true; - } - - if state.input_state.tick_font_picker_repeat(Instant::now()) { - state.input_state.needs_redraw = true; - } - - // Synthesize auto-repeat for a held key (sctk's calloop repeat is not - // wired to this manual loop). `dispatch_key_repeat` sets needs_redraw - // as its routed action requires. - state.tick_key_repeat(Instant::now(), conn, qh); - if state.input_state.expire_pending_sequence(Instant::now()) { - state.input_state.needs_redraw = true; - } - - if !capture_active && state.ui_animation_due(std::time::Instant::now()) { - state.input_state.needs_redraw = true; - } - if state.input_state.ocr_scan_due(std::time::Instant::now()) { - state.input_state.needs_redraw = true; - } - - // When the radial paint deadline passes, this requests the redraw - // that paints the menu (no-op before the deadline and after paint). - state - .input_state - .tick_radial_menu_paint(std::time::Instant::now()); - - capture::handle_pending_actions(state, qh); - // Desktop-open handoff (and other pending-action completions) can set - // should_exit after the post-dispatch check. Break here so exit does not - // wait on another compositor wake under a blocking dispatch timeout. - if break_on_requested_exit(state) { + if advance_post_dispatch_state(state, conn, qh, capture_active) { break; } - state.sync_overlay_interactivity(); - state.apply_onboarding_hints(); - - // Hand changed palette recents to the dedicated persistence worker. - // The event loop only replaces a bounded in-memory snapshot and wakes - // the worker; directory creation, atomic replacement, and fsync happen - // off-dispatch. Retain the dirty flag if the worker is unavailable. - if state.input_state.command_palette_recents_dirty() { - let recents = state.input_state.command_palette_recent.clone(); - if state.palette_recents.request(&recents) { - state.input_state.clear_command_palette_recents_dirty(); - } - } - - if let Err(err) = session_save::autosave_if_due(state, Instant::now()) { - warn!("Failed to autosave session state: {}", err); - } - - // Reconcile after every input mutation in this pass, including GTK - // toolbar actions and synthesized repeats, so a newly opened modal or - // completed text edit cannot leave the canvas IME enabled until a - // later event-loop iteration. - if !state.input_state.should_exit { - state.reconcile_text_input(); - } - - // Advance the top-strip idle fade before the snapshot consumers - // (GTK bridge below, layer/inline toolbar rendering inside - // maybe_render) read `top_fade` for this pass. - let now = Instant::now(); - state.update_top_strip_fade(now); - state.update_inline_toolbar_tooltip(now); - - state.push_gtk_toolbar_update(); if let Some(err) = render::maybe_render( state, @@ -378,6 +186,164 @@ pub(super) fn run_event_loop( EventLoopOutcome { loop_error } } +fn advance_post_dispatch_state( + state: &mut WaylandState, + conn: &Connection, + qh: &wayland_client::QueueHandle, + capture_active: bool, +) -> bool { + state.sync_toolbar_visibility(qh); + if state.finish_toolbar_drag_handoff_if_due(Instant::now()) { + let _ = conn.flush(); + } + if state.input_state.tick_delayed_history(Instant::now()) { + state.toolbar.mark_dirty(); + state.input_state.needs_redraw = true; + } + if state.input_state.has_pending_history() { + state.input_state.needs_redraw = true; + } + if state + .input_state + .tick_command_palette_repeat(Instant::now()) + { + state.input_state.needs_redraw = true; + } + if state.input_state.tick_font_picker_repeat(Instant::now()) { + state.input_state.needs_redraw = true; + } + state.tick_key_repeat(Instant::now(), conn, qh); + if state.input_state.expire_pending_sequence(Instant::now()) { + state.input_state.needs_redraw = true; + } + if !capture_active && state.ui_animation_due(Instant::now()) { + state.input_state.needs_redraw = true; + } + if state.input_state.ocr_scan_due(Instant::now()) { + state.input_state.needs_redraw = true; + } + state.input_state.tick_radial_menu_paint(Instant::now()); + capture::handle_pending_actions(state, qh); + if break_on_requested_exit(state) { + return true; + } + state.sync_overlay_interactivity(); + state.apply_onboarding_hints(); + persist_post_dispatch_state(state); + state.push_gtk_toolbar_update(); + false +} + +fn persist_post_dispatch_state(state: &mut WaylandState) { + if state.input_state.command_palette_recents_dirty() { + let recents = state.input_state.command_palette_recent.clone(); + if state.palette_recents.request(&recents) { + state.input_state.clear_command_palette_recents_dirty(); + } + } + if let Err(err) = session_save::autosave_if_due(state, Instant::now()) { + warn!("Failed to autosave session state: {}", err); + } + if !state.input_state.should_exit { + state.reconcile_text_input(); + } + let now = Instant::now(); + state.update_top_strip_fade(now); + state.update_inline_toolbar_tooltip(now); +} + +fn event_loop_timeout( + state: &WaylandState, + capture_active: bool, + last_render_time: Option, +) -> Option { + let vsync_enabled = state.config.performance.enable_vsync; + let should_block = capture_active + || !state.surface.is_configured() + || (vsync_enabled && state.surface.frame_callback_pending()); + let now = Instant::now(); + let animation_timeout = min_timeout( + min_timeout( + min_timeout( + state.ui_animation_timeout(now), + state.top_strip_fade_timeout(now), + ), + state.inline_toolbar_tooltip_timeout(now), + ), + state.input_state.ocr_scan_wake_after(now), + ); + let autosave_timeout = session_save::autosave_timeout(state, now); + let focus_exit_timeout = state.focus_exit_timeout(now); + let base_timeout = if should_block { + min_timeout(autosave_timeout, focus_exit_timeout) + } else if !vsync_enabled && state.input_state.needs_redraw { + let frame_cap_timeout = render::frame_rate_cap_timeout( + state.config.performance.max_fps_no_vsync, + last_render_time, + ); + let frame_timeout = match (frame_cap_timeout, animation_timeout) { + (Some(frame), Some(animation)) => Some(frame.min(animation)), + (Some(frame), None) => Some(frame), + (None, _) => Some(Duration::ZERO), + }; + min_timeout( + frame_timeout, + min_timeout(autosave_timeout, focus_exit_timeout), + ) + } else { + min_timeout( + animation_timeout, + min_timeout(autosave_timeout, focus_exit_timeout), + ) + }; + let pending_backend_action_timeout = (state.input_state.has_pending_backend_actions() + || state.toolbar_persistence_drain_ready()) + .then_some(Duration::ZERO); + [ + state.toolbar_drag_handoff_timeout(now), + state.input_state.command_palette_repeat_timeout(now), + state.input_state.font_picker_repeat_timeout(now), + capture::capture_timeout(state, now), + interaction::interaction_timeout(state.spotlight_wheel_idle_deadline, now), + durable_action_retry_timeout(state, now), + pending_backend_action_timeout, + state.input_state.radial_menu_paint_timeout(now), + state.key_repeat_timeout(now), + state.input_state.sequence_timeout(now), + ] + .into_iter() + .fold(base_timeout, min_timeout) +} + +fn handle_xdg_focus_loss(state: &mut WaylandState, qh: &wayland_client::QueueHandle) { + if !state.surface.is_xdg_window() + || state.input_state.should_exit + || state.has_keyboard_focus() + || state.desktop_open_in_progress() + || !state.focus_exit_suppression_expired(Instant::now()) + { + return; + } + if state.xdg_focus_loss_exits_overlay() { + warn!("Keyboard focus not restored after clipboard action; exiting overlay"); + state.clear_focus_exit_suppression(); + notification::send_notification_async( + &state.tokio_handle, + "Wayscriber lost focus".to_string(), + "The desktop could not keep the overlay focused, so Wayscriber closed it.".to_string(), + Some("dialog-warning".to_string()), + ); + state.input_state.should_exit = true; + } else { + warn!( + "Keyboard focus not restored after clipboard action; keeping overlay open (ui.xdg_focus_loss_behavior=stay)" + ); + state.clear_focus_exit_suppression(); + state.set_xdg_close_guard_for(Duration::from_millis(2500)); + state.request_xdg_activation(qh); + } +} + fn install_then_scan( install: impl FnOnce() -> std::io::Result, scan: impl FnOnce(), From dedcad121f91ef7e53040ae43e78ca3363d79c0a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:39:27 +0200 Subject: [PATCH 34/53] refactor(render): isolate committed shape culling --- .../wayland/state/render/canvas/mod.rs | 289 ++++++++++-------- 1 file changed, 163 insertions(+), 126 deletions(-) diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index 42a00238..efae7496 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -169,131 +169,17 @@ impl WaylandState { let replay_ctx = eraser_ctx.replay_context(); let completed_shapes_start = perf.as_ref().map(|_| Instant::now()); - if layer_cache_ready && self.canvas_layer_cache.blit(ctx) { - // Board background and committed shapes came from the baked layer. - debug!("Rendered committed shapes from layer cache"); - if let Some(perf) = perf.as_mut() { - perf.shapes_total = shapes_total; - perf.canvas_layer_cache_used = true; - } - } else { - // Render all completed shapes from active frame - debug!("Rendering {} completed shapes", shapes_total); - let shapes = &self.input_state.boards.active_frame().shapes; - if let Some(perf) = perf.as_mut() { - perf.shapes_total = shapes.len(); - } - - // Manual Culling: Only render shapes that intersect with the damage regions. - // Cairo's internal clipping is efficient for rasterization, but sending - // thousands of shapes to Cairo still incurs overhead for geometry processing. - // A simple bounding box check here eliminates that overhead. - let render_drawn_shape = |drawn_shape: &crate::draw::DrawnShape| { - super::super::canvas_layer::render_committed_shape( - ctx, - drawn_shape, - &replay_ctx, - text_halo_enabled, - ) - }; - - // Compute bounding box of all damage regions for fast rejection - // (Union of all dirty rects). These bounds are in world coordinates. - let damage_bounds = - damage_world - .iter() - .fold(None, |acc: Option, r| match acc { - None => Some(*r), - Some(u) => { - // Manual union to avoid extra allocations. - let min_x = u.x.min(r.x); - let min_y = u.y.min(r.y); - let max_x = - u.x.saturating_add(u.width).max(r.x.saturating_add(r.width)); - let max_y = - u.y.saturating_add(u.height) - .max(r.y.saturating_add(r.height)); - Some(crate::util::Rect { - x: min_x, - y: min_y, - width: max_x - min_x, - height: max_y - min_y, - }) - } - }); - - if let Some(bounds) = damage_bounds { - // Expand bounds slightly to account for line width/glow that might extend outside - // the logical shape bounds (though Shape::bounding_box should theoretically cover it, - // safety margin is good). - let margin = 2; - let safe_x = bounds.x.saturating_sub(margin); - let safe_y = bounds.y.saturating_sub(margin); - let safe_width = bounds.width.saturating_add(margin * 2); - let safe_height = bounds.height.saturating_add(margin * 2); - let safe_bounds = if canvas_transform_active { - crate::util::Rect::new(safe_x, safe_y, safe_width, safe_height) - } else { - // Clamp to logical surface bounds to avoid negative coords or overflow. - let logical_width = width as i32; - let logical_height = height as i32; - let clamped_x = safe_x.max(0); - let clamped_y = safe_y.max(0); - let max_width = logical_width.saturating_sub(clamped_x); - let max_height = logical_height.saturating_sub(clamped_y); - crate::util::Rect::new( - clamped_x, - clamped_y, - safe_width.min(max_width), - safe_height.min(max_height), - ) - }; - - if let Some(safe_bounds) = safe_bounds { - let mut shapes_tested = 0usize; - let mut shapes_rendered = 0usize; - for drawn_shape in shapes { - shapes_tested += 1; - // If shape has no bounding box (e.g. empty freehand), skip it. - // If it has one, check intersection. Uses the per-shape - // memoized bounds to avoid O(points) recomputation per frame. - if let Some(bbox) = drawn_shape.bounding_box() { - // Check intersection: - // !(bbox.left > safe.right || bbox.right < safe.left || ...) - let bbox_right = bbox.x.saturating_add(bbox.width); - let bbox_bottom = bbox.y.saturating_add(bbox.height); - let safe_right = safe_bounds.x.saturating_add(safe_bounds.width); - let safe_bottom = safe_bounds.y.saturating_add(safe_bounds.height); - - let intersects = !(bbox.x >= safe_right - || bbox_right <= safe_bounds.x - || bbox.y >= safe_bottom - || bbox_bottom <= safe_bounds.y); - - if intersects { - render_drawn_shape(drawn_shape); - shapes_rendered += 1; - } - } - } - if let Some(perf) = perf.as_mut() { - perf.shapes_tested = shapes_tested; - perf.shapes_rendered = shapes_rendered; - } - } - } else { - // If we don't have damage bounds, render everything to stay correct. - let mut shapes_rendered = 0usize; - for drawn_shape in shapes { - render_drawn_shape(drawn_shape); - shapes_rendered += 1; - } - if let Some(perf) = perf.as_mut() { - perf.shapes_tested = shapes.len(); - perf.shapes_rendered = shapes_rendered; - } - } - } + self.render_committed_canvas_shapes( + ctx, + width, + height, + damage_world, + canvas_transform_active, + layer_cache_ready, + &replay_ctx, + text_halo_enabled, + perf.as_deref_mut(), + ); if let (Some(perf), Some(completed_shapes_start)) = (perf.as_mut(), completed_shapes_start) { perf.stages.completed_shapes = perf @@ -456,6 +342,124 @@ impl WaylandState { Ok(()) } + + #[allow(clippy::too_many_arguments)] + fn render_committed_canvas_shapes( + &mut self, + ctx: &cairo::Context, + width: u32, + height: u32, + damage_world: &[crate::util::Rect], + canvas_transform_active: bool, + layer_cache_ready: bool, + replay_ctx: &crate::draw::EraserReplayContext<'_>, + text_halo_enabled: bool, + mut perf: Option<&mut PerfRenderBreakdown>, + ) { + let shapes = &self.input_state.boards.active_frame().shapes; + if layer_cache_ready && self.canvas_layer_cache.blit(ctx) { + debug!("Rendered committed shapes from layer cache"); + if let Some(perf) = perf.as_mut() { + perf.shapes_total = shapes.len(); + perf.canvas_layer_cache_used = true; + } + return; + } + debug!("Rendering {} completed shapes", shapes.len()); + if let Some(perf) = perf.as_mut() { + perf.shapes_total = shapes.len(); + } + let render_shape = |shape: &crate::draw::DrawnShape| { + super::super::canvas_layer::render_committed_shape( + ctx, + shape, + replay_ctx, + text_halo_enabled, + ) + }; + let Some(bounds) = union_damage_bounds(damage_world) else { + for shape in shapes { + render_shape(shape); + } + if let Some(perf) = perf.as_mut() { + perf.shapes_tested = shapes.len(); + perf.shapes_rendered = shapes.len(); + } + return; + }; + let Some(safe_bounds) = + safe_shape_damage_bounds(bounds, width, height, canvas_transform_active) + else { + return; + }; + let mut shapes_rendered = 0usize; + for shape in shapes { + if shape + .bounding_box() + .is_some_and(|bounds| rects_intersect(bounds, safe_bounds)) + { + render_shape(shape); + shapes_rendered += 1; + } + } + if let Some(perf) = perf.as_mut() { + perf.shapes_tested = shapes.len(); + perf.shapes_rendered = shapes_rendered; + } + } +} + +fn union_damage_bounds(regions: &[crate::util::Rect]) -> Option { + regions.iter().copied().reduce(|union, region| { + let min_x = union.x.min(region.x); + let min_y = union.y.min(region.y); + let max_x = union + .x + .saturating_add(union.width) + .max(region.x.saturating_add(region.width)); + let max_y = union + .y + .saturating_add(union.height) + .max(region.y.saturating_add(region.height)); + crate::util::Rect { + x: min_x, + y: min_y, + width: max_x - min_x, + height: max_y - min_y, + } + }) +} + +fn safe_shape_damage_bounds( + bounds: crate::util::Rect, + width: u32, + height: u32, + canvas_transform_active: bool, +) -> Option { + let margin = 2; + let x = bounds.x.saturating_sub(margin); + let y = bounds.y.saturating_sub(margin); + let width_with_margin = bounds.width.saturating_add(margin * 2); + let height_with_margin = bounds.height.saturating_add(margin * 2); + if canvas_transform_active { + return crate::util::Rect::new(x, y, width_with_margin, height_with_margin); + } + let x = x.max(0); + let y = y.max(0); + crate::util::Rect::new( + x, + y, + width_with_margin.min((width as i32).saturating_sub(x)), + height_with_margin.min((height as i32).saturating_sub(y)), + ) +} + +fn rects_intersect(a: crate::util::Rect, b: crate::util::Rect) -> bool { + let a_right = a.x.saturating_add(a.width); + let a_bottom = a.y.saturating_add(a.height); + let b_right = b.x.saturating_add(b.width); + let b_bottom = b.y.saturating_add(b.height); + !(a.x >= b_right || a_right <= b.x || a.y >= b_bottom || a_bottom <= b.y) } fn provisional_point_count(stroke: &crate::input::tool::ProvisionalToolStroke<'_>) -> usize { @@ -534,9 +538,42 @@ const fn capture_picker_draws_committed( mod tests { use super::{ ArrivedMagnificationWarning, arrived_magnification_warning, capture_picker_draws_committed, - spotlight_magnifier_warning_is_due, + rects_intersect, safe_shape_damage_bounds, spotlight_magnifier_warning_is_due, + union_damage_bounds, }; use crate::draw::SpotlightMagnifierSource; + use crate::util::Rect; + + #[test] + fn damage_union_covers_every_region() { + assert_eq!( + union_damage_bounds(&[ + Rect::new(10, 20, 30, 40).unwrap(), + Rect::new(-5, 50, 20, 10).unwrap(), + ]), + Rect::new(-5, 20, 45, 40) + ); + } + + #[test] + fn shape_damage_margin_clamps_only_in_screen_space() { + let bounds = Rect::new(0, 0, 10, 10).unwrap(); + assert_eq!( + safe_shape_damage_bounds(bounds, 100, 100, false), + Rect::new(0, 0, 14, 14) + ); + assert_eq!( + safe_shape_damage_bounds(bounds, 100, 100, true), + Rect::new(-2, -2, 14, 14) + ); + } + + #[test] + fn edge_touching_shape_is_outside_damage() { + let damage = Rect::new(10, 10, 20, 20).unwrap(); + assert!(!rects_intersect(Rect::new(0, 10, 10, 5).unwrap(), damage)); + assert!(rects_intersect(Rect::new(9, 10, 2, 5).unwrap(), damage)); + } #[test] fn arriving_at_a_page_of_unavailable_loupes_warns_once_not_every_frame() { From 9fc25d46a880d699de7d7eec05373ccd84753f86 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:41:26 +0200 Subject: [PATCH 35/53] refactor(toolbar): split top popover builders --- src/backend/wayland/toolbar/view/top/build.rs | 284 ++++++++++-------- 1 file changed, 165 insertions(+), 119 deletions(-) diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index 3d18a36d..3e8d6870 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -212,73 +212,17 @@ pub(super) fn build_top_view_planned( tree.push(node); } - // --- Shapes popover: the grid plus per-tool options ------------------------ - if snapshot.shape_picker_open - && let Some(anchor) = picker_anchor - { - let rows = model::visible_shape_picker_rows(snapshot, is_simple); - let option_rows = shape_option_rows(snapshot); - let max_row_len = rows.iter().map(Vec::len).max().unwrap_or(0); - if max_row_len > 0 || !option_rows.is_empty() { - let pad = ToolbarLayoutSpec::TOP_POPOVER_PAD; - let option_h = ToolbarLayoutSpec::TOP_OPTION_ROW_H; - let grid_w = max_row_len as f64 * (btn_w + gap) - gap; - let content_w = grid_w.max(160.0) + pad * 2.0; - let grid_h = rows.len() as f64 * (btn_h + gap) - gap; - let content_h = pad * 2.0 - + grid_h.max(0.0) - + option_rows.len() as f64 * (option_h + gap) - + if rows.is_empty() { -gap } else { 0.0 }; - let popover_anchor = popover_anchor_below_contextual_rows(anchor, snapshot, plan); - let placement = - super::super::popover::place_popover(super::super::popover::PopoverSpec { - anchor: popover_anchor, - content: (content_w, content_h), - bounds: (width, height), - gap: ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP, - margin: 4.0, - }); - let (px, py, pw, _ph) = placement.rect; - tree.push(WidgetNode::decor( - "top.shapes.panel", - placement.rect, - WidgetKind::Popover { - caret_x: placement.caret_x, - caret_up: placement.side == super::super::popover::PopoverSide::Below, - }, - )); - let mut row_y = py + pad; - for row in rows { - let mut shape_x = px + pad; - for tool in row { - if !model::tool_visible(snapshot, tool) { - continue; - } - tree.push(tool_button_node( - snapshot, - tool, - format!( - "top.picker.{}", - model::toolbar_item_id_for_tool(tool).as_str() - ), - (shape_x, row_y, btn_w, btn_h), - use_icons, - )); - shape_x += btn_w + gap; - } - row_y += btn_h + gap; - } - for option_row in option_rows { - push_option_row( - &mut tree, - snapshot, - option_row, - (px + pad, row_y, pw - pad * 2.0, option_h), - ); - row_y += option_h + gap; - } - } - } + push_shape_popover( + &mut tree, + snapshot, + plan, + picker_anchor, + (width, height), + (btn_w, btn_h), + gap, + use_icons, + is_simple, + ); // --- Right-aligned chrome island -------------------------------------------- let chrome_metrics = super::ChromeMetrics::for_plan(plan); @@ -335,58 +279,17 @@ pub(super) fn build_top_view_planned( // --- Style pill (island D): contextual tool properties ------------------- push_style_pill(&mut tree, snapshot, plan, band_h); - // --- Overflow popover: the width-dropped items --------------------------- - if snapshot.top_overflow_open - && let Some(anchor) = overflow_anchor - { - let dropped_count = spec.overflow().len(); - if dropped_count > 0 { - let cols = dropped_count.min(5); - let rows = dropped_count.div_ceil(cols); - let pad = 8.0; - let content_w = cols as f64 * btn_w + (cols as f64 - 1.0) * gap + pad * 2.0; - let content_h = rows as f64 * btn_h + (rows as f64 - 1.0) * gap + pad * 2.0; - let popover_anchor = overflow_family_anchor(anchor, snapshot, plan); - let placement = - super::super::popover::place_popover(super::super::popover::PopoverSpec { - anchor: popover_anchor, - content: (content_w, content_h), - bounds: (width, height), - gap: OVERFLOW_ANCHOR_GAP, - margin: OVERFLOW_BOTTOM_MARGIN, - }); - let (px, py, _pw, _ph) = placement.rect; - tree.suppress_interactions_covered_by(placement.rect); - tree.push(WidgetNode::decor( - "top.overflow.panel", - placement.rect, - WidgetKind::Popover { - caret_x: placement.caret_x, - caret_up: placement.side == super::super::popover::PopoverSide::Below, - }, - )); - let item_rect = |index: usize| { - let col = index % cols; - let row = index / cols; - ( - px + pad + col as f64 * (btn_w + gap), - py + pad + row as f64 * (btn_h + gap), - btn_w, - btn_h, - ) - }; - for (index, control) in spec.overflow().iter().copied().enumerate() { - let id = format!("top.overflow.{}", control.id().render_id()); - tree.push(overflow_control_button_node( - snapshot, - control, - id, - item_rect(index), - use_icons, - )); - } - } - } + push_overflow_popover( + &mut tree, + snapshot, + plan, + &spec, + overflow_anchor, + (width, height), + (btn_w, btn_h), + gap, + use_icons, + ); // --- Canvas/Session/Settings popovers ----------------------------------- if let Some(anchor) = overflow_anchor { @@ -401,6 +304,149 @@ pub(super) fn build_top_view_planned( tree } +#[allow(clippy::too_many_arguments)] +fn push_shape_popover( + tree: &mut WidgetTree, + snapshot: &ToolbarSnapshot, + plan: &TopStripPlan, + anchor: Option<(f64, f64, f64, f64)>, + bounds: (f64, f64), + button_size: (f64, f64), + gap: f64, + use_icons: bool, + is_simple: bool, +) { + let Some(anchor) = anchor.filter(|_| snapshot.shape_picker_open) else { + return; + }; + let rows = model::visible_shape_picker_rows(snapshot, is_simple); + let option_rows = shape_option_rows(snapshot); + let max_row_len = rows.iter().map(Vec::len).max().unwrap_or(0); + if max_row_len == 0 && option_rows.is_empty() { + return; + } + let (btn_w, btn_h) = button_size; + let pad = ToolbarLayoutSpec::TOP_POPOVER_PAD; + let option_h = ToolbarLayoutSpec::TOP_OPTION_ROW_H; + let grid_w = max_row_len as f64 * (btn_w + gap) - gap; + let content_w = grid_w.max(160.0) + pad * 2.0; + let grid_h = rows.len() as f64 * (btn_h + gap) - gap; + let content_h = pad * 2.0 + + grid_h.max(0.0) + + option_rows.len() as f64 * (option_h + gap) + + if rows.is_empty() { -gap } else { 0.0 }; + let placement = super::super::popover::place_popover(super::super::popover::PopoverSpec { + anchor: popover_anchor_below_contextual_rows(anchor, snapshot, plan), + content: (content_w, content_h), + bounds, + gap: ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP, + margin: 4.0, + }); + let (px, py, pw, _ph) = placement.rect; + tree.push(WidgetNode::decor( + "top.shapes.panel", + placement.rect, + WidgetKind::Popover { + caret_x: placement.caret_x, + caret_up: placement.side == super::super::popover::PopoverSide::Below, + }, + )); + let mut row_y = py + pad; + for row in rows { + let mut shape_x = px + pad; + for tool in row { + if !model::tool_visible(snapshot, tool) { + continue; + } + tree.push(tool_button_node( + snapshot, + tool, + format!( + "top.picker.{}", + model::toolbar_item_id_for_tool(tool).as_str() + ), + (shape_x, row_y, btn_w, btn_h), + use_icons, + )); + shape_x += btn_w + gap; + } + row_y += btn_h + gap; + } + for option_row in option_rows { + push_option_row( + tree, + snapshot, + option_row, + (px + pad, row_y, pw - pad * 2.0, option_h), + ); + row_y += option_h + gap; + } +} + +#[allow(clippy::too_many_arguments)] +fn push_overflow_popover( + tree: &mut WidgetTree, + snapshot: &ToolbarSnapshot, + plan: &TopStripPlan, + spec: &model::TopToolbarSpec, + anchor: Option<(f64, f64, f64, f64)>, + bounds: (f64, f64), + button_size: (f64, f64), + gap: f64, + use_icons: bool, +) { + let Some(anchor) = anchor.filter(|_| snapshot.top_overflow_open) else { + return; + }; + let dropped_count = spec.overflow().len(); + if dropped_count == 0 { + return; + } + let (btn_w, btn_h) = button_size; + let cols = dropped_count.min(5); + let rows = dropped_count.div_ceil(cols); + let pad = 8.0; + let content_w = cols as f64 * btn_w + (cols as f64 - 1.0) * gap + pad * 2.0; + let content_h = rows as f64 * btn_h + (rows as f64 - 1.0) * gap + pad * 2.0; + let placement = super::super::popover::place_popover(super::super::popover::PopoverSpec { + anchor: overflow_family_anchor(anchor, snapshot, plan), + content: (content_w, content_h), + bounds, + gap: OVERFLOW_ANCHOR_GAP, + margin: OVERFLOW_BOTTOM_MARGIN, + }); + let (px, py, _pw, _ph) = placement.rect; + tree.suppress_interactions_covered_by(placement.rect); + tree.push(WidgetNode::decor( + "top.overflow.panel", + placement.rect, + WidgetKind::Popover { + caret_x: placement.caret_x, + caret_up: placement.side == super::super::popover::PopoverSide::Below, + }, + )); + let item_rect = |index: usize| { + let col = index % cols; + let row = index / cols; + ( + px + pad + col as f64 * (btn_w + gap), + py + pad + row as f64 * (btn_h + gap), + btn_w, + btn_h, + ) + }; + for (index, control) in spec.overflow().iter().copied().enumerate() { + let id = format!("top.overflow.{}", control.id().render_id()); + tree.push(overflow_control_button_node( + snapshot, + control, + id, + item_rect(index), + use_icons, + )); + } +} + /// Minimized top strip: the whole tab is one restore button. It is not an /// item id on purpose — the way back must not be hideable. fn build_top_minimized_tab( From ecfee87ca4a256dfd0443edc57241b98076fe65d Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:46:21 +0200 Subject: [PATCH 36/53] refactor(board-picker): split row rendering passes --- src/ui/board_picker/rows.rs | 750 ++++++++++++++++++++---------------- 1 file changed, 419 insertions(+), 331 deletions(-) diff --git a/src/ui/board_picker/rows.rs b/src/ui/board_picker/rows.rs index e41222c6..8d9ef445 100644 --- a/src/ui/board_picker/rows.rs +++ b/src/ui/board_picker/rows.rs @@ -1,4 +1,5 @@ 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::theme::Rgba; @@ -12,386 +13,473 @@ use super::helpers::{ SWATCH_EDGE, board_slot_hint, draw_drag_handle, draw_open_icon, draw_pin_icon, }; -/// Outline + cross for the transparent-board swatch (no matching theme token; -/// kept from the pre-theme literal). const SWATCH_TRANSPARENT_OUTLINE: Rgba = (0.62, 0.68, 0.76, 0.85); pub(super) fn render_board_rows( ctx: &cairo::Context, input_state: &InputState, - layout: crate::input::state::BoardPickerLayout, + layout: BoardPickerLayout, board_count: usize, max_count: usize, ) { - let rows_top = layout.origin_y + layout.padding_y + layout.header_height; - let name_x = layout.origin_x + layout.padding_x + layout.swatch_size + layout.swatch_padding; - let list_right = layout.origin_x + layout.list_width; - let handle_x = if layout.handle_width > 0.0 { - Some(list_right - layout.padding_x - layout.handle_width) - } else { - None - }; - let open_icon_x = if layout.open_icon_size > 0.0 { - handle_x.map(|x| x - layout.open_icon_gap - layout.open_icon_size) - } else { - None - }; - let hint_right_edge = if let Some(open_icon_x) = open_icon_x { - open_icon_x - layout.handle_gap - } else if let Some(handle_x) = handle_x { - handle_x - layout.handle_gap - } else { - list_right - layout.padding_x - }; - let hint_x = if layout.hint_width > 0.0 { - Some(hint_right_edge - layout.hint_width) - } else { - None - }; - - let highlight_index = input_state.board_picker_active_index(); - let selected_index = input_state.board_picker_selected_index(); - let active_board_index = input_state.boards.active_index(); - let edit_state = input_state.board_picker_edit_state(); - let pinned_count = input_state.board_picker_pinned_count(); + BoardRowsRenderer::new(ctx, input_state, layout, board_count, max_count).render(); +} - let body_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: layout.body_font_size, - }; +struct BoardRowsRenderer<'a> { + ctx: &'a cairo::Context, + input: &'a InputState, + layout: BoardPickerLayout, + board_count: usize, + max_count: usize, + rows_top: f64, + name_x: f64, + list_right: f64, + handle_x: Option, + open_icon_x: Option, + hint_x: Option, + highlight_index: Option, + selected_index: Option, + active_board_index: usize, + edit_state: Option<(BoardPickerEditMode, usize, &'a str)>, + pinned_count: usize, + body_style: UiTextStyle<'static>, +} - // Draw "Pinned" section label. - if pinned_count > 0 && !input_state.board_picker_is_quick() { - let pinned_style = UiTextStyle { - size: layout.footer_font_size * 0.9, - ..body_style - }; - constants::set_color(ctx, constants::with_alpha(TEXT_HINT, 0.6)); - let pinned_label_y = rows_top - layout.footer_font_size * 0.4; - draw_text_baseline( +impl<'a> BoardRowsRenderer<'a> { + fn new( + ctx: &'a cairo::Context, + input: &'a InputState, + layout: BoardPickerLayout, + board_count: usize, + max_count: usize, + ) -> Self { + let rows_top = layout.origin_y + layout.padding_y + layout.header_height; + let name_x = + layout.origin_x + layout.padding_x + layout.swatch_size + layout.swatch_padding; + let list_right = layout.origin_x + layout.list_width; + let handle_x = (layout.handle_width > 0.0) + .then_some(list_right - layout.padding_x - layout.handle_width); + let open_icon_x = (layout.open_icon_size > 0.0) + .then(|| handle_x.map(|x| x - layout.open_icon_gap - layout.open_icon_size)) + .flatten(); + let hint_right_edge = open_icon_x + .map(|x| x - layout.handle_gap) + .or_else(|| handle_x.map(|x| x - layout.handle_gap)) + .unwrap_or(list_right - layout.padding_x); + let hint_x = (layout.hint_width > 0.0).then_some(hint_right_edge - layout.hint_width); + Self { ctx, - pinned_style, - "Pinned", - layout.origin_x + layout.padding_x, - pinned_label_y, - None, - ); + input, + layout, + board_count, + max_count, + rows_top, + name_x, + list_right, + handle_x, + open_icon_x, + hint_x, + highlight_index: input.board_picker_active_index(), + selected_index: input.board_picker_selected_index(), + active_board_index: input.boards.active_index(), + edit_state: input.board_picker_edit_state(), + pinned_count: input.board_picker_pinned_count(), + body_style: UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: layout.body_font_size, + }, + } } - // Draw pinned/unpinned section divider. - if pinned_count > 0 && pinned_count < board_count { - let divider_y = rows_top + layout.row_height * pinned_count as f64; - constants::set_color(ctx, DIVIDER_LIGHT); - ctx.set_line_width(1.0); - ctx.move_to(layout.origin_x + layout.padding_x, divider_y); - ctx.line_to(list_right - layout.padding_x, divider_y); - let _ = ctx.stroke(); + fn render(&self) { + self.render_section_header(); + for row in 0..self.layout.row_count { + self.render_row(row); + } } - for row in 0..layout.row_count { - let row_top = rows_top + layout.row_height * row as f64; - let row_center = row_top + layout.row_height * 0.5; - let is_highlighted = highlight_index == Some(row); - let is_selected = selected_index == Some(row); - let board_index = if row < board_count { - input_state - .board_picker_board_index_for_row(row) - .unwrap_or(row) - } else { - 0 - }; - let is_active_board = row < board_count && board_index == active_board_index; - - if is_highlighted { - constants::set_color(ctx, BG_SELECTION); - ctx.rectangle( - layout.origin_x + 6.0, - row_top, - layout.list_width - 12.0, - layout.row_height, + fn render_section_header(&self) { + if self.pinned_count > 0 && !self.input.board_picker_is_quick() { + let style = UiTextStyle { + size: self.layout.footer_font_size * 0.9, + ..self.body_style + }; + constants::set_color(self.ctx, constants::with_alpha(TEXT_HINT, 0.6)); + draw_text_baseline( + self.ctx, + style, + "Pinned", + self.layout.origin_x + self.layout.padding_x, + self.rows_top - self.layout.footer_font_size * 0.4, + None, ); - let _ = ctx.fill(); } - - if is_selected { - constants::set_color(ctx, BG_SELECTED_INDICATOR); - ctx.rectangle(layout.origin_x + 6.0, row_top, 3.0, layout.row_height); - let _ = ctx.fill(); + if self.pinned_count > 0 && self.pinned_count < self.board_count { + let y = self.rows_top + self.layout.row_height * self.pinned_count as f64; + constants::set_color(self.ctx, DIVIDER_LIGHT); + self.ctx.set_line_width(1.0); + self.ctx + .move_to(self.layout.origin_x + self.layout.padding_x, y); + self.ctx.line_to(self.list_right - self.layout.padding_x, y); + let _ = self.ctx.stroke(); } + } - let swatch_x = layout.origin_x + layout.padding_x; - let swatch_y = row_center - layout.swatch_size * 0.5; + fn render_row(&self, row: usize) { + let row_top = self.rows_top + self.layout.row_height * row as f64; + let row_center = row_top + self.layout.row_height * 0.5; + let highlighted = self.highlight_index == Some(row); + let selected = self.selected_index == Some(row); + self.render_row_selection(row_top, highlighted, selected); - let is_new_row = row >= board_count; - if is_new_row && row > 0 { - constants::set_color(ctx, DIVIDER_LIGHT); - ctx.set_line_width(0.5); - ctx.move_to(layout.origin_x + layout.padding_x, row_top); - ctx.line_to(list_right - layout.padding_x, row_top); - let _ = ctx.stroke(); + let is_new = row >= self.board_count; + if is_new && row > 0 { + self.render_new_row_divider(row_top); } - if is_new_row { - constants::set_color(ctx, TEXT_HINT); - draw_rounded_rect( - ctx, - swatch_x, - swatch_y, - layout.swatch_size, - layout.swatch_size, - 3.5, + let swatch_x = self.layout.origin_x + self.layout.padding_x; + let swatch_y = row_center - self.layout.swatch_size * 0.5; + if is_new { + self.render_new_row(swatch_x, swatch_y, row_center); + return; + } + let board_index = self + .input + .board_picker_board_index_for_row(row) + .unwrap_or(row); + let active = board_index == self.active_board_index; + self.render_board_swatch(board_index, swatch_x, swatch_y, active); + self.render_existing_row( + row, + board_index, + row_center, + swatch_x, + highlighted, + selected, + active, + ); + } + + fn render_row_selection(&self, row_top: f64, highlighted: bool, selected: bool) { + if highlighted { + constants::set_color(self.ctx, BG_SELECTION); + self.ctx.rectangle( + self.layout.origin_x + 6.0, + row_top, + self.layout.list_width - 12.0, + self.layout.row_height, + ); + let _ = self.ctx.fill(); + } + if selected { + constants::set_color(self.ctx, BG_SELECTED_INDICATOR); + self.ctx.rectangle( + self.layout.origin_x + 6.0, + row_top, + 3.0, + self.layout.row_height, ); - let _ = ctx.stroke(); - ctx.set_line_width(1.5); - let mid_x = swatch_x + layout.swatch_size * 0.5; - let mid_y = swatch_y + layout.swatch_size * 0.5; - ctx.move_to(mid_x - 4.0, mid_y); - ctx.line_to(mid_x + 4.0, mid_y); - ctx.move_to(mid_x, mid_y - 4.0); - ctx.line_to(mid_x, mid_y + 4.0); - let _ = ctx.stroke(); + let _ = self.ctx.fill(); + } + } + + fn render_new_row_divider(&self, row_top: f64) { + constants::set_color(self.ctx, DIVIDER_LIGHT); + self.ctx.set_line_width(0.5); + self.ctx + .move_to(self.layout.origin_x + self.layout.padding_x, row_top); + self.ctx + .line_to(self.list_right - self.layout.padding_x, row_top); + let _ = self.ctx.stroke(); + } + + fn render_new_row(&self, swatch_x: f64, swatch_y: f64, row_center: f64) { + constants::set_color(self.ctx, TEXT_HINT); + draw_rounded_rect( + self.ctx, + swatch_x, + swatch_y, + self.layout.swatch_size, + self.layout.swatch_size, + 3.5, + ); + let _ = self.ctx.stroke(); + self.ctx.set_line_width(1.5); + let mid_x = swatch_x + self.layout.swatch_size * 0.5; + let mid_y = swatch_y + self.layout.swatch_size * 0.5; + self.ctx.move_to(mid_x - 4.0, mid_y); + self.ctx.line_to(mid_x + 4.0, mid_y); + self.ctx.move_to(mid_x, mid_y - 4.0); + self.ctx.line_to(mid_x, mid_y + 4.0); + let _ = self.ctx.stroke(); + let label = if self.board_count >= self.max_count { + "New board (max reached)" } else { - let board = &input_state.boards.board_states()[board_index]; - match board.spec.background { - BoardBackground::Transparent => { - constants::set_color(ctx, SWATCH_TRANSPARENT_OUTLINE); - draw_rounded_rect( - ctx, - swatch_x, - swatch_y, - layout.swatch_size, - layout.swatch_size, - 3.5, - ); - let _ = ctx.stroke(); - ctx.move_to(swatch_x, swatch_y); - ctx.line_to(swatch_x + layout.swatch_size, swatch_y + layout.swatch_size); - ctx.move_to(swatch_x + layout.swatch_size, swatch_y); - ctx.line_to(swatch_x, swatch_y + layout.swatch_size); - let _ = ctx.stroke(); - } - BoardBackground::Solid(color) => { - ctx.set_source_rgba(color.r, color.g, color.b, 1.0); - draw_rounded_rect( - ctx, - swatch_x, - swatch_y, - layout.swatch_size, - layout.swatch_size, - 3.5, - ); - let _ = ctx.fill(); - constants::set_color(ctx, SWATCH_EDGE); - draw_rounded_rect( - ctx, - swatch_x, - swatch_y, - layout.swatch_size, - layout.swatch_size, - 3.5, - ); - let _ = ctx.stroke(); - } + "New board" + }; + constants::set_color(self.ctx, TEXT_HINT); + draw_text_baseline( + self.ctx, + self.body_style, + label, + self.name_x, + self.text_baseline(row_center), + None, + ); + } + + fn render_board_swatch(&self, board_index: usize, x: f64, y: f64, active: bool) { + let board = &self.input.boards.board_states()[board_index]; + match board.spec.background { + BoardBackground::Transparent => { + constants::set_color(self.ctx, SWATCH_TRANSPARENT_OUTLINE); + draw_rounded_rect( + self.ctx, + x, + y, + self.layout.swatch_size, + self.layout.swatch_size, + 3.5, + ); + let _ = self.ctx.stroke(); + self.ctx.move_to(x, y); + self.ctx + .line_to(x + self.layout.swatch_size, y + self.layout.swatch_size); + self.ctx.move_to(x + self.layout.swatch_size, y); + self.ctx.line_to(x, y + self.layout.swatch_size); + let _ = self.ctx.stroke(); } - if is_active_board { - constants::set_color(ctx, ACCENT_PRIMARY); + BoardBackground::Solid(color) => { + self.ctx.set_source_rgba(color.r, color.g, color.b, 1.0); + draw_rounded_rect( + self.ctx, + x, + y, + self.layout.swatch_size, + self.layout.swatch_size, + 3.5, + ); + let _ = self.ctx.fill(); + constants::set_color(self.ctx, SWATCH_EDGE); draw_rounded_rect( - ctx, - swatch_x - 2.0, - swatch_y - 2.0, - layout.swatch_size + 4.0, - layout.swatch_size + 4.0, - 4.0, + self.ctx, + x, + y, + self.layout.swatch_size, + self.layout.swatch_size, + 3.5, ); - let _ = ctx.stroke(); + let _ = self.ctx.stroke(); } } - - if is_new_row { - let label = if board_count >= max_count { - "New board (max reached)" - } else { - "New board" - }; - constants::set_color(ctx, TEXT_HINT); - draw_text_baseline( - ctx, - body_style, - label, - name_x, - row_center + layout.body_font_size * 0.35, - None, + if active { + constants::set_color(self.ctx, ACCENT_PRIMARY); + draw_rounded_rect( + self.ctx, + x - 2.0, + y - 2.0, + self.layout.swatch_size + 4.0, + self.layout.swatch_size + 4.0, + 4.0, ); - continue; + let _ = self.ctx.stroke(); } + } - let board = &input_state.boards.board_states()[board_index]; - let show_pin = board.spec.pinned || is_highlighted || is_selected; - if show_pin { - let pin_x = swatch_x - (layout.swatch_padding * 0.6); - let (color, filled) = if board.spec.pinned { - ( - Color { - r: ICON_PIN_ACTIVE.0, - g: ICON_PIN_ACTIVE.1, - b: ICON_PIN_ACTIVE.2, - a: ICON_PIN_ACTIVE.3, - }, - true, - ) - } else { - ( - Color { - r: ICON_PIN_INACTIVE.0, - g: ICON_PIN_INACTIVE.1, - b: ICON_PIN_INACTIVE.2, - a: ICON_PIN_INACTIVE.3, - }, - false, - ) - }; - draw_pin_icon(ctx, pin_x, row_center, layout.body_font_size, color, filled); + #[allow(clippy::too_many_arguments)] + fn render_existing_row( + &self, + row: usize, + board_index: usize, + row_center: f64, + swatch_x: f64, + highlighted: bool, + selected: bool, + active: bool, + ) { + self.render_pin(board_index, row_center, swatch_x, highlighted, selected); + let (name, hint_override) = self.edited_board_text(row, board_index); + self.render_board_name(board_index, row_center, active, &name); + self.render_edit_caret( + row, + BoardPickerEditMode::Name, + &name, + self.name_x, + row_center, + ); + self.render_board_hint(row, board_index, row_center, hint_override); + self.render_row_controls(row_center, highlighted, selected); + } + + fn render_pin( + &self, + board_index: usize, + row_center: f64, + swatch_x: f64, + highlighted: bool, + selected: bool, + ) { + let pinned = self.input.boards.board_states()[board_index].spec.pinned; + if !pinned && !highlighted && !selected { + return; } - let (mut name, mut hint_override) = (board.spec.name.clone(), None); - if let Some((mode, edit_index, buffer)) = edit_state + let rgba = if pinned { + ICON_PIN_ACTIVE + } else { + ICON_PIN_INACTIVE + }; + draw_pin_icon( + self.ctx, + swatch_x - self.layout.swatch_padding * 0.6, + row_center, + self.layout.body_font_size, + Color { + r: rgba.0, + g: rgba.1, + b: rgba.2, + a: rgba.3, + }, + pinned, + ); + } + + fn edited_board_text(&self, row: usize, board_index: usize) -> (String, Option) { + let mut name = self.input.boards.board_states()[board_index] + .spec + .name + .clone(); + let mut hint = None; + if let Some((mode, edit_index, buffer)) = self.edit_state && edit_index == row { match mode { - crate::input::state::BoardPickerEditMode::Name => { - name = buffer.to_string(); - } - crate::input::state::BoardPickerEditMode::Color => { - hint_override = Some(buffer.to_string()); - } + BoardPickerEditMode::Name => name = buffer.to_string(), + BoardPickerEditMode::Color => hint = Some(buffer.to_string()), } } + (name, hint) + } - let name_color = if is_active_board { - TEXT_ACTIVE - } else { - TEXT_SECONDARY + 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.ctx, + self.body_style, + name, + self.name_x, + self.text_baseline(row_center), + None, + ); + let page_count = self.input.boards.board_states()[board_index] + .pages + .page_count(); + if page_count <= 1 || self.layout.page_panel_enabled { + return; + } + let extents = self.text_extents(name); + constants::set_color(self.ctx, constants::with_alpha(TEXT_HINT, 0.85)); + draw_text_baseline( + self.ctx, + self.body_style, + &format!(" ({page_count} pages)"), + self.name_x + extents.width(), + self.text_baseline(row_center), + None, + ); + } + + fn render_board_hint( + &self, + row: usize, + board_index: usize, + row_center: f64, + hint_override: Option, + ) { + let Some(hint_x) = self.hint_x else { + return; + }; + let Some(hint) = hint_override.or_else(|| board_slot_hint(self.input, board_index)) else { + return; }; - constants::set_color(ctx, name_color); + constants::set_color(self.ctx, TEXT_HINT); draw_text_baseline( - ctx, - body_style, - &name, - name_x, - row_center + layout.body_font_size * 0.35, + self.ctx, + self.body_style, + &hint, + hint_x, + self.text_baseline(row_center), None, ); + self.render_edit_caret(row, BoardPickerEditMode::Color, &hint, hint_x, row_center); + } - // Show page count badge after board name (skip when page panel is visible). - let page_count = board.pages.page_count(); - if page_count > 1 && !layout.page_panel_enabled { - let name_extents = text_extents_for( - ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - layout.body_font_size, - &name, - ); - let page_label = format!(" ({} pages)", page_count); - constants::set_color(ctx, constants::with_alpha(TEXT_HINT, 0.85)); - draw_text_baseline( - ctx, - body_style, - &page_label, - name_x + name_extents.width(), - row_center + layout.body_font_size * 0.35, - None, - ); + fn render_edit_caret( + &self, + row: usize, + mode: BoardPickerEditMode, + text: &str, + x: f64, + row_center: f64, + ) { + if !self + .edit_state + .is_some_and(|(edit_mode, edit_index, _)| edit_mode == mode && edit_index == row) + { + return; } + let advance = self.text_extents(text).x_advance(); + constants::set_color(self.ctx, INPUT_CARET); + self.ctx.set_line_width(1.0); + self.ctx.move_to( + x + advance + 2.0, + row_center - self.layout.body_font_size * 0.5, + ); + self.ctx.line_to( + x + advance + 2.0, + row_center + self.layout.body_font_size * 0.5, + ); + let _ = self.ctx.stroke(); + self.ctx + .move_to(x, row_center + self.layout.body_font_size * 0.55); + self.ctx.line_to( + x + advance + 6.0, + row_center + self.layout.body_font_size * 0.55, + ); + let _ = self.ctx.stroke(); + } - if let Some((mode, edit_index, _buffer)) = edit_state - && edit_index == row - && mode == crate::input::state::BoardPickerEditMode::Name - { - let extents = text_extents_for( - ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - layout.body_font_size, - &name, - ); - let advance = extents.x_advance(); - let caret_x = name_x + advance + 2.0; - constants::set_color(ctx, INPUT_CARET); - ctx.set_line_width(1.0); - ctx.move_to(caret_x, row_center - layout.body_font_size * 0.5); - ctx.line_to(caret_x, row_center + layout.body_font_size * 0.5); - let _ = ctx.stroke(); - ctx.move_to(name_x, row_center + layout.body_font_size * 0.55); - ctx.line_to( - name_x + advance + 6.0, - row_center + layout.body_font_size * 0.55, + fn render_row_controls(&self, row_center: f64, highlighted: bool, selected: bool) { + if self.input.board_picker_is_quick() { + return; + } + if let Some(x) = self.open_icon_x { + let alpha = if highlighted || selected { 0.95 } else { 0.6 }; + draw_open_icon( + self.ctx, + x + self.layout.open_icon_size * 0.5, + row_center, + self.layout.open_icon_size, + alpha, ); - let _ = ctx.stroke(); } - - if let Some(hint_x) = hint_x { - let hint = hint_override.or_else(|| board_slot_hint(input_state, board_index)); - if let Some(hint) = hint { - constants::set_color(ctx, TEXT_HINT); - draw_text_baseline( - ctx, - body_style, - &hint, - hint_x, - row_center + layout.body_font_size * 0.35, - None, - ); - - if let Some((mode, edit_index, _)) = edit_state - && edit_index == row - && mode == crate::input::state::BoardPickerEditMode::Color - { - let extents = text_extents_for( - ctx, - "Sans", - cairo::FontSlant::Normal, - cairo::FontWeight::Normal, - layout.body_font_size, - &hint, - ); - let advance = extents.x_advance(); - let caret_x = hint_x + advance + 2.0; - constants::set_color(ctx, INPUT_CARET); - ctx.set_line_width(1.0); - ctx.move_to(caret_x, row_center - layout.body_font_size * 0.5); - ctx.line_to(caret_x, row_center + layout.body_font_size * 0.5); - let _ = ctx.stroke(); - ctx.move_to(hint_x, row_center + layout.body_font_size * 0.55); - ctx.line_to( - hint_x + advance + 6.0, - row_center + layout.body_font_size * 0.55, - ); - let _ = ctx.stroke(); - } - } + if let Some(x) = self.handle_x { + draw_drag_handle(self.ctx, x, row_center, self.layout.handle_width); } + } - if let Some(open_icon_x) = open_icon_x - && !is_new_row - && !input_state.board_picker_is_quick() - { - let alpha = if is_highlighted || is_selected { - 0.95 - } else { - 0.6 - }; - let center_x = open_icon_x + layout.open_icon_size * 0.5; - draw_open_icon(ctx, center_x, row_center, layout.open_icon_size, alpha); - } + fn text_extents(&self, text: &str) -> cairo::TextExtents { + text_extents_for( + self.ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + self.layout.body_font_size, + text, + ) + } - if let Some(handle_x) = handle_x - && !is_new_row - && !input_state.board_picker_is_quick() - { - draw_drag_handle(ctx, handle_x, row_center, layout.handle_width); - } + fn text_baseline(&self, row_center: f64) -> f64 { + row_center + self.layout.body_font_size * 0.35 } } From cd21836e99a3f05d080ff189816bf7813bc917f1 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:49:39 +0200 Subject: [PATCH 37/53] refactor(render): isolate frame timing and damage collection --- src/backend/wayland/state/render/mod.rs | 215 ++++++++++++++---------- 1 file changed, 127 insertions(+), 88 deletions(-) diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index b4becc32..b9ade111 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -19,6 +19,47 @@ pub(in crate::backend::wayland) enum RenderOutcome { BuffersInFlight, } +#[derive(Clone, Copy)] +struct RenderAnimationState { + highlight: bool, + preset_feedback: bool, + ui_toast: bool, + blocked_feedback: bool, + text_edit_entry: bool, + input_hud: bool, + ocr_scan: bool, +} + +impl RenderAnimationState { + fn any_active(self) -> bool { + [ + self.highlight, + self.preset_feedback, + self.ui_toast, + self.blocked_feedback, + self.text_edit_entry, + self.input_hud, + self.ocr_scan, + ] + .into_iter() + .any(|active| active) + } +} + +fn record_render_stage( + enabled: bool, + breakdown: Option<&mut PerfRenderBreakdown>, + record: impl FnOnce(&mut PerfRenderBreakdown, Duration), + body: impl FnOnce() -> T, +) -> T { + let started = enabled.then(Instant::now); + let result = body(); + if let (Some(breakdown), Some(started)) = (breakdown, started) { + record(breakdown, Instant::now().saturating_duration_since(started)); + } + result +} + impl WaylandState { pub(in crate::backend::wayland) fn render( &mut self, @@ -48,17 +89,14 @@ impl WaylandState { }); macro_rules! record_stage { ($field:ident, $body:expr) => {{ - let stage_start = perf_enabled.then(Instant::now); - let result = $body; - if let (Some(breakdown), Some(stage_start)) = - (render_breakdown.as_mut(), stage_start) - { - breakdown.stages.$field = breakdown - .stages - .$field - .saturating_add(Instant::now().saturating_duration_since(stage_start)); - } - result + record_render_stage( + perf_enabled, + render_breakdown.as_mut(), + |breakdown, duration| { + breakdown.stages.$field = breakdown.stages.$field.saturating_add(duration); + }, + || $body, + ) }}; } @@ -74,8 +112,8 @@ impl WaylandState { phys_width as i32, phys_height as i32, (phys_width * 4) as i32, - )? - }); + ) + })?; let Some(acquired) = acquired else { // Every slot is still owned by the compositor. Keep the redraw // pending and retry on the next pass rather than painting over a @@ -97,32 +135,9 @@ impl WaylandState { self.surface.update_pool_size(pool_size); let now = Instant::now(); - let ( - highlight_active, - preset_feedback_active, - ui_toast_active, - blocked_feedback_active, - text_edit_entry_active, - input_hud_animating, - ocr_scan_animating, - ) = record_stage!(advance_animations, { - ( - self.input_state.advance_click_highlights(now), - self.input_state.advance_preset_feedback(now), - self.input_state.advance_ui_toast(now), - self.input_state.advance_blocked_feedback(now), - self.input_state.advance_text_edit_entry_feedback(now), - self.input_state.advance_input_hud(now), - self.input_state.advance_ocr_scan(now), - ) - }); - let ui_animation_active = highlight_active - || preset_feedback_active - || ui_toast_active - || blocked_feedback_active - || text_edit_entry_active - || input_hud_animating - || ocr_scan_animating; + let animation_state = + record_stage!(advance_animations, { self.advance_render_animations(now) }); + let ui_animation_active = animation_state.any_active(); self.update_ui_animation_tick(now, ui_animation_active); let keep_rendering = ui_animation_active && self.ui_animation_interval.is_none(); @@ -132,51 +147,15 @@ impl WaylandState { // damage reported for this slot. let logical_width = width.min(i32::MAX as u32) as i32; let logical_height = height.min(i32::MAX as u32) as i32; - let mut damage_diagnostics = PerfDamageDiagnostics::default(); - record_stage!(dirty_collect, { - let input_damage_report = self.input_state.take_dirty_region_report(); - let input_damage = input_damage_report.regions; - let input_full_reason = input_full_damage_reason(input_damage_report.full_reason); - damage_diagnostics.input_regions = input_damage.len(); - damage_diagnostics.input_full_reason = input_full_reason; - damage_diagnostics.input_covers_surface = - damage_covers_logical_surface(&input_damage, logical_width, logical_height); - let force_full_damage_reason = self.render_force_full_damage_reason(); - // Transient UI effects (toasts, feedback flashes) emit targeted damage - // instead of forcing full-surface redraws. Collect on every frame so the - // previous-bounds tracking stays in sync even when full damage is forced - // for other reasons. - let tool_preview_active = render_ui && self.mouse_tool_preview_eligible(); - let zoom_chip_active = render_ui && self.zoom_chip_visible(); - let command_palette_active = render_ui && self.input_state.command_palette_is_engaged(); - let color_picker_active = render_ui && self.input_state.is_color_picker_popup_open(); - let input_hud_active = render_ui && self.input_state.input_hud_visible(); - let shape_measure_badge_active = render_ui && !self.capture_picker_chrome_suppressed(); - let ui_effect_damage = self.collect_ui_effect_damage( - ui_toast_active, - preset_feedback_active, - blocked_feedback_active, - text_edit_entry_active, - render_ui && self.input_state.show_status_bar, - zoom_chip_active, - input_hud_active, - command_palette_active, - color_picker_active, - tool_preview_active, - shape_measure_badge_active, + let mut damage_diagnostics = record_stage!(dirty_collect, { + self.collect_frame_damage( + render_ui, + animation_state, width, height, - ); - if let Some(reason) = force_full_damage_reason { - // Zoom and board pan use a world transform; full damage avoids - // mismatched coordinate spaces. - self.buffer_damage.mark_all_full(reason); - } else if let Some(reason) = input_full_reason { - self.buffer_damage.mark_all_full(reason); - } else { - self.buffer_damage.add_regions(input_damage); - self.buffer_damage.add_regions(ui_effect_damage); - } + logical_width, + logical_height, + ) }); // Take damage for this buffer slot (identified by canvas memory address). @@ -255,13 +234,14 @@ impl WaylandState { phys_height as i32, (phys_width * 4) as i32, ) - .context("Failed to create Cairo surface")? + .context("Failed to create Cairo surface") }; - - let ctx = - cairo::Context::new(&cairo_surface).context("Failed to create Cairo context")?; - (cairo_surface, ctx) - }); + cairo_surface.and_then(|cairo_surface| { + let ctx = cairo::Context::new(&cairo_surface) + .context("Failed to create Cairo context")?; + Ok((cairo_surface, ctx)) + }) + })?; record_stage!(clear_clip, { // Optimization: Clip drawing to the damage regions. @@ -480,6 +460,65 @@ impl WaylandState { Ok(RenderOutcome::Committed { keep_rendering }) } + fn advance_render_animations(&mut self, now: Instant) -> RenderAnimationState { + RenderAnimationState { + highlight: self.input_state.advance_click_highlights(now), + preset_feedback: self.input_state.advance_preset_feedback(now), + ui_toast: self.input_state.advance_ui_toast(now), + blocked_feedback: self.input_state.advance_blocked_feedback(now), + text_edit_entry: self.input_state.advance_text_edit_entry_feedback(now), + input_hud: self.input_state.advance_input_hud(now), + ocr_scan: self.input_state.advance_ocr_scan(now), + } + } + + #[allow(clippy::too_many_arguments)] + fn collect_frame_damage( + &mut self, + render_ui: bool, + animation: RenderAnimationState, + width: u32, + height: u32, + logical_width: i32, + logical_height: i32, + ) -> PerfDamageDiagnostics { + let input_damage_report = self.input_state.take_dirty_region_report(); + let input_damage = input_damage_report.regions; + let input_full_reason = input_full_damage_reason(input_damage_report.full_reason); + let diagnostics = PerfDamageDiagnostics { + input_regions: input_damage.len(), + input_full_reason, + input_covers_surface: damage_covers_logical_surface( + &input_damage, + logical_width, + logical_height, + ), + ..PerfDamageDiagnostics::default() + }; + let ui_effect_damage = self.collect_ui_effect_damage( + animation.ui_toast, + animation.preset_feedback, + animation.blocked_feedback, + animation.text_edit_entry, + render_ui && self.input_state.show_status_bar, + render_ui && self.zoom_chip_visible(), + render_ui && self.input_state.input_hud_visible(), + render_ui && self.input_state.command_palette_is_engaged(), + render_ui && self.input_state.is_color_picker_popup_open(), + render_ui && self.mouse_tool_preview_eligible(), + render_ui && !self.capture_picker_chrome_suppressed(), + width, + height, + ); + if let Some(reason) = self.render_force_full_damage_reason().or(input_full_reason) { + self.buffer_damage.mark_all_full(reason); + } else { + self.buffer_damage.add_regions(input_damage); + self.buffer_damage.add_regions(ui_effect_damage); + } + diagnostics + } + fn render_force_full_damage_reason(&self) -> Option { if self.zoom.active { Some(FullDamageReason::Zoom) From f82605037861a7cc09836f7e83834008db6f7e6a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:52:27 +0200 Subject: [PATCH 38/53] refactor(test): split board pin recovery setup --- .../runtime_ui_state/tests/board_pins.rs | 138 ++++++++++-------- 1 file changed, 76 insertions(+), 62 deletions(-) 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 75696f29..c75e9d55 100644 --- a/src/backend/wayland/runtime_ui_state/tests/board_pins.rs +++ b/src/backend/wayland/runtime_ui_state/tests/board_pins.rs @@ -1,5 +1,79 @@ use super::*; +fn force_board_pin_test_persistence_failure( + runtime: &mut ToolbarRuntimeState, +) -> PersistenceIncidentId { + let target = InteractionSeedTarget::TopPinned; + let permit = runtime + .controller + .begin_mutation(RuntimeUiMutationScope::one(target.clone())) + .unwrap(); + assert!(matches!( + runtime.controller.commit( + permit, + RuntimeUiMutationValues::one(target, InteractionSeedValue::Bool(false)).unwrap(), + ), + CommitResult::Accepted { .. } + )); + let failed = runtime + .controller + .take_source_mutation() + .expect("replacement to fail"); + let active = RuntimeStateSourceObservation::missing(failed.expected_source.clone()); + match runtime + .controller + .submit_source_mutation(SourceMutationResult::Failed { + id: failed.id, + error: RuntimeStateIoError::new("temporary board-pin test failure"), + active: Some(active), + recovery_artifacts: Vec::new(), + path_effect: RuntimeStateFailurePathEffect::Known( + RuntimeStateObservedPathEffect::Untouched, + ), + }) { + SubmitSourceMutationResult::PersistenceUnhealthy { incident, .. } => incident, + result => panic!("unexpected persistence result: {result:?}"), + } +} + +fn recover_board_pin_test_persistence( + runtime: &mut ToolbarRuntimeState, + incident: PersistenceIncidentId, +) -> bool { + let recovery = match runtime + .controller + .checkout_persistence_recovery_handle(incident) + { + CheckoutPersistenceRecoveryHandleResult::CheckedOut(handle) => handle, + result => panic!("recovery checkout failed: {result:?}"), + }; + let client = match runtime + .controller + .begin_persistence_recovery(PersistenceRecoveryRequest { + recovery, + action: PersistenceRecoveryAction::RetryPending, + }) { + BeginPersistenceRecoveryResult::Started { client, .. } => client, + result => panic!("recovery start failed: {result:?}"), + }; + runtime.dispatch_writer_command(); + let mut rebuild_live = false; + for _ in 0..400 { + let drain = runtime.drain_writer_completions(); + rebuild_live |= drain.rebuild_live; + if runtime.controller.active_barrier().is_none() { + break; + } + thread::sleep(Duration::from_millis(5)); + } + assert!(runtime.controller.active_barrier().is_none()); + assert!(matches!( + client.completion.try_recv(), + Some(PersistenceRecoveryResult::Recovered { .. }) + )); + rebuild_live +} + #[test] fn board_pin_is_runtime_owned_and_survives_restart_without_touching_config() { const AUTHORED: &[u8] = b"# authored config bytes stay exact\n"; @@ -151,37 +225,7 @@ fn restored_board_pin_is_replayed_after_same_authority_recovery() { let mut input = input_from_config(&config); let mut runtime = test_runtime(&config, &runtime_path); - let target = InteractionSeedTarget::TopPinned; - let permit = runtime - .controller - .begin_mutation(RuntimeUiMutationScope::one(target.clone())) - .unwrap(); - assert!(matches!( - runtime.controller.commit( - permit, - RuntimeUiMutationValues::one(target, InteractionSeedValue::Bool(false)).unwrap(), - ), - CommitResult::Accepted { .. } - )); - let failed = runtime - .controller - .take_source_mutation() - .expect("replacement to fail"); - let active = RuntimeStateSourceObservation::missing(failed.expected_source.clone()); - let incident = match runtime - .controller - .submit_source_mutation(SourceMutationResult::Failed { - id: failed.id, - error: RuntimeStateIoError::new("temporary board-pin test failure"), - active: Some(active), - recovery_artifacts: Vec::new(), - path_effect: RuntimeStateFailurePathEffect::Known( - RuntimeStateObservedPathEffect::Untouched, - ), - }) { - SubmitSourceMutationResult::PersistenceUnhealthy { incident, .. } => incident, - result => panic!("unexpected persistence result: {result:?}"), - }; + let incident = force_board_pin_test_persistence_failure(&mut runtime); assert!(input.create_board()); let board_id = input.board_id().to_string(); @@ -196,37 +240,7 @@ fn restored_board_pin_is_replayed_after_same_authority_recovery() { assert!(board_pinned(&input, &board_id)); assert_eq!(runtime.deferred_board_pin_restores.len(), 1); - let recovery = match runtime - .controller - .checkout_persistence_recovery_handle(incident) - { - CheckoutPersistenceRecoveryHandleResult::CheckedOut(handle) => handle, - result => panic!("recovery checkout failed: {result:?}"), - }; - let client = match runtime - .controller - .begin_persistence_recovery(PersistenceRecoveryRequest { - recovery, - action: PersistenceRecoveryAction::RetryPending, - }) { - BeginPersistenceRecoveryResult::Started { client, .. } => client, - result => panic!("recovery start failed: {result:?}"), - }; - runtime.dispatch_writer_command(); - let mut rebuild_live = false; - for _ in 0..400 { - let drain = runtime.drain_writer_completions(); - rebuild_live |= drain.rebuild_live; - if runtime.controller.active_barrier().is_none() { - break; - } - thread::sleep(Duration::from_millis(5)); - } - assert!(runtime.controller.active_barrier().is_none()); - assert!(matches!( - client.completion.try_recv(), - Some(PersistenceRecoveryResult::Recovered { .. }) - )); + 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) }); assert!(!board_pinned(&input, &board_id)); From f2fe1c3da1fbc3bfa48795d84d7ecf0545f713f0 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:53:43 +0200 Subject: [PATCH 39/53] refactor(test): split config clamp assertions --- src/config/tests/validate.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index bf6ad5f7..b34612bd 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -566,6 +566,15 @@ fn validate_and_clamp_clamps_input_hud_fields() { #[test] fn validate_and_clamp_clamps_ui_and_session_fields() { let mut config = Config::default(); + configure_invalid_ui_and_session_fields(&mut config); + + config.validate_and_clamp(); + + assert_ui_fields_clamped(&config); + assert_session_fields_clamped(&config); +} + +fn configure_invalid_ui_and_session_fields(config: &mut Config) { config.drawing.marker_opacity = 2.0; config.drawing.hit_test_tolerance = 0.5; config.drawing.hit_test_linear_threshold = 0; @@ -586,9 +595,9 @@ fn validate_and_clamp_clamps_ui_and_session_fields() { config.session.storage = SessionStorageMode::Custom; config.session.custom_directory = Some(" ".to_string()); config.keybindings.core.exit = vec!["Ctrl+Shift".to_string()]; +} - config.validate_and_clamp(); - +fn assert_ui_fields_clamped(config: &Config) { assert_eq!(config.drawing.marker_opacity, 0.9); assert_eq!(config.drawing.hit_test_tolerance, 1.0); assert_eq!(config.drawing.hit_test_linear_threshold, 400); @@ -614,6 +623,9 @@ fn validate_and_clamp_clamps_ui_and_session_fields() { .all(|c| (0.0..=1.0).contains(c)) ); assert_eq!(config.ui.toolbar.scale, 3.0); +} + +fn assert_session_fields_clamped(config: &Config) { assert_eq!(config.session.max_shapes_per_frame, 1); assert_eq!(config.session.max_file_size_mb, 1024); assert_eq!(config.session.auto_compress_threshold_kb, 1); From 47777f5550ebdbd8de9bbce4d7c94f8f29a4f080 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:55:17 +0200 Subject: [PATCH 40/53] refactor(test): split session artifact move checks --- src/session/artifacts/move_file/tests.rs | 95 +++++++++++++++--------- 1 file changed, 59 insertions(+), 36 deletions(-) diff --git a/src/session/artifacts/move_file/tests.rs b/src/session/artifacts/move_file/tests.rs index 74319b89..67e05d69 100644 --- a/src/session/artifacts/move_file/tests.rs +++ b/src/session/artifacts/move_file/tests.rs @@ -1,5 +1,52 @@ use super::*; -use crate::session::named_session_artifact_paths; +use crate::session::{SessionArtifactPaths, named_session_artifact_paths}; + +fn assert_moved_target_artifacts( + artifacts: &SessionArtifactPaths, + rotated_recovery: &Path, + preserved: &Path, + preserved_moved: &Path, +) { + assert_eq!(std::fs::read(&artifacts.primary).unwrap(), b"primary"); + assert_eq!(std::fs::read(&artifacts.backup).unwrap(), b"backup"); + assert_eq!(std::fs::read(&artifacts.recovery).unwrap(), b"recovery"); + assert_eq!(std::fs::read(&artifacts.clear_marker).unwrap(), b"cleared"); + assert_eq!(std::fs::read(rotated_recovery).unwrap(), b"rotated"); + assert_eq!(std::fs::read(preserved).unwrap(), b"newer"); + assert_eq!(std::fs::read(preserved_moved).unwrap(), b"newer moved"); +} + +fn assert_moved_sources_removed( + artifacts: &SessionArtifactPaths, + rotated_recovery: &Path, + preserved: &Path, + preserved_moved: &Path, +) { + assert!(!artifacts.primary.exists()); + assert!(!artifacts.backup.exists()); + assert!(!artifacts.recovery.exists()); + assert!(!artifacts.clear_marker.exists()); + assert!(!rotated_recovery.exists()); + assert!(!preserved.exists()); + assert!(!preserved_moved.exists()); +} + +fn assert_source_lock_was_not_moved( + source_artifacts: &SessionArtifactPaths, + target_artifacts: &SessionArtifactPaths, +) { + assert_eq!( + std::fs::read(&source_artifacts.lock).unwrap(), + b"source lock", + "source lock must not be moved as session data" + ); + if target_artifacts.lock.exists() { + assert_ne!( + std::fs::read(&target_artifacts.lock).unwrap(), + b"source lock" + ); + } +} #[test] fn move_named_session_non_lock_artifacts_moves_primary_and_sidecars_without_lock() { @@ -34,43 +81,19 @@ fn move_named_session_non_lock_artifacts_moves_primary_and_sidecars_without_lock assert_eq!(outcome.target, target); assert_eq!(outcome.moved_artifacts, 7); assert_eq!(outcome.moved_artifact_paths.len(), 7); - assert_eq!( - std::fs::read(&target_artifacts.primary).unwrap(), - b"primary" + assert_moved_target_artifacts( + &target_artifacts, + &target_rotated_recovery, + &target_preserved, + &target_preserved_moved, ); - assert_eq!(std::fs::read(&target_artifacts.backup).unwrap(), b"backup"); - assert_eq!( - std::fs::read(&target_artifacts.recovery).unwrap(), - b"recovery" - ); - assert_eq!( - std::fs::read(&target_artifacts.clear_marker).unwrap(), - b"cleared" - ); - assert_eq!(std::fs::read(&target_rotated_recovery).unwrap(), b"rotated"); - assert_eq!(std::fs::read(&target_preserved).unwrap(), b"newer"); - assert_eq!( - std::fs::read(&target_preserved_moved).unwrap(), - b"newer moved" - ); - assert!(!source_artifacts.primary.exists()); - assert!(!source_artifacts.backup.exists()); - assert!(!source_artifacts.recovery.exists()); - assert!(!source_artifacts.clear_marker.exists()); - assert!(!rotated_recovery.exists()); - assert!(!preserved.exists()); - assert!(!preserved_moved.exists()); - assert_eq!( - std::fs::read(&source_artifacts.lock).unwrap(), - b"source lock", - "source lock must not be moved as session data" + assert_moved_sources_removed( + &source_artifacts, + &rotated_recovery, + &preserved, + &preserved_moved, ); - if target_artifacts.lock.exists() { - assert_ne!( - std::fs::read(&target_artifacts.lock).unwrap(), - b"source lock" - ); - } + assert_source_lock_was_not_moved(&source_artifacts, &target_artifacts); } #[test] From e77cb14de4c52b7f26fd459111a3ad3f41a2fe2e Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:57:21 +0200 Subject: [PATCH 41/53] refactor(test): split compressed session checks --- src/session/tests/limits.rs | 72 ++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/src/session/tests/limits.rs b/src/session/tests/limits.rs index dde4f7d2..73e3eaba 100644 --- a/src/session/tests/limits.rs +++ b/src/session/tests/limits.rs @@ -2,7 +2,7 @@ use super::super::*; use super::helpers::dummy_input_state; use crate::draw::frame::{ShapeSnapshot, UndoAction}; use crate::draw::{Color, EmbeddedImage, FontDescriptor, Shape}; -use crate::input::{BOARD_ID_WHITEBOARD, EraserMode}; +use crate::input::{BOARD_ID_WHITEBOARD, EraserMode, InputState}; use std::fs; use std::path::Path; @@ -237,6 +237,42 @@ fn pseudo_random_bytes(len: usize) -> Vec { .collect() } +fn populate_compressed_limit_pages(input: &mut InputState, page_count: usize, image_bytes: usize) { + for page_index in 0..page_count { + if page_index > 0 { + input.page_new(); + } + add_image_and_annotations(input.boards.active_frame_mut(), page_index, image_bytes); + } +} + +fn assert_restored_limit_page(page: &crate::draw::Frame, page_index: usize, image_bytes: usize) { + assert_eq!(page.shapes.len(), 3, "page {page_index} shape count"); + assert_eq!(page.undo_stack_len(), 1, "page {page_index} undo depth"); + match &page.shapes[0].shape { + Shape::Image { data, .. } => { + assert_eq!(data.bytes.len(), image_bytes); + assert_eq!(data.width, 640); + assert_eq!(data.height, 360); + } + other => panic!("expected image on page {page_index}, got {other:?}"), + } + match &page.shapes[1].shape { + Shape::Freehand { points, .. } => { + assert_eq!(points.len(), 3); + assert_eq!( + points[0], + (i32::try_from(page_index).expect("page index fits i32"), 20) + ); + } + other => panic!("expected annotation stroke on page {page_index}, got {other:?}"), + } + match &page.shapes[2].shape { + Shape::Text { text, .. } => assert_eq!(text, &format!("note-{page_index}")), + other => panic!("expected text annotation on page {page_index}, got {other:?}"), + } +} + #[test] fn save_snapshot_allows_compressed_payload_that_fits_limit() { const PAGE_COUNT: usize = 12; @@ -254,12 +290,7 @@ fn save_snapshot_allows_compressed_payload_that_fits_limit() { let mut input = dummy_input_state(); input.switch_board(BOARD_ID_WHITEBOARD); - for page_index in 0..PAGE_COUNT { - if page_index > 0 { - input.page_new(); - } - add_image_and_annotations(input.boards.active_frame_mut(), page_index, IMAGE_BYTES); - } + populate_compressed_limit_pages(&mut input, PAGE_COUNT, IMAGE_BYTES); assert!(input.switch_to_page(ACTIVE_PAGE)); let snapshot = snapshot_from_input(&input, &options).expect("snapshot present"); @@ -297,32 +328,7 @@ fn save_snapshot_allows_compressed_payload_that_fits_limit() { assert_eq!(pages.page_count(), PAGE_COUNT); assert_eq!(pages.active_index(), ACTIVE_PAGE); for (page_index, page) in pages.pages().iter().enumerate() { - assert_eq!(page.shapes.len(), 3, "page {page_index} shape count"); - assert_eq!(page.undo_stack_len(), 1, "page {page_index} undo depth"); - match &page.shapes[0].shape { - Shape::Image { data, .. } => { - assert_eq!(data.bytes.len(), IMAGE_BYTES); - assert_eq!(data.width, 640); - assert_eq!(data.height, 360); - } - other => panic!("expected image on page {page_index}, got {other:?}"), - } - match &page.shapes[1].shape { - Shape::Freehand { points, .. } => { - assert_eq!(points.len(), 3); - assert_eq!( - points[0], - (i32::try_from(page_index).expect("page index fits i32"), 20) - ); - } - other => panic!("expected annotation stroke on page {page_index}, got {other:?}"), - } - match &page.shapes[2].shape { - Shape::Text { text, .. } => { - assert_eq!(text, &format!("note-{page_index}")); - } - other => panic!("expected text annotation on page {page_index}, got {other:?}"), - } + assert_restored_limit_page(page, page_index, IMAGE_BYTES); } } From 9683017b756bd4e64b38a571efcb7a010b26afbf Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:59:11 +0200 Subject: [PATCH 42/53] refactor(test): split canvas popover checks --- src/ui/toolbar/apply/layout.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/ui/toolbar/apply/layout.rs b/src/ui/toolbar/apply/layout.rs index 267d461c..f0ad0e60 100644 --- a/src/ui/toolbar/apply/layout.rs +++ b/src/ui/toolbar/apply/layout.rs @@ -677,6 +677,13 @@ mod tests { fn canvas_popover_is_mutually_exclusive_with_the_other_top_menus() { let mut state = make_test_input_state(); + assert_canvas_open_closes_top_menus(&mut state); + assert_canvas_is_exclusive_with_session_and_settings(&mut state); + assert_overflow_and_shapes_close_canvas(&mut state); + assert_minimize_and_close_handle_canvas(&mut state); + } + + fn assert_canvas_open_closes_top_menus(state: &mut crate::input::InputState) { // Opening Canvas closes the overflow, the shapes picker, and resets // the shared internal scroll. state.apply_toolbar_event(ToolbarEvent::ToggleTopOverflow(true)); @@ -690,7 +697,9 @@ mod tests { assert!(!state.toolbar_top_overflow_open); assert!(!state.toolbar_shapes_expanded); assert_eq!(state.toolbar_top_popover_scroll, 0.0); + } + fn assert_canvas_is_exclusive_with_session_and_settings(state: &mut crate::input::InputState) { // Opening Session or Settings closes Canvas, and Canvas closes them. assert!(state.apply_toolbar_event(ToolbarEvent::ToggleSessionPopover(true))); assert!(!state.toolbar_canvas_popover_open); @@ -700,14 +709,18 @@ mod tests { assert!(!state.toolbar_canvas_popover_open); assert!(state.apply_toolbar_event(ToolbarEvent::ToggleCanvasPopover(true))); assert!(!state.toolbar_settings_popover_open); + } + fn assert_overflow_and_shapes_close_canvas(state: &mut crate::input::InputState) { // Re-opening the overflow (or the shapes picker) closes Canvas. assert!(state.apply_toolbar_event(ToolbarEvent::ToggleTopOverflow(true))); assert!(!state.toolbar_canvas_popover_open); state.apply_toolbar_event(ToolbarEvent::ToggleCanvasPopover(true)); assert!(state.apply_toolbar_event(ToolbarEvent::ToggleShapePicker(true))); assert!(!state.toolbar_canvas_popover_open); + } + fn assert_minimize_and_close_handle_canvas(state: &mut crate::input::InputState) { // Minimizing the strip closes an open Canvas popover. state.apply_toolbar_event(ToolbarEvent::ToggleCanvasPopover(true)); state.apply_toolbar_event(ToolbarEvent::SetTopMinimized(true)); From 5e8ee22260fe0177e327c764a31a90e6467b746b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:01:36 +0200 Subject: [PATCH 43/53] refactor(test): split overflow menu structure checks --- .../toolbar/model/top_spec/tests/structure.rs | 115 ++++++++++-------- 1 file changed, 62 insertions(+), 53 deletions(-) diff --git a/src/ui/toolbar/model/top_spec/tests/structure.rs b/src/ui/toolbar/model/top_spec/tests/structure.rs index e53c817c..9775b7b9 100644 --- a/src/ui/toolbar/model/top_spec/tests/structure.rs +++ b/src/ui/toolbar/model/top_spec/tests/structure.rs @@ -271,101 +271,110 @@ fn narrow_spec_moves_dropped_controls_to_one_ordered_overflow() { #[test] fn overflow_hosts_canvas_session_and_settings_entries_in_every_layout() { - // The three popover entries are unconditional and not hideable items; - // they are the only surface hosting Canvas/Session/Settings. for layout_mode in [ToolbarLayoutMode::Regular, ToolbarLayoutMode::Simple] { - let mut snapshot = snapshot(); - snapshot.layout_mode = layout_mode; - let spec = TopToolbarSpec::build(&snapshot, &TopStripPlan::unconstrained()); - let tail: Vec<_> = spec - .overflow() - .iter() - .rev() - .take(3) - .rev() - .copied() - .collect(); - assert_eq!( - tail, - [ - TopToolbarControl::CanvasMenu, - TopToolbarControl::SessionMenu, - TopToolbarControl::SettingsMenu, - ], - "{layout_mode:?}: the menu entries close the overflow, Canvas first" - ); - assert!( - !spec.overflow().is_empty(), - "the overflow toggle always has content" - ); - assert!( - spec.strip() - .contains(&TopToolbarNode::Control(TopToolbarControl::Overflow)) - ); + assert_overflow_menu_entries(layout_mode); } let mut snapshot = snapshot(); + assert_overflow_menu_metadata(&snapshot); + assert_overflow_menu_toggle_behavior(&mut snapshot); + assert_collapsed_strips_have_no_overflow(); +} + +fn assert_overflow_menu_entries(layout_mode: ToolbarLayoutMode) { + let mut snapshot = snapshot(); + snapshot.layout_mode = layout_mode; + let spec = TopToolbarSpec::build(&snapshot, &TopStripPlan::unconstrained()); + let tail: Vec<_> = spec + .overflow() + .iter() + .rev() + .take(3) + .rev() + .copied() + .collect(); + assert_eq!( + tail, + [ + TopToolbarControl::CanvasMenu, + TopToolbarControl::SessionMenu, + TopToolbarControl::SettingsMenu, + ], + "{layout_mode:?}: the menu entries close the overflow, Canvas first" + ); + assert!(!spec.overflow().is_empty()); + assert!( + spec.strip() + .contains(&TopToolbarNode::Control(TopToolbarControl::Overflow)) + ); +} + +fn assert_overflow_menu_metadata(snapshot: &ToolbarSnapshot) { let canvas = TopToolbarControl::CanvasMenu; let session = TopToolbarControl::SessionMenu; let settings = TopToolbarControl::SettingsMenu; - assert_eq!(canvas.id().render_id(), "top.menu.canvas"); assert_eq!(session.id().render_id(), "top.menu.session"); assert_eq!(settings.id().render_id(), "top.menu.settings"); - assert_eq!(canvas.label(&snapshot), "Canvas..."); - assert_eq!(session.label(&snapshot), "Session..."); - assert_eq!(settings.label(&snapshot), "Settings..."); - assert_eq!(canvas.icon(&snapshot), Some(TopToolbarIcon::Canvas)); - assert_eq!(session.icon(&snapshot), Some(TopToolbarIcon::Session)); - assert_eq!(settings.icon(&snapshot), Some(TopToolbarIcon::Settings)); + assert_eq!(canvas.label(snapshot), "Canvas..."); + assert_eq!(session.label(snapshot), "Session..."); + assert_eq!(settings.label(snapshot), "Settings..."); + assert_eq!(canvas.icon(snapshot), Some(TopToolbarIcon::Canvas)); + assert_eq!(session.icon(snapshot), Some(TopToolbarIcon::Session)); + assert_eq!(settings.icon(snapshot), Some(TopToolbarIcon::Settings)); // The three entries carry distinct icons. - assert_ne!(canvas.icon(&snapshot), session.icon(&snapshot)); - assert_ne!(canvas.icon(&snapshot), settings.icon(&snapshot)); - assert_ne!(session.icon(&snapshot), settings.icon(&snapshot)); + assert_ne!(canvas.icon(snapshot), session.icon(snapshot)); + assert_ne!(canvas.icon(snapshot), settings.icon(snapshot)); + assert_ne!(session.icon(snapshot), settings.icon(snapshot)); assert_eq!(canvas.role(), TopToolbarControlRole::Toggle); assert_eq!(session.role(), TopToolbarControlRole::Toggle); assert_eq!(settings.role(), TopToolbarControlRole::Toggle); assert_eq!(canvas.island(), TopToolbarIsland::History); assert_eq!(session.island(), TopToolbarIsland::History); assert_eq!(settings.island(), TopToolbarIsland::History); - assert!(canvas.enabled(&snapshot) && session.enabled(&snapshot) && settings.enabled(&snapshot)); + assert!(canvas.enabled(snapshot) && session.enabled(snapshot) && settings.enabled(snapshot)); +} - // Entries toggle their popover open state and report it as active. +fn assert_overflow_menu_toggle_behavior(snapshot: &mut ToolbarSnapshot) { + let canvas = TopToolbarControl::CanvasMenu; + let session = TopToolbarControl::SessionMenu; + let settings = TopToolbarControl::SettingsMenu; assert_eq!( - canvas.event(&snapshot), + canvas.event(snapshot), ToolbarEvent::ToggleCanvasPopover(true) ); assert_eq!( - session.event(&snapshot), + session.event(snapshot), ToolbarEvent::ToggleSessionPopover(true) ); assert_eq!( - settings.event(&snapshot), + settings.event(snapshot), ToolbarEvent::ToggleSettingsPopover(true) ); - assert!(!canvas.active(&snapshot) && !session.active(&snapshot) && !settings.active(&snapshot)); + assert!(!canvas.active(snapshot) && !session.active(snapshot) && !settings.active(snapshot)); snapshot.canvas_popover_open = true; - assert!(canvas.active(&snapshot)); + assert!(canvas.active(snapshot)); assert_eq!( - canvas.event(&snapshot), + canvas.event(snapshot), ToolbarEvent::ToggleCanvasPopover(false) ); snapshot.canvas_popover_open = false; snapshot.session_popover_open = true; - assert!(session.active(&snapshot)); + assert!(session.active(snapshot)); assert_eq!( - session.event(&snapshot), + session.event(snapshot), ToolbarEvent::ToggleSessionPopover(false) ); snapshot.session_popover_open = false; snapshot.settings_popover_open = true; - assert!(settings.active(&snapshot)); + assert!(settings.active(snapshot)); assert_eq!( - settings.event(&snapshot), + settings.event(snapshot), ToolbarEvent::ToggleSettingsPopover(false) ); +} - // Minimized and micro strips carry no overflow (and so no entries). +fn assert_collapsed_strips_have_no_overflow() { let mut minimized = self::snapshot(); minimized.top_minimized = true; assert!( From 26689e89246e7bb2c05fc7d286c5ff8fcc42a420 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:04:28 +0200 Subject: [PATCH 44/53] refactor(test): split style pill tool checks --- src/backend/wayland/toolbar/view/top/tests.rs | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index 22047624..a3378379 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -748,8 +748,17 @@ fn spotlight_style_pill_shows_missing_source_status_inline() { #[test] fn style_pill_morphs_per_tool() { + assert_marker_style_pill(); + assert_eraser_style_pill(); + assert_shape_style_pill(); + assert_arrow_style_pill(); + assert_step_marker_style_pill(); + assert_text_style_pill(); +} + +fn assert_marker_style_pill() { use crate::backend::wayland::toolbar::events::HitKind; - use crate::input::{EraserMode, Tool}; + use crate::input::Tool; // Marker: thickness (targeting the marker size) plus the opacity slider // with its inline readout decoration. @@ -776,6 +785,10 @@ fn style_pill_morphs_per_tool() { other => panic!("readout kind, got {other:?}"), } assert!(readout.interact.is_none()); +} + +fn assert_eraser_style_pill() { + use crate::input::{EraserMode, Tool}; // Eraser: colorless; the old checkbox became a Brush/Stroke segment // emitting SetEraserMode, painted by the activated SegmentedControl. @@ -825,6 +838,10 @@ fn style_pill_morphs_per_tool() { )) => {} other => panic!("numeral opens the precise entry, got {other:?}"), } +} + +fn assert_shape_style_pill() { + use crate::input::Tool; // Shapes: the Fill mini-toggle joins the stroke controls. let rect = snapshot_for_tool(Tool::Rect); @@ -840,6 +857,10 @@ fn style_pill_morphs_per_tool() { fill.interact.as_ref().unwrap().event, ToolbarEvent::ToggleFill(value) if value != rect.fill_enabled )); +} + +fn assert_arrow_style_pill() { + use crate::input::Tool; // Arrow: auto-number toggle, and the reset button (with the next-N // tooltip) only while numbering is enabled. @@ -866,6 +887,10 @@ fn style_pill_morphs_per_tool() { interaction.tooltip.as_deref(), Some("Reset numbering to 1 (next: 7)") ); +} + +fn assert_step_marker_style_pill() { + use crate::input::Tool; // Step marker: the reset targets the step counter. let mut step = snapshot_for_tool(Tool::StepMarker); @@ -883,6 +908,10 @@ fn style_pill_morphs_per_tool() { interaction.tooltip.as_deref(), Some("Reset numbering to 1 (next: 4)") ); +} + +fn assert_text_style_pill() { + use crate::backend::wayland::toolbar::events::HitKind; // Text: pt-labelled size slider plus independent weight/family controls. let mut text = snapshot(); From 9f76a8572a2675e58540124a20f618e8cfd66bf3 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:06:34 +0200 Subject: [PATCH 45/53] refactor(test): split session popover checks --- src/backend/wayland/toolbar/view/top/tests.rs | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index a3378379..dfe7d9bb 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -1509,9 +1509,21 @@ fn session_popover_re_hosts_the_session_pane_content() { .expect("session popover panel"); assert!(matches!(panel.kind, WidgetKind::Popover { .. })); + assert_session_popover_model(&tree, &snapshot); + assert_session_nodes_inside_panel(&tree); + assert_session_popover_input_rect(&snapshot, &tree, w, h); + + // A pending Save-As overwrite swaps the button grid for the + // confirmation, exactly like the pane. + snapshot.pending_save_as_overwrite_path = + Some(PathBuf::from("/tmp/existing.wayscriber-session")); + assert_session_overwrite_confirmation(&snapshot); +} + +fn assert_session_popover_model(tree: &WidgetTree, snapshot: &ToolbarSnapshot) { // Content parity with the Session pane: the same model drives both, so // every model control appears exactly once with the same event. - let model = crate::ui::toolbar::model::ToolbarSessionModel::for_popover(&snapshot) + let model = crate::ui::toolbar::model::ToolbarSessionModel::for_popover(snapshot) .expect("session model"); assert_eq!(model.buttons.len(), 5); for button in &model.buttons { @@ -1547,7 +1559,12 @@ fn session_popover_re_hosts_the_session_pane_content() { // Meta labels are decor; this popover has no collapsible header. assert!(tree.node_by_id(&"top.menu.session.name".into()).is_some()); assert!(tree.node_by_id(&"top.menu.session.path".into()).is_some()); +} +fn assert_session_nodes_inside_panel(tree: &WidgetTree) { + let panel = tree + .node_by_id(&"top.menu.session.panel".into()) + .expect("session popover panel"); // Every content node paints inside the panel. for node in tree.nodes() { if node.id.as_str().starts_with("top.menu.session.") @@ -1563,9 +1580,19 @@ fn session_popover_re_hosts_the_session_pane_content() { ); } } +} +fn assert_session_popover_input_rect( + snapshot: &ToolbarSnapshot, + tree: &WidgetTree, + w: u32, + h: u32, +) { + let panel = tree + .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(snapshot, w as f64, h as f64).expect("input rects"); assert!( rects .iter() @@ -1574,12 +1601,10 @@ fn session_popover_re_hosts_the_session_pane_content() { && rect.0 + rect.2 >= panel.rect.0 + panel.rect.2), "popover rect missing from {rects:?}" ); +} - // A pending Save-As overwrite swaps the button grid for the - // confirmation, exactly like the pane. - snapshot.pending_save_as_overwrite_path = - Some(PathBuf::from("/tmp/existing.wayscriber-session")); - let tree = build(&snapshot); +fn assert_session_overwrite_confirmation(snapshot: &ToolbarSnapshot) { + let tree = build(snapshot); let replace = tree .node_by_id(&"top.menu.session.confirm-replace".into()) .expect("replace button"); From 526d56d868718da21aec8646a60f816eec4a16e0 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:08:28 +0200 Subject: [PATCH 46/53] refactor(test): table-drive preset capture checks --- src/input/state/tests/tool_controls.rs | 112 +++++-------------------- 1 file changed, 23 insertions(+), 89 deletions(-) diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index 7afb3be6..c772b241 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -1712,79 +1712,22 @@ fn color_picker_apply_updates_opening_modifier_tool_after_modifier_release() { fn save_preset_captures_all_tool_settings() { let mut state = create_test_input_state(); state.preset_slot_count = 3; - let pen_color = Color { - r: 0.1, - g: 0.2, - b: 0.3, - a: 1.0, - }; - let line_color = Color { - r: 0.4, - g: 0.5, - b: 0.6, - a: 1.0, - }; - let rect_color = Color { - r: 0.9, - g: 0.1, - b: 0.2, - a: 1.0, - }; - let ellipse_color = Color { - r: 0.2, - g: 0.9, - b: 0.4, - a: 1.0, - }; - let arrow_color = Color { - r: 0.8, - g: 0.3, - b: 0.7, - a: 1.0, - }; - let blur_color = Color { - r: 0.3, - g: 0.3, - b: 0.3, - a: 1.0, - }; - let marker_color = Color { - r: 0.7, - g: 0.8, - b: 0.1, - a: 1.0, - }; - let step_color = Color { - r: 0.2, - g: 0.7, - b: 0.9, - a: 1.0, - }; + let styles = [ + (Tool::Pen, Color::new(0.1, 0.2, 0.3, 1.0), 4.0), + (Tool::Line, Color::new(0.4, 0.5, 0.6, 1.0), 14.0), + (Tool::Rect, Color::new(0.9, 0.1, 0.2, 1.0), 16.0), + (Tool::Ellipse, Color::new(0.2, 0.9, 0.4, 1.0), 18.0), + (Tool::Arrow, Color::new(0.8, 0.3, 0.7, 1.0), 20.0), + (Tool::Blur, Color::new(0.3, 0.3, 0.3, 1.0), 22.0), + (Tool::Marker, Color::new(0.7, 0.8, 0.1, 1.0), 24.0), + (Tool::StepMarker, Color::new(0.2, 0.7, 0.9, 1.0), 30.0), + ]; - assert!(state.set_tool_override(Some(Tool::Pen))); - assert!(state.set_color(pen_color)); - assert!(state.set_thickness(4.0)); - assert!(state.set_tool_override(Some(Tool::Line))); - assert!(state.set_color(line_color)); - assert!(state.set_thickness(14.0)); - assert!(state.set_tool_override(Some(Tool::Rect))); - assert!(state.set_color(rect_color)); - assert!(state.set_thickness(16.0)); - assert!(state.set_tool_override(Some(Tool::Ellipse))); - assert!(state.set_color(ellipse_color)); - assert!(state.set_thickness(18.0)); - assert!(state.set_tool_override(Some(Tool::Arrow))); - assert!(state.set_color(arrow_color)); - assert!(state.set_thickness(20.0)); - assert!(state.set_tool_override(Some(Tool::Blur))); - assert!(state.set_color(blur_color)); - assert!(state.set_thickness(22.0)); - assert!(state.set_tool_override(Some(Tool::Marker))); - assert!(state.set_color(marker_color)); - assert!(state.set_thickness(24.0)); - assert!(state.set_tool_override(Some(Tool::StepMarker))); - assert!(state.set_color(step_color)); - assert!(state.set_thickness(30.0)); + for &(tool, color, size) in &styles { + assert!(state.set_tool_override(Some(tool))); + assert!(state.set_color(color)); + assert!(state.set_thickness(size)); + } assert!(state.set_tool_override(Some(Tool::Eraser))); assert!(state.set_eraser_size(33.0)); assert!(state.set_tool_override(Some(Tool::Line))); @@ -1794,24 +1737,15 @@ fn save_preset_captures_all_tool_settings() { let tool_settings = preset.tool_settings.as_ref().expect("tool settings"); assert_eq!(preset.tool, Tool::Line); - assert_eq!(preset.color, ColorSpec::from(line_color)); + assert_eq!(preset.color, ColorSpec::from(styles[1].1)); assert_eq!(preset.size, 14.0); - assert_eq!(tool_settings.pen.color, ColorSpec::from(pen_color)); - assert_eq!(tool_settings.pen.size, 4.0); - assert_eq!(tool_settings.line.color, ColorSpec::from(line_color)); - assert_eq!(tool_settings.line.size, 14.0); - assert_eq!(tool_settings.rect.color, ColorSpec::from(rect_color)); - assert_eq!(tool_settings.rect.size, 16.0); - assert_eq!(tool_settings.ellipse.color, ColorSpec::from(ellipse_color)); - assert_eq!(tool_settings.ellipse.size, 18.0); - assert_eq!(tool_settings.arrow.color, ColorSpec::from(arrow_color)); - assert_eq!(tool_settings.arrow.size, 20.0); - assert_eq!(tool_settings.blur.color, ColorSpec::from(blur_color)); - assert_eq!(tool_settings.blur.size, 22.0); - assert_eq!(tool_settings.marker.color, ColorSpec::from(marker_color)); - assert_eq!(tool_settings.marker.size, 24.0); - assert_eq!(tool_settings.step_marker.color, ColorSpec::from(step_color)); - assert_eq!(tool_settings.step_marker.size, 30.0); + for (tool, color, size) in styles { + assert_eq!( + tool_settings.color_spec_for_tool(tool), + ColorSpec::from(color) + ); + assert_eq!(tool_settings.size_for_tool(tool), size); + } assert_eq!(tool_settings.eraser_size, 33.0); } From 393f283d93a436b652653309fea7b8cf4d1dd0b5 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:10:07 +0200 Subject: [PATCH 47/53] refactor(test): table-drive preset restore checks --- src/input/state/tests/tool_controls.rs | 133 +++++-------------------- 1 file changed, 25 insertions(+), 108 deletions(-) diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index c772b241..b601910f 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -1819,75 +1819,28 @@ fn save_preset_without_override_uses_unmodified_drag_tool() { fn apply_full_preset_restores_all_tool_settings() { let mut state = create_test_input_state(); state.preset_slot_count = 3; - let pen_color = Color { - r: 0.1, - g: 0.2, - b: 0.3, - a: 1.0, - }; - let line_color = Color { - r: 0.4, - g: 0.5, - b: 0.6, - a: 1.0, - }; - let rect_color = Color { - r: 0.9, - g: 0.1, - b: 0.2, - a: 1.0, - }; - let ellipse_color = Color { - r: 0.2, - g: 0.9, - b: 0.4, - a: 1.0, - }; - let arrow_color = Color { - r: 0.8, - g: 0.3, - b: 0.7, - a: 1.0, - }; - let blur_color = Color { - r: 0.3, - g: 0.3, - b: 0.3, - a: 1.0, - }; - let marker_color = Color { - r: 0.7, - g: 0.8, - b: 0.1, - a: 1.0, - }; - let step_color = Color { - r: 0.2, - g: 0.7, - b: 0.9, - a: 1.0, - }; - let mut settings = PerToolDrawingSettings::new(pen_color, 4.0); - settings.line.color = line_color; - settings.line.thickness = 14.0; - settings.rect.color = rect_color; - settings.rect.thickness = 16.0; - settings.ellipse.color = ellipse_color; - settings.ellipse.thickness = 18.0; - settings.arrow.color = arrow_color; - settings.arrow.thickness = 20.0; - settings.blur.color = blur_color; - settings.blur.thickness = 22.0; - settings.marker.color = marker_color; - settings.marker.thickness = 24.0; - settings.step_marker.color = step_color; - settings.step_marker.thickness = 30.0; + let styles = [ + (Tool::Pen, Color::new(0.1, 0.2, 0.3, 1.0), 4.0), + (Tool::Line, Color::new(0.4, 0.5, 0.6, 1.0), 14.0), + (Tool::Rect, Color::new(0.9, 0.1, 0.2, 1.0), 16.0), + (Tool::Ellipse, Color::new(0.2, 0.9, 0.4, 1.0), 18.0), + (Tool::Arrow, Color::new(0.8, 0.3, 0.7, 1.0), 20.0), + (Tool::Blur, Color::new(0.3, 0.3, 0.3, 1.0), 22.0), + (Tool::Marker, Color::new(0.7, 0.8, 0.1, 1.0), 24.0), + (Tool::StepMarker, Color::new(0.2, 0.7, 0.9, 1.0), 30.0), + ]; + let mut settings = PerToolDrawingSettings::new(styles[0].1, styles[0].2); + for &(tool, color, thickness) in &styles[1..] { + let setting = settings.get_mut(tool); + setting.color = color; + setting.thickness = thickness; + } let tool_settings = PresetToolStatesConfig::from_runtime(&settings, 33.0); state.presets[0] = Some(ToolPresetConfig { name: None, tool: Tool::Marker, - color: ColorSpec::from(marker_color), + color: ColorSpec::from(styles[6].1), size: 24.0, tool_settings: Some(tool_settings), eraser_kind: None, @@ -1907,51 +1860,15 @@ fn apply_full_preset_restores_all_tool_settings() { assert!(state.apply_preset(1)); assert_eq!(state.active_tool(), Tool::Marker); - assert_eq!( - state.color_for_tool(Tool::Pen), - ColorSpec::from(pen_color).to_color() - ); - assert_eq!(state.thickness_for_tool(Tool::Pen), 4.0); - assert_eq!( - state.color_for_tool(Tool::Line), - ColorSpec::from(line_color).to_color() - ); - assert_eq!(state.thickness_for_tool(Tool::Line), 14.0); - assert_eq!( - state.color_for_tool(Tool::Rect), - ColorSpec::from(rect_color).to_color() - ); - assert_eq!(state.thickness_for_tool(Tool::Rect), 16.0); - assert_eq!( - state.color_for_tool(Tool::Ellipse), - ColorSpec::from(ellipse_color).to_color() - ); - assert_eq!(state.thickness_for_tool(Tool::Ellipse), 18.0); - assert_eq!( - state.color_for_tool(Tool::Arrow), - ColorSpec::from(arrow_color).to_color() - ); - assert_eq!(state.thickness_for_tool(Tool::Arrow), 20.0); - assert_eq!( - state.color_for_tool(Tool::Blur), - ColorSpec::from(blur_color).to_color() - ); - assert_eq!(state.thickness_for_tool(Tool::Blur), 22.0); - assert_eq!( - state.color_for_tool(Tool::Marker), - ColorSpec::from(marker_color).to_color() - ); - assert_eq!(state.thickness_for_tool(Tool::Marker), 24.0); - assert_eq!( - state.color_for_tool(Tool::StepMarker), - ColorSpec::from(step_color).to_color() - ); - assert_eq!(state.thickness_for_tool(Tool::StepMarker), 30.0); + for (tool, color, thickness) in styles { + assert_eq!( + state.color_for_tool(tool), + ColorSpec::from(color).to_color() + ); + assert_eq!(state.thickness_for_tool(tool), thickness); + } assert_eq!(state.eraser_size, 33.0); - assert_eq!( - state.current_color, - ColorSpec::from(marker_color).to_color() - ); + assert_eq!(state.current_color, ColorSpec::from(styles[6].1).to_color()); assert_eq!(state.current_thickness, 24.0); } From 22e437339106c57507918975c053900e2b74df9f Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:13:32 +0200 Subject: [PATCH 48/53] refactor(test): split GTK capture suppression phases --- src/toolbar_gtk/view/capture_suppression.rs | 132 ++++++++++++++++---- 1 file changed, 105 insertions(+), 27 deletions(-) diff --git a/src/toolbar_gtk/view/capture_suppression.rs b/src/toolbar_gtk/view/capture_suppression.rs index 12bdf0b5..a0cf020d 100644 --- a/src/toolbar_gtk/view/capture_suppression.rs +++ b/src/toolbar_gtk/view/capture_suppression.rs @@ -533,6 +533,39 @@ mod tests { return; } + let fixture = gtk_popup_fixture(); + let (capture, tooltip, popover_content) = suppress_gtk_popup_fixture(&fixture); + + // Exercise GtkPopoverMenu's class map/unmap hooks under + // G_DEBUG=fatal-criticals. The reused native must also submit a fresh + // proof after the remap. + assert_context_popover_remap(&capture, &fixture); + + // A popup discovered by the final post-input rescan must receive its + // own proof and remain non-interactive after input admission closes. + let (late_popover, replacement_late_child) = + assert_late_popover_reuse(&capture, &fixture, &tooltip); + assert_capture_restored( + &capture, + &fixture, + &popover_content, + &late_popover, + replacement_late_child, + ); + assert_withdrawn_popover_rejected(&capture, &fixture.root); + assert_asynchronous_popover_discovery(); + } + + struct GtkPopupFixture { + root: gtk4::Box, + button: gtk4::Button, + context_popover: gtk4::Popover, + context_menu: gtk4::PopoverMenu, + original_context_child: gtk4::Widget, + original_context_model: gtk4::gio::MenuModel, + } + + fn gtk_popup_fixture() -> GtkPopupFixture { let root = gtk4::Box::new(gtk4::Orientation::Vertical, 0); let window = gtk4::Window::new(); window.set_child(Some(&root)); @@ -560,32 +593,50 @@ mod tests { .expect("GtkText context popup is a GtkPopoverMenu"); let original_context_child = context_popover.child().expect("GtkText popup child"); let original_context_model = context_menu.menu_model().expect("GtkText menu model"); + GtkPopupFixture { + root, + button, + context_popover, + context_menu, + original_context_child, + original_context_model, + } + } + fn suppress_gtk_popup_fixture( + fixture: &GtkPopupFixture, + ) -> (TooltipCapture, gtk4::Tooltip, CaptureSurfaceContent) { let capture = TooltipCapture::new(); - capture.install_tree(root.upcast_ref()); - capture.install_tree(root.upcast_ref()); + capture.install_tree(fixture.root.upcast_ref()); + capture.install_tree(fixture.root.upcast_ref()); assert_eq!(capture.installed_source_count(), 1); let tooltip = gtk4::glib::Object::builder::().build(); - assert!(button.emit_by_name::("query-tooltip", &[&0i32, &0i32, &false, &tooltip])); + assert!( + fixture + .button + .emit_by_name::("query-tooltip", &[&0i32, &0i32, &false, &tooltip]) + ); assert_eq!(capture.inner.label.text().as_str(), "Zoom"); assert_eq!(capture.transparent_content_state(), (Some(1.0), false)); capture.set_suppressed(true); - capture.install_tree(root.upcast_ref()); + capture.install_tree(fixture.root.upcast_ref()); // A hidden realized toplevel cannot map its popup, so explicitly mark // and map the real GtkText-owned surface for this structural test. The // parent window remains hidden throughout. - capture.enroll_native_popover(&context_popover, true); - gtk4::prelude::WidgetExt::map(&context_popover); + capture.enroll_native_popover(&fixture.context_popover, true); + gtk4::prelude::WidgetExt::map(&fixture.context_popover); assert_eq!( - context_popover.child().as_ref(), - Some(&original_context_child), + fixture.context_popover.child().as_ref(), + Some(&fixture.original_context_child), "capture suppression must preserve GtkPopoverMenu's class-owned child" ); assert_eq!(capture.transparent_content_state(), (Some(0.0), true)); assert!( - context_popover.has_css_class(CAPTURE_TRANSPARENT_CLASS), + fixture + .context_popover + .has_css_class(CAPTURE_TRANSPARENT_CLASS), "GTK-owned entry popovers must join capture suppression" ); let popover_targets = capture.capture_popover_targets(); @@ -596,24 +647,28 @@ mod tests { assert_eq!(capture.pending_capture_popover_targets().len(), 1); capture.mark_capture_popovers_proven(); assert!(capture.pending_capture_popover_targets().is_empty()); + (capture, tooltip, popover_content) + } - // Exercise GtkPopoverMenu's class map/unmap hooks under - // G_DEBUG=fatal-criticals. The reused native must also submit a fresh - // proof after the remap. - gtk4::prelude::WidgetExt::unmap(&context_popover); + fn assert_context_popover_remap(capture: &TooltipCapture, fixture: &GtkPopupFixture) { + gtk4::prelude::WidgetExt::unmap(&fixture.context_popover); assert!(capture.capture_popover_targets().is_empty()); - gtk4::prelude::WidgetExt::map(&context_popover); + gtk4::prelude::WidgetExt::map(&fixture.context_popover); assert_eq!(capture.pending_capture_popover_targets().len(), 1); assert_eq!( - context_popover.child().as_ref(), - Some(&original_context_child) + fixture.context_popover.child().as_ref(), + Some(&fixture.original_context_child) ); capture.mark_capture_popovers_proven(); assert!(capture.pending_capture_popover_targets().is_empty()); - assert!(!context_popover.can_target()); + assert!(!fixture.context_popover.can_target()); + } - // A popup discovered by the final post-input rescan must receive its - // own proof and remain non-interactive after input admission closes. + fn assert_late_popover_reuse( + capture: &TooltipCapture, + fixture: &GtkPopupFixture, + tooltip: >k4::Tooltip, + ) -> (gtk4::Popover, gtk4::Label) { let late_popover = gtk4::Popover::new(); let late_child = gtk4::Label::new(Some("Late popup")); late_popover.set_child(Some(&late_child)); @@ -635,39 +690,60 @@ mod tests { ); let proof_before = capture.inner.content.proof.paintable(); let serial_before = capture.inner.content.proof_serial(); - CaptureProofTarget::new("tooltip", &root, &capture.inner.content) + CaptureProofTarget::new("tooltip", &fixture.root, &capture.inner.content) .refresh_transparent_proof(); assert_ne!(capture.inner.content.proof_serial(), serial_before); assert_ne!(capture.inner.content.proof.paintable(), proof_before); assert!( - !capture.query_tooltip(button.upcast_ref(), &tooltip), + !capture.query_tooltip(fixture.button.upcast_ref(), tooltip), "an unmapped tooltip must not appear after suppression starts" ); // The mapped case uses the same query path but remains admitted so its // existing native popup can submit the transparent proof. capture.inner.mapped_before_capture.set(true); - assert!(capture.query_tooltip(button.upcast_ref(), &tooltip)); + assert!(capture.query_tooltip(fixture.button.upcast_ref(), tooltip)); + (late_popover, replacement_late_child) + } + fn assert_capture_restored( + capture: &TooltipCapture, + fixture: &GtkPopupFixture, + popover_content: &CaptureSurfaceContent, + late_popover: >k4::Popover, + replacement_late_child: gtk4::Label, + ) { capture.set_suppressed(false); assert_eq!(capture.transparent_content_state(), (Some(1.0), false)); - assert!(!context_popover.has_css_class(CAPTURE_TRANSPARENT_CLASS)); + assert!( + !fixture + .context_popover + .has_css_class(CAPTURE_TRANSPARENT_CLASS) + ); assert_eq!(popover_content.content_opacity(), None); assert!(!popover_content.proof_visible()); - assert!(context_popover.can_target()); - assert_eq!(context_popover.child(), Some(original_context_child)); - assert_eq!(context_menu.menu_model(), Some(original_context_model)); + assert!(fixture.context_popover.can_target()); + assert_eq!( + fixture.context_popover.child().as_ref(), + Some(&fixture.original_context_child) + ); + assert_eq!( + fixture.context_menu.menu_model().as_ref(), + Some(&fixture.original_context_model) + ); assert_eq!( late_popover.child(), Some(replacement_late_child.upcast::()) ); assert!(capture.capture_popover_targets().is_empty()); + } + fn assert_withdrawn_popover_rejected(capture: &TooltipCapture, root: >k4::Box) { let withdrawn = Rc::new(Cell::new(false)); let withdrawn_callback = Rc::clone(&withdrawn); let closed_target = CaptureProofTarget::new_withdrawable_with_callback( "closed-native-popover", - &root, + root, &capture.inner.content, move || withdrawn_callback.set(true), ); @@ -683,7 +759,9 @@ mod tests { "unexpected withdrawal error: {error}" ); assert!(withdrawn.get()); + } + fn assert_asynchronous_popover_discovery() { // Exercise the production discovery path across a main-context yield. // The GtkText-owned popover does not exist during the first scan and // must join the barrier without a direct enroll_native_popover() call. From 1f71af2f4f0b745d0e80fccfcb27fc14120fc7ce Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:16:16 +0200 Subject: [PATCH 49/53] refactor(test): split GTK style role checks --- src/toolbar_gtk/view/top_bar/tests.rs | 468 ++++++++++++++------------ 1 file changed, 254 insertions(+), 214 deletions(-) diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index cc0c10c7..1c0b8640 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -838,223 +838,263 @@ fn assert_gtk_style_widget( ) { let id = widget.widget_name().to_string(); match control.role() { - model::StylePillRole::Swatch => { - let button = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a swatch button")); - assert!(button.has_css_class("swatch"), "{id} swatch class"); - assert_eq!( - button.tooltip_text().as_deref(), - control.tooltip(snapshot).as_deref(), - "{id} tooltip" - ); - assert_accessible_label(widget, &control.label(snapshot), &id); - } - model::StylePillRole::Slider => { - // SliderRow: a box hosting the hand-drawn track DrawingArea. - let row = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a slider row")); - let track = row.first_child().expect("slider track"); - assert!(track.is::(), "{id} slider track"); - let value = track.next_sibling().expect("slider value readout"); - let value = value - .downcast::() - .unwrap_or_else(|_| panic!("{id} value readout is a label")); - let carries_readout = control.carries_inline_readout(); - assert_eq!( - value.property::("visible"), - carries_readout, - "{id} readout visibility" - ); - let expected_width = if carries_readout { - STYLE_SLIDER_W + STYLE_PILL_GAP + STYLE_VALUE_W - } else { - STYLE_SLIDER_W - }; - assert_eq!( - row.width_request(), - expected_width.round() as i32, - "{id} keeps the shared track width when its readout is visible" - ); - if carries_readout { - assert_eq!( - value.xalign(), - 0.0, - "{id} places its readout next to the track" - ); - } - } - model::StylePillRole::Value => { - let button = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a numeral button")); - assert_eq!( - button.label().as_deref(), - control.value_text(snapshot).as_deref(), - "{id} live numeral" - ); - assert_eq!( - button.tooltip_text().as_deref(), - control.tooltip(snapshot).as_deref(), - "{id} tooltip" - ); + model::StylePillRole::Swatch => assert_gtk_style_swatch(widget, control, snapshot, &id), + model::StylePillRole::Slider => assert_gtk_style_slider(widget, control, &id), + model::StylePillRole::Value => assert_gtk_style_value(widget, control, snapshot, &id), + model::StylePillRole::Toggle => assert_gtk_style_toggle(widget, control, snapshot, &id), + model::StylePillRole::Button => assert_gtk_style_button(widget, control, snapshot, &id), + model::StylePillRole::Stepper => assert_gtk_style_stepper(widget, control, snapshot, &id), + model::StylePillRole::Segmented => { + assert_gtk_style_segmented(widget, control, snapshot, &id) } - model::StylePillRole::Toggle => { - let check = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a check button")); - assert_eq!(check.is_active(), control.active(snapshot), "{id} state"); - assert_eq!( - check.label().as_deref(), - Some(control.label(snapshot).as_ref()), - "{id} label" - ); - assert_eq!( - check.tooltip_text().as_deref(), - control.tooltip(snapshot).as_deref(), - "{id} tooltip" - ); + } +} + +fn assert_gtk_style_swatch( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let button = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a swatch button")); + assert!(button.has_css_class("swatch"), "{id} swatch class"); + assert_eq!( + button.tooltip_text().as_deref(), + control.tooltip(snapshot).as_deref(), + "{id} tooltip" + ); + assert_accessible_label(widget, &control.label(snapshot), id); +} + +fn assert_gtk_style_slider(widget: >k4::Widget, control: model::StylePillControl, id: &str) { + // SliderRow: a box hosting the hand-drawn track DrawingArea. + let row = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a slider row")); + let track = row.first_child().expect("slider track"); + assert!(track.is::(), "{id} slider track"); + let value = track.next_sibling().expect("slider value readout"); + let value = value + .downcast::() + .unwrap_or_else(|_| panic!("{id} value readout is a label")); + let carries_readout = control.carries_inline_readout(); + assert_eq!( + value.property::("visible"), + carries_readout, + "{id} readout visibility" + ); + let expected_width = if carries_readout { + STYLE_SLIDER_W + STYLE_PILL_GAP + STYLE_VALUE_W + } else { + STYLE_SLIDER_W + }; + assert_eq!( + row.width_request(), + expected_width.round() as i32, + "{id} keeps the shared track width when its readout is visible" + ); + if carries_readout { + assert_eq!( + value.xalign(), + 0.0, + "{id} places its readout next to the track" + ); + } +} + +fn assert_gtk_style_value( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let button = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a numeral button")); + assert_eq!( + button.label().as_deref(), + control.value_text(snapshot).as_deref(), + "{id} live numeral" + ); + assert_eq!( + button.tooltip_text().as_deref(), + control.tooltip(snapshot).as_deref(), + "{id} tooltip" + ); +} + +fn assert_gtk_style_toggle( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let check = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a check button")); + assert_eq!(check.is_active(), control.active(snapshot), "{id} state"); + assert_eq!( + check.label().as_deref(), + Some(control.label(snapshot).as_ref()), + "{id} label" + ); + assert_eq!( + check.tooltip_text().as_deref(), + control.tooltip(snapshot).as_deref(), + "{id} tooltip" + ); +} + +fn assert_gtk_style_button( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let button = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a button")); + // Cycle buttons show the live value they step; plain buttons show their + // label. + let expected_text = match control { + model::StylePillControl::SelectionCycle(_) | model::StylePillControl::ArrowStyleCycle => { + control.value_text(snapshot).expect("cycle value text") } - model::StylePillRole::Button => { - let button = widget - .clone() + _ => control.label(snapshot).into_owned(), + }; + assert_eq!( + button.label().as_deref(), + Some(expected_text.as_str()), + "{id} text" + ); + assert_eq!( + button.is_sensitive(), + control.enabled(snapshot), + "{id} enabled" + ); + assert_eq!( + button.tooltip_text().as_deref(), + control.tooltip(snapshot).as_deref(), + "{id} tooltip" + ); + if control == model::StylePillControl::FontFamilyPicker { + // The builtin leaves a gap before the family picker so it does not + // crowd the point-size numeral. + assert!( + button.margin_start() > 0, + "{id} lost the leading gap the builtin gives it" + ); + } +} + +fn assert_gtk_style_stepper( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let steps = control.steps(snapshot).expect("stepper halves"); + let row = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a stepper row")); + // The builtin lays the three parts out abutting and the width planner + // budgets step + value + step exactly. + assert_eq!(row.spacing(), 0, "{id} stepper spacing"); + assert_accessible_label(widget, &control.label(snapshot), id); + let minus = widget.first_child().expect("stepper minus half"); + let value = minus.next_sibling().expect("stepper value readout"); + let plus = value.next_sibling().expect("stepper plus half"); + assert!(plus.next_sibling().is_none(), "{id} has three children"); + for (half, step) in [(&minus, &steps[0]), (&plus, &steps[1])] { + let button = half + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{} is a button", step.id)); + assert_eq!(half.widget_name().as_str(), step.id, "{id} half id"); + assert_eq!( + button.label().as_deref(), + Some(step.label), + "{} label", + step.id + ); + assert_eq!( + button.tooltip_text().as_deref(), + Some(step.tooltip.as_str()), + "{} tooltip", + step.id + ); + // A tooltip is not an accessible name: a screen reader on these halves + // would otherwise announce only "−" and "+". + assert_accessible_label(button.upcast_ref(), &step.tooltip, step.id); + assert_eq!( + button.is_sensitive(), + control.enabled(snapshot), + "{} enabled", + step.id + ); + } + let value_label = value + .downcast::() + .unwrap_or_else(|_| panic!("{id} value readout is a label")); + assert_eq!( + value_label.widget_name().as_str(), + format!("{}.value", control.id()), + "{id} value id" + ); + assert_eq!( + Some(value_label.text().to_string()), + control.value_text(snapshot), + "{id} live value" + ); +} + +fn assert_gtk_style_segmented( + widget: >k4::Widget, + control: model::StylePillControl, + snapshot: &ToolbarSnapshot, + id: &str, +) { + let segments = control.segments(snapshot).expect("segment halves"); + let mut buttons = Vec::new(); + let mut child = widget.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + buttons.push( + current .downcast::() - .unwrap_or_else(|_| panic!("{id} is a button")); - // Cycle buttons show the live value they step; plain buttons show - // their label. - let expected_text = match control { - model::StylePillControl::SelectionCycle(_) - | model::StylePillControl::ArrowStyleCycle => { - control.value_text(snapshot).expect("cycle value text") - } - _ => control.label(snapshot).into_owned(), - }; - assert_eq!( - button.label().as_deref(), - Some(expected_text.as_str()), - "{id} text" - ); - assert_eq!( - button.is_sensitive(), - control.enabled(snapshot), - "{id} enabled" - ); - assert_eq!( - button.tooltip_text().as_deref(), - control.tooltip(snapshot).as_deref(), - "{id} tooltip" - ); - if control == model::StylePillControl::FontFamilyPicker { - // The builtin puts a clear gap before this button so the family - // name does not crowd the "72pt" numeral to its left. Without - // the matching margin here the two toolbars space it - // differently. - assert!( - button.margin_start() > 0, - "{id} lost the leading gap the builtin gives it" - ); - } - } - model::StylePillRole::Stepper => { - let steps = control.steps(snapshot).expect("stepper halves"); - let row = widget - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{id} is a stepper row")); - // The builtin lays the three parts out abutting and the width - // planner budgets step + value + step exactly. Child spacing here - // would make the widget wider than the plan says it is. - assert_eq!(row.spacing(), 0, "{id} stepper spacing"); - // "− 3 +" says nothing about what it steps. - assert_accessible_label(widget, &control.label(snapshot), &id); - let minus = widget.first_child().expect("stepper minus half"); - let value = minus.next_sibling().expect("stepper value readout"); - let plus = value.next_sibling().expect("stepper plus half"); - assert!(plus.next_sibling().is_none(), "{id} has three children"); - for (half, step) in [(&minus, &steps[0]), (&plus, &steps[1])] { - let button = half - .clone() - .downcast::() - .unwrap_or_else(|_| panic!("{} is a button", step.id)); - assert_eq!(half.widget_name().as_str(), step.id, "{id} half id"); - assert_eq!( - button.label().as_deref(), - Some(step.label), - "{} label", - step.id - ); - assert_eq!( - button.tooltip_text().as_deref(), - Some(step.tooltip.as_str()), - "{} tooltip", - step.id - ); - // A tooltip is not an accessible name: a screen reader on - // these halves would otherwise announce "−" and "+". - assert_accessible_label(button.upcast_ref(), &step.tooltip, step.id); - assert_eq!( - button.is_sensitive(), - control.enabled(snapshot), - "{} enabled", - step.id - ); - } - let value_label = value - .downcast::() - .unwrap_or_else(|_| panic!("{id} value readout is a label")); - assert_eq!( - value_label.widget_name().as_str(), - format!("{}.value", control.id()), - "{id} value id" - ); - assert_eq!( - Some(value_label.text().to_string()), - control.value_text(snapshot), - "{id} live value" - ); - } - model::StylePillRole::Segmented => { - let segments = control.segments(snapshot).expect("segment halves"); - let mut buttons = Vec::new(); - let mut child = widget.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - buttons.push( - current - .downcast::() - .unwrap_or_else(|_| panic!("{id} segment half is a button")), - ); - } - assert_eq!(buttons.len(), segments.len(), "{id} segment count"); - for (button, segment) in buttons.iter().zip(&segments) { - // Contract-id parity with the builtin tree's HitArea halves. - assert_eq!(button.widget_name().as_str(), segment.id, "{id} half id"); - assert!(button.has_css_class("tab"), "{id} tab class"); - assert_eq!( - button.label().as_deref(), - Some(segment.label), - "{} label", - segment.id - ); - assert_eq!( - button.has_css_class("active"), - segment.active, - "{} active state", - segment.id - ); - assert_eq!( - button.tooltip_text().as_deref(), - Some(segment.tooltip.as_str()), - "{} tooltip", - segment.id - ); - } - } + .unwrap_or_else(|_| panic!("{id} segment half is a button")), + ); + } + assert_eq!(buttons.len(), segments.len(), "{id} segment count"); + for (button, segment) in buttons.iter().zip(&segments) { + assert_eq!(button.widget_name().as_str(), segment.id, "{id} half id"); + assert!(button.has_css_class("tab"), "{id} tab class"); + assert_eq!( + button.label().as_deref(), + Some(segment.label), + "{} label", + segment.id + ); + assert_eq!( + button.has_css_class("active"), + segment.active, + "{} active state", + segment.id + ); + assert_eq!( + button.tooltip_text().as_deref(), + Some(segment.tooltip.as_str()), + "{} tooltip", + segment.id + ); } } From 5c9d7c87b18a6c975d614bc3ff4e1bc68288bc87 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:18:30 +0200 Subject: [PATCH 50/53] refactor(test): split builtin toolbar node checks --- src/toolbar_gtk/view/top_bar/tests.rs | 37 ++++++++++++++++++--------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 1c0b8640..495cd22a 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -1155,6 +1155,15 @@ fn assert_builtin_node( ); assert_eq!(shortcut_badge, expected.shortcut_badge.as_deref()); + assert_builtin_node_kind(node, expected); +} + +fn assert_builtin_node_kind( + node: &crate::backend::wayland::TopToolbarWidgetKind, + expected: &SemanticControlRecord, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + match node { W::IconButton { glyph, @@ -1166,21 +1175,11 @@ fn assert_builtin_node( glyph.0, crate::toolbar_icons::top_toolbar_icon_painter(icon) )); - assert_eq!(style.active, expected.active); - assert_eq!(style.disabled, !expected.enabled); - assert_eq!( - style.destructive, - expected.role == model::TopToolbarControlRole::Destructive - ); + assert_builtin_button_style(style.active, style.disabled, style.destructive, expected); } W::TextButton { label, style } => { assert_eq!(label.text, expected.label); - assert_eq!(style.active, expected.active); - assert_eq!(style.disabled, !expected.enabled); - assert_eq!( - style.destructive, - expected.role == model::TopToolbarControlRole::Destructive - ); + assert_builtin_button_style(style.active, style.disabled, style.destructive, expected); } W::Swatch { selected, .. } => assert_eq!(*selected, expected.active), W::PresetSlot { @@ -1224,6 +1223,20 @@ fn assert_builtin_node( } } +fn assert_builtin_button_style( + active: bool, + disabled: bool, + destructive: bool, + expected: &SemanticControlRecord, +) { + assert_eq!(active, expected.active); + assert_eq!(disabled, !expected.enabled); + assert_eq!( + destructive, + expected.role == model::TopToolbarControlRole::Destructive + ); +} + fn builtin_semantic_records( snapshot: &ToolbarSnapshot, expected: &[SemanticAdapterRecord], From f4febcdb9cfb406c0235ceabdd9899885eaac69c Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:21:26 +0200 Subject: [PATCH 51/53] refactor(test): split builtin style pill parity checks --- src/toolbar_gtk/view/top_bar/tests.rs | 522 ++++++++++++++++---------- 1 file changed, 330 insertions(+), 192 deletions(-) diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 495cd22a..da6652da 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -1388,8 +1388,6 @@ fn expected_style_pill_nodes( #[test] fn style_pill_spec_matches_builtin_tree_across_morph_states() { - use crate::backend::wayland::TopToolbarWidgetKind as W; - let state = make_test_input_state(); let regular = ToolbarSnapshot::from_input_with_bindings( &state, @@ -1422,208 +1420,348 @@ fn style_pill_spec_matches_builtin_tree_across_morph_states() { ("minimized", minimized), ("micro", micro), ] { - let plan = plan_top_strip(&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); + assert_builtin_style_pill_scenario(name, &snapshot); + } +} - assert_eq!( - tree.node_by_id(&"top.island.style".into()).is_some(), - !expected.is_empty(), - "{name}: the pill card exists exactly when the spec has controls" - ); +fn assert_builtin_style_pill_scenario(name: &str, snapshot: &ToolbarSnapshot) { + let plan = plan_top_strip(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 actual: Vec<_> = tree - .nodes() + assert_eq!( + tree.node_by_id(&"top.island.style".into()).is_some(), + !expected.is_empty(), + "{name}: the pill card exists exactly when the spec has controls" + ); + + let actual: Vec<_> = tree + .nodes() + .iter() + .filter(|node| node.id.as_str().starts_with("top.style.")) + .collect(); + assert_eq!( + actual + .iter() + .map(|node| node.id.as_str().to_string()) + .collect::>(), + expected .iter() - .filter(|node| node.id.as_str().starts_with("top.style.")) - .collect(); + .map(|(id, _)| id.clone()) + .collect::>(), + "{name}: builtin pill node order" + ); + + for (node, (id, expectation)) in actual.iter().zip(&expected) { + assert_builtin_style_pill_node( + name, + snapshot, + &node.kind, + node.interact.as_ref().map(|interaction| &interaction.event), + node.interact + .as_ref() + .and_then(|interaction| interaction.tooltip.as_deref()), + node.interact.is_some(), + id, + expectation, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_node( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + interaction_tooltip: Option<&str>, + has_interaction: bool, + id: &str, + expectation: &StylePillNodeExpectation, +) { + match expectation { + StylePillNodeExpectation::Control(control) => assert_builtin_style_pill_control( + name, + snapshot, + kind, + interaction_event, + interaction_tooltip, + has_interaction, + id, + *control, + ), + StylePillNodeExpectation::Readout(control) => { + assert_builtin_style_pill_readout(name, snapshot, kind, has_interaction, id, *control) + } + StylePillNodeExpectation::StepHalf(control, index) => assert_builtin_style_pill_step_half( + name, + snapshot, + kind, + interaction_event, + interaction_tooltip, + id, + *control, + *index, + ), + StylePillNodeExpectation::StepValue(control) => assert_builtin_style_pill_step_value( + name, + snapshot, + kind, + has_interaction, + id, + *control, + ), + StylePillNodeExpectation::SegmentHalf(control, index) => { + assert_builtin_style_pill_segment_half( + name, + snapshot, + kind, + interaction_event, + interaction_tooltip, + id, + *control, + *index, + ) + } + } +} + +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_control( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + interaction_tooltip: Option<&str>, + has_interaction: bool, + id: &str, + control: model::StylePillControl, +) { + let expected_event = control + .enabled(snapshot) + .then(|| control.event(snapshot)) + .flatten(); + assert_eq!( + interaction_event, + expected_event.as_ref(), + "{name}: {id} event" + ); + if has_interaction { assert_eq!( - actual - .iter() - .map(|node| node.id.as_str().to_string()) - .collect::>(), - expected - .iter() - .map(|(id, _)| id.clone()) - .collect::>(), - "{name}: builtin pill node order" + interaction_tooltip, + control.tooltip(snapshot).as_deref(), + "{name}: {id} tooltip" ); + } + assert_builtin_style_pill_control_kind( + name, + snapshot, + kind, + interaction_event, + has_interaction, + id, + control, + ); +} - for (node, (id, expectation)) in actual.iter().zip(&expected) { - let interaction_event = node.interact.as_ref().map(|interaction| &interaction.event); - let interaction_tooltip = node - .interact - .as_ref() - .and_then(|interaction| interaction.tooltip.clone()); - match expectation { - StylePillNodeExpectation::Control(control) => { - let expected_event = control - .enabled(&snapshot) - .then(|| control.event(&snapshot)) - .flatten(); - assert_eq!( - interaction_event, - expected_event.as_ref(), - "{name}: {id} event" - ); - if node.interact.is_some() { - assert_eq!( - interaction_tooltip, - control.tooltip(&snapshot), - "{name}: {id} tooltip" - ); - } - match (control.role(), &node.kind) { - (model::StylePillRole::Swatch, W::Swatch { color, selected }) => { - let expected_color = match control { - model::StylePillControl::QuickSwatch(index) => { - snapshot.quick_colors.rendered_entries()[*index].color - } - _ => snapshot.color, - }; - assert_eq!( - *color, - ( - expected_color.r, - expected_color.g, - expected_color.b, - expected_color.a - ), - "{name}: {id} color" - ); - assert_eq!(*selected, control.active(&snapshot), "{name}: {id}"); - } - (model::StylePillRole::Slider, W::Slider { t }) => { - let (spec, value) = control.slider(&snapshot).expect("slider spec"); - assert!( - (*t - spec.t_from_value(value)).abs() < 1e-9, - "{name}: {id} slider position" - ); - } - (model::StylePillRole::Value, W::TextButton { label, .. }) => { - assert_eq!( - Some(label.text.clone()), - control.value_text(&snapshot), - "{name}: {id} live numeral" - ); - // Covered by the generic event assertion above: - // the numeral opens the precise-entry popup. - assert!( - matches!( - interaction_event, - Some(crate::ui::toolbar::ToolbarEvent::OpenPrecisionEntry(_)) - ), - "{name}: {id} opens the precise entry" - ); - } - (model::StylePillRole::Toggle, W::MiniCheckbox { checked, label }) => { - assert_eq!(*checked, control.active(&snapshot), "{name}: {id}"); - assert_eq!( - label.text, - control.label(&snapshot).as_ref(), - "{name}: {id} label" - ); - } - (model::StylePillRole::Button, W::TextButton { label, style }) => { - // Cycle buttons show the live value they step; - // plain buttons show their label. - let expected_text = match control { - model::StylePillControl::SelectionCycle(_) - | model::StylePillControl::ArrowStyleCycle => { - control.value_text(&snapshot).expect("cycle value text") - } - _ => control.label(&snapshot).into_owned(), - }; - assert_eq!(label.text, expected_text, "{name}: {id} text"); - assert_eq!( - style.disabled, - !control.enabled(&snapshot), - "{name}: {id} disabled style" - ); - } - ( - model::StylePillRole::Segmented, - W::SegmentedControl { - left, - right, - active_right, - }, - ) => { - let segments = control.segments(&snapshot).expect("segments"); - assert_eq!(left.text, segments[0].label, "{name}: {id}"); - assert_eq!(right.text, segments[1].label, "{name}: {id}"); - assert_eq!(*active_right, segments[1].active, "{name}: {id}"); - assert!(node.interact.is_none(), "halves carry the interactions"); - } - (role, kind) => panic!("{name}: {id} role {role:?} painted as {kind:?}"), - } - } - StylePillNodeExpectation::Readout(control) => { - assert!(node.interact.is_none(), "{name}: {id} readout is decor"); - match &node.kind { - W::Label(label) => assert_eq!( - Some(label.text.clone()), - control.value_text(&snapshot), - "{name}: {id} readout" - ), - other => panic!("{name}: {id} readout kind {other:?}"), - } - } - StylePillNodeExpectation::StepHalf(control, index) => { - let steps = control.steps(&snapshot).expect("stepper halves"); - let step = &steps[*index]; - let enabled = control.enabled(&snapshot); - match &node.kind { - W::TextButton { label, style } => { - assert_eq!(label.text, step.label, "{name}: {id} step label"); - assert_eq!(style.disabled, !enabled, "{name}: {id} step style"); - } - other => panic!("{name}: {id} step kind {other:?}"), - } - assert_eq!( - interaction_event, - enabled.then_some(&step.event), - "{name}: {id} step event" - ); - assert_eq!( - interaction_tooltip.as_deref(), - enabled.then_some(step.tooltip.as_str()), - "{name}: {id} step tooltip" - ); - } - StylePillNodeExpectation::StepValue(control) => { - assert!(node.interact.is_none(), "{name}: {id} readout is decor"); - match &node.kind { - W::Label(label) => assert_eq!( - Some(label.text.clone()), - control.value_text(&snapshot), - "{name}: {id} stepper readout" - ), - other => panic!("{name}: {id} stepper readout kind {other:?}"), - } +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_control_kind( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + has_interaction: bool, + id: &str, + control: model::StylePillControl, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + match (control.role(), kind) { + (model::StylePillRole::Swatch, W::Swatch { color, selected }) => { + let expected_color = match control { + model::StylePillControl::QuickSwatch(index) => { + snapshot.quick_colors.rendered_entries()[index].color } - StylePillNodeExpectation::SegmentHalf(control, index) => { - let segments = control.segments(&snapshot).expect("segments"); - let segment = &segments[*index]; - assert!(matches!(node.kind, W::HitArea), "{name}: {id}"); - assert_eq!( - interaction_event, - Some(&segment.event), - "{name}: {id} segment event" - ); - assert_eq!( - interaction_tooltip.as_deref(), - Some(segment.tooltip.as_str()), - "{name}: {id} segment tooltip" - ); + _ => snapshot.color, + }; + assert_eq!( + *color, + ( + expected_color.r, + expected_color.g, + expected_color.b, + expected_color.a + ), + "{name}: {id} color" + ); + assert_eq!(*selected, control.active(snapshot), "{name}: {id}"); + } + (model::StylePillRole::Slider, W::Slider { t }) => { + let (spec, value) = control.slider(snapshot).expect("slider spec"); + assert!( + (*t - spec.t_from_value(value)).abs() < 1e-9, + "{name}: {id} slider position" + ); + } + (model::StylePillRole::Value, W::TextButton { label, .. }) => { + assert_eq!( + Some(label.text.clone()), + control.value_text(snapshot), + "{name}: {id} live numeral" + ); + assert!( + matches!(interaction_event, Some(ToolbarEvent::OpenPrecisionEntry(_))), + "{name}: {id} opens the precise entry" + ); + } + (model::StylePillRole::Toggle, W::MiniCheckbox { checked, label }) => { + assert_eq!(*checked, control.active(snapshot), "{name}: {id}"); + assert_eq!( + label.text, + control.label(snapshot).as_ref(), + "{name}: {id} label" + ); + } + (model::StylePillRole::Button, W::TextButton { label, style }) => { + let expected_text = match control { + model::StylePillControl::SelectionCycle(_) + | model::StylePillControl::ArrowStyleCycle => { + control.value_text(snapshot).expect("cycle value text") } - } + _ => control.label(snapshot).into_owned(), + }; + assert_eq!(label.text, expected_text, "{name}: {id} text"); + assert_eq!( + style.disabled, + !control.enabled(snapshot), + "{name}: {id} disabled style" + ); } + ( + model::StylePillRole::Segmented, + W::SegmentedControl { + left, + right, + active_right, + }, + ) => { + let segments = control.segments(snapshot).expect("segments"); + assert_eq!(left.text, segments[0].label, "{name}: {id}"); + assert_eq!(right.text, segments[1].label, "{name}: {id}"); + assert_eq!(*active_right, segments[1].active, "{name}: {id}"); + assert!(!has_interaction, "halves carry the interactions"); + } + (role, kind) => panic!("{name}: {id} role {role:?} painted as {kind:?}"), } } +fn assert_builtin_style_pill_readout( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + has_interaction: bool, + id: &str, + control: model::StylePillControl, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + assert!(!has_interaction, "{name}: {id} readout is decor"); + match kind { + W::Label(label) => assert_eq!( + Some(label.text.clone()), + control.value_text(snapshot), + "{name}: {id} readout" + ), + other => panic!("{name}: {id} readout kind {other:?}"), + } +} + +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_step_half( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + interaction_tooltip: Option<&str>, + id: &str, + control: model::StylePillControl, + index: usize, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + let steps = control.steps(snapshot).expect("stepper halves"); + let step = &steps[index]; + let enabled = control.enabled(snapshot); + match kind { + W::TextButton { label, style } => { + assert_eq!(label.text, step.label, "{name}: {id} step label"); + assert_eq!(style.disabled, !enabled, "{name}: {id} step style"); + } + other => panic!("{name}: {id} step kind {other:?}"), + } + assert_eq!( + interaction_event, + enabled.then_some(&step.event), + "{name}: {id} step event" + ); + assert_eq!( + interaction_tooltip, + enabled.then_some(step.tooltip.as_str()), + "{name}: {id} step tooltip" + ); +} + +fn assert_builtin_style_pill_step_value( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + has_interaction: bool, + id: &str, + control: model::StylePillControl, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + assert!(!has_interaction, "{name}: {id} readout is decor"); + match kind { + W::Label(label) => assert_eq!( + Some(label.text.clone()), + control.value_text(snapshot), + "{name}: {id} stepper readout" + ), + other => panic!("{name}: {id} stepper readout kind {other:?}"), + } +} + +#[allow(clippy::too_many_arguments)] +fn assert_builtin_style_pill_segment_half( + name: &str, + snapshot: &ToolbarSnapshot, + kind: &crate::backend::wayland::TopToolbarWidgetKind, + interaction_event: Option<&ToolbarEvent>, + interaction_tooltip: Option<&str>, + id: &str, + control: model::StylePillControl, + index: usize, +) { + use crate::backend::wayland::TopToolbarWidgetKind as W; + + let segments = control.segments(snapshot).expect("segments"); + let segment = &segments[index]; + assert!(matches!(kind, W::HitArea), "{name}: {id}"); + assert_eq!( + interaction_event, + Some(&segment.event), + "{name}: {id} segment event" + ); + assert_eq!( + interaction_tooltip, + Some(segment.tooltip.as_str()), + "{name}: {id} segment tooltip" + ); +} + #[test] fn top_structure_rebuilds_when_the_style_pill_morphs() { let state = make_test_input_state(); From d48b0ba9c9ba48dc284ca1e490d8cfebb344c3a6 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:31:49 +0200 Subject: [PATCH 52/53] refactor(test): split GTK contract scenarios --- src/toolbar_gtk/view/top_bar/tests.rs | 1114 +++++++++++++------------ 1 file changed, 572 insertions(+), 542 deletions(-) diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index da6652da..c5a21df5 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -1787,33 +1787,32 @@ fn top_structure_rebuilds_when_the_style_pill_morphs() { assert!(pen_key == thicker_key, "value churn must not rebuild"); } -#[test] -fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { - const CHILD_ENV: &str = "WAYSCRIBER_GTK_WIDGET_CONTRACT_CHILD"; - const TEST_NAME: &str = "toolbar_gtk::view::top_bar::tests::actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window"; - - if std::env::var_os(CHILD_ENV).is_none() { - let status = std::process::Command::new(std::env::current_exe().expect("test binary")) - .arg(TEST_NAME) - .arg("--exact") - .arg("--test-threads=1") - .env(CHILD_ENV, "1") - .status() - .expect("run isolated GTK widget contract test"); - assert!(status.success(), "isolated GTK widget contract test failed"); - return; +fn find_widget_named(root: >k4::Widget, name: &str) -> Option { + if root.widget_name() == name { + return Some(root.clone()); + } + let mut child = root.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + if let Some(found) = find_widget_named(¤t, name) { + return Some(found); + } } + None +} - if let Err(error) = gtk4::init() { - eprintln!("skipping GTK widget contract test: {error}"); - return; +fn collect_descendants>(root: >k4::Widget, out: &mut Vec) { + if let Ok(widget) = root.clone().downcast::() { + out.push(widget); } + let mut child = root.first_child(); + while let Some(current) = child { + child = current.next_sibling(); + collect_descendants(¤t, out); + } +} - // The size budgets below are in spec pixels, so the widgets must be - // measured with the shipped metrics. Production installs this stylesheet - // in `Windows::new`; without it — and without a neutral font DPI — GTK - // sizes everything from the tester's desktop theme, and a text-scaling - // factor alone inflates the rows past the budgets' headroom. +fn install_gtk_contract_metrics() { let css_provider = gtk4::CssProvider::new(); css_provider.load_from_string(&crate::toolbar_gtk::css::stylesheet(1.0)); if let Some(display) = gtk4::gdk::Display::default() { @@ -1827,7 +1826,13 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { settings.set_gtk_font_name(Some("Sans 11")); settings.set_gtk_xft_dpi(96 * 1024); } +} +fn gtk_widget_contract_scenarios() -> ( + ToolbarSnapshot, + ToolbarSnapshot, + Vec<(&'static str, ToolbarSnapshot)>, +) { let state = make_test_input_state(); let regular = ToolbarSnapshot::from_input_with_bindings( &state, @@ -1845,7 +1850,6 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { highlighted.highlight_tool_active = true; let mut narrow = regular.clone(); narrow.top_viewport_max = Some(520.0); - let mut scenarios = vec![ ("regular", regular.clone()), ("simple", simple), @@ -1855,8 +1859,6 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { ("highlighted", highlighted.clone()), ("narrow", narrow), ]; - // One scenario per style-pill morph state, so every pill shape passes - // the full order/island/semantics contract. scenarios.extend( [ ("marker-tool", Tool::Marker), @@ -1871,7 +1873,6 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { let mut text_mode = style_pill_tool_snapshot(®ular, Tool::Pen); text_mode.text_active = true; scenarios.push(("text-mode", text_mode.clone())); - // A family name far wider than the font button's planned slot. let mut long_font = text_mode; long_font.font = crate::draw::FontDescriptor::new( "Noto Sans Mono CJK JP ExtraCondensed Black".to_string(), @@ -1880,92 +1881,122 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { ); scenarios.push(("long-font-name", long_font)); scenarios.push(("selection", style_pill_selection_snapshot(®ular))); + (regular, highlighted, scenarios) +} - // Font-button widths by scenario, checked against each other after the - // loop: the slot is theme-dependent, but it must not depend on the name. - let mut font_button_widths: std::collections::BTreeMap<&str, i32> = - std::collections::BTreeMap::new(); - +fn assert_gtk_widget_scenarios( + scenarios: Vec<(&'static str, ToolbarSnapshot)>, +) -> std::collections::BTreeMap<&'static str, i32> { + let mut widths = std::collections::BTreeMap::new(); for (name, snapshot) in scenarios { - let plan = plan_top_strip(&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); - let (tx, _rx) = std::sync::mpsc::channel(); - let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); - if snapshot.top_minimized { - top.build_minimized(&snapshot, &plan); - } else if snapshot.top_micro_active() { - top.build_micro(&snapshot, &plan); - } else { - top.build_strip(&snapshot, &plan); - } - for updater in top.updaters.borrow().iter() { - updater(&snapshot); - } + assert_gtk_widget_scenario(name, &snapshot, &mut widths); + } + widths +} - let widgets = collect_semantic_widgets(top.root.upcast_ref()); +fn build_contract_top(snapshot: &ToolbarSnapshot, plan: &TopStripPlan) -> TopBar { + let (tx, _rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + if snapshot.top_minimized { + top.build_minimized(snapshot, plan); + } else if snapshot.top_micro_active() { + top.build_micro(snapshot, plan); + } else { + top.build_strip(snapshot, plan); + } + for updater in top.updaters.borrow().iter() { + updater(snapshot); + } + top +} + +fn assert_gtk_widget_scenario( + name: &'static str, + snapshot: &ToolbarSnapshot, + widths: &mut std::collections::BTreeMap<&'static str, i32>, +) { + let plan = plan_top_strip(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); + let mut top = build_contract_top(snapshot, &plan); + let widgets = collect_semantic_widgets(top.root.upcast_ref()); + assert_eq!( + widgets + .iter() + .map(|widget| widget.widget_name().to_string()) + .collect::>(), + expected_main_widget_ids(&spec, snapshot, &plan), + "{name} GTK widget order" + ); + assert_scenario_widget_islands(name, snapshot, &spec, &plan, &widgets); + assert_scenario_widget_semantics(name, snapshot, &expected, &style_controls, widgets, widths); + detach_test_popovers(&mut top); +} + +fn assert_scenario_widget_islands( + name: &str, + snapshot: &ToolbarSnapshot, + spec: &model::TopToolbarSpec, + plan: &TopStripPlan, + widgets: &[gtk4::Widget], +) { + let expected_islands = expected_island_keys(snapshot, spec, plan); + for widget in widgets { + let id = widget.widget_name().to_string(); + let expected_island = expected_islands + .get(&id) + .unwrap_or_else(|| panic!("{name}: no island expectation for {id}")); assert_eq!( - widgets - .iter() - .map(|widget| widget.widget_name().to_string()) - .collect::>(), - expected_main_widget_ids(&spec, &snapshot, &plan), - "{name} GTK widget order" + nearest_island_key(widget).as_deref(), + *expected_island, + "{name}: {id} island membership" ); - // Ordered ids alone cannot catch a control appended to the wrong - // pill: also assert every widget's ancestor chain reaches the - // island container the shared spec assigns it. - let expected_islands = expected_island_keys(&snapshot, &spec, &plan); - for widget in &widgets { - let id = widget.widget_name().to_string(); - let expected_island = expected_islands - .get(&id) - .unwrap_or_else(|| panic!("{name}: no island expectation for {id}")); - assert_eq!( - nearest_island_key(widget).as_deref(), - *expected_island, - "{name}: {id} island membership" - ); - } - for widget in widgets { - let id = widget.widget_name(); - if let Some((_, control)) = style_controls - .iter() - .find(|(control_id, _)| *control_id == id) - { - assert_gtk_style_widget(&widget, *control, &snapshot); - if let Some(width) = font_button_natural_width(&widget, *control) { - font_button_widths.insert(name, width); - } - continue; + } +} + +fn assert_scenario_widget_semantics( + name: &'static str, + snapshot: &ToolbarSnapshot, + expected: &[SemanticAdapterRecord], + style_controls: &[(String, model::StylePillControl)], + widgets: Vec, + widths: &mut std::collections::BTreeMap<&'static str, i32>, +) { + for widget in widgets { + let id = widget.widget_name(); + if let Some((_, control)) = style_controls + .iter() + .find(|(control_id, _)| *control_id == id) + { + assert_gtk_style_widget(&widget, *control, snapshot); + if let Some(width) = font_button_natural_width(&widget, *control) { + widths.insert(name, width); } - let Some(control) = expected.iter().find_map(|record| match record { - SemanticAdapterRecord::Control(control) if control.id == id => Some(control), - _ => None, - }) else { - assert!( - expected.iter().any( - |record| matches!(record, SemanticAdapterRecord::Divider(divider) if *divider == id) - ), - "{name}: unexpected GTK widget {id}" - ); - continue; - }; - assert_gtk_control_widget(&widget, control); + continue; } - detach_test_popovers(&mut top); + let Some(control) = expected.iter().find_map(|record| match record { + SemanticAdapterRecord::Control(control) if control.id == id => Some(control), + _ => None, + }) else { + assert!( + expected.iter().any( + |record| matches!(record, SemanticAdapterRecord::Divider(divider) if *divider == id) + ), + "{name}: unexpected GTK widget {id}" + ); + continue; + }; + assert_gtk_control_widget(&widget, control); } +} - // The font button's width must come from the layout plan, not from the - // family it happens to name. Shortening the string by character count is - // not enough on its own: a display face draws twelve wide characters wider - // than twelve narrow ones. - let short = font_button_widths +fn assert_font_button_width_stable(widths: &std::collections::BTreeMap<&str, i32>) { + let short = widths .get("text-mode") .copied() .expect("the text-mode pill has a font button"); - let long = font_button_widths + let long = widths .get("long-font-name") .copied() .expect("the long-font-name pill has a font button"); @@ -1973,27 +2004,20 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { long, short, "the font button grew from {short}px to {long}px for a longer family name" ); +} - // Compact plans normally drop quick colors before reaching the last - // degradation step. Keep a direct adapter case so the presentation - // contract cannot silently diverge if that planner policy changes. - // Colors left the strip (M7-C1) and the presets island yields under the - // compact plan (M7-C2): assert neither renders in a compact build. +fn assert_compact_gtk_widget_contract(regular: &ToolbarSnapshot) { let mut compact_plan = TopStripPlan::unconstrained(); compact_plan.compact = true; let (tx, _rx) = std::sync::mpsc::channel(); let mut compact_top = TopBar::new_for_test(FeedbackSender::new(tx)); - compact_top.build_strip(®ular, &compact_plan); - let compact_widgets = collect_semantic_widgets(compact_top.root.upcast_ref()); - let compact_ids = compact_widgets + compact_top.build_strip(regular, &compact_plan); + let compact_ids = collect_semantic_widgets(compact_top.root.upcast_ref()) .iter() .map(|widget| widget.widget_name().to_string()) .collect::>(); - // Positive presence first, so the absence check below can never pass - // vacuously on an empty strip: the compact build must materialize exactly - // the shared spec's protected widget set (tools/history/chrome). - let compact_spec = super::strip::top_toolbar_spec(®ular, &compact_plan); - let expected_compact_ids = expected_main_widget_ids(&compact_spec, ®ular, &compact_plan); + let compact_spec = super::strip::top_toolbar_spec(regular, &compact_plan); + let expected_compact_ids = expected_main_widget_ids(&compact_spec, regular, &compact_plan); assert!( !expected_compact_ids.is_empty(), "the compact strip still builds its protected core" @@ -2002,7 +2026,6 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { compact_ids, expected_compact_ids, "the compact strip builds exactly the shared spec's widget set" ); - // Then the contract of this case: no colors or presets survive compaction. assert!( compact_ids.iter().all(|name| { !name.starts_with("top.quick-color.") @@ -2012,30 +2035,31 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { "the compact strip carries no colors or presets: {compact_ids:?}" ); detach_test_popovers(&mut compact_top); +} +fn assert_shapes_and_overflow_contract(regular: &ToolbarSnapshot) { + assert_shapes_popover_contract(regular); + assert_overflow_popover_contract(regular); +} + +fn assert_shapes_popover_contract(regular: &ToolbarSnapshot) { let mut shapes = regular.clone(); shapes.shape_picker_open = true; shapes.active_tool = Tool::RegularPolygon; - let (tx, shape_rx) = std::sync::mpsc::channel(); - let shape_top = TopBar::new_for_test(FeedbackSender::new(tx)); - let shape_content = shape_top.build_shapes_popover_content( - &shapes, - (ICON_BUTTON, ICON_BUTTON), - ICON_SIZE, - true, - 1.0, - ); + let (tx, rx) = std::sync::mpsc::channel(); + let top = TopBar::new_for_test(FeedbackSender::new(tx)); + let content = + top.build_shapes_popover_content(&shapes, (ICON_BUTTON, ICON_BUTTON), ICON_SIZE, true, 1.0); assert!( - has_capture_phase_click_gesture(shape_content.upcast_ref()), - "the shapes popover must capture click modifiers, or Ctrl+Shift+click \ - cannot rebind any tool inside the picker" + has_capture_phase_click_gesture(content.upcast_ref()), + "the shapes popover must capture click modifiers" ); - let shape_tools = model::visible_shape_picker_rows(&shapes, false) + let tools = model::visible_shape_picker_rows(&shapes, false) .into_iter() .flatten() .filter(|tool| model::tool_visible(&shapes, *tool)) .collect::>(); - let mut expected_shape_ids = shape_tools + let mut expected_ids = tools .iter() .map(|tool| { format!( @@ -2045,26 +2069,22 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { }) .collect::>(); if model::top_fill_visible(&shapes) { - expected_shape_ids.push( - crate::config::toolbar_item_ids::TOP_UTILITY_FILL - .as_str() - .to_string(), - ); + expected_ids.push(ids::TOP_UTILITY_FILL.as_str().to_string()); } - expected_shape_ids.extend([ + expected_ids.extend([ "top.options.sides-minus".to_string(), "top.options.sides-plus".to_string(), ]); - let shape_widgets = collect_semantic_widgets(shape_content.upcast_ref()); + let widgets = collect_semantic_widgets(content.upcast_ref()); assert_eq!( - shape_widgets + widgets .iter() .map(|widget| widget.widget_name().to_string()) .collect::>(), - expected_shape_ids, + expected_ids, "GTK shapes-popover order" ); - for (widget, tool) in shape_widgets.iter().zip(&shape_tools) { + for (widget, tool) in widgets.iter().zip(&tools) { let expected = control_record( &shapes, SemanticLane::Strip, @@ -2073,24 +2093,23 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { ); assert_gtk_control_widget(widget, &expected); } - let first_shape = first_control_surface(&shape_widgets[0]) + first_control_surface(&widgets[0]) .downcast::() - .expect("shape-picker tool button"); - first_shape.emit_clicked(); + .expect("shape-picker tool button") + .emit_clicked(); assert_eq!( - shape_rx - .recv_timeout(Duration::from_secs(1)) + rx.recv_timeout(Duration::from_secs(1)) .expect("GTK shape event"), GtkToolbarFeedback::Event { - event: ToolbarEvent::SelectTool(shape_tools[0]), + event: ToolbarEvent::SelectTool(tools[0]), rebind_requested: false, } ); - let mut line_shapes = shapes.clone(); + let mut line_shapes = shapes; line_shapes.active_tool = Tool::Line; line_shapes.tool_override = None; - let line_shape_content = shape_top.build_shapes_popover_content( + let line_content = top.build_shapes_popover_content( &line_shapes, (ICON_BUTTON, ICON_BUTTON), ICON_SIZE, @@ -2098,178 +2117,182 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { 1.0, ); assert!( - collect_semantic_widgets(line_shape_content.upcast_ref()) + collect_semantic_widgets(line_content.upcast_ref()) .iter() - .any(|widget| { - widget.widget_name() == crate::config::toolbar_item_ids::TOP_UTILITY_FILL.as_str() - }), + .any(|widget| widget.widget_name() == ids::TOP_UTILITY_FILL.as_str()), "GTK Shapes must expose Fill before a fill-capable shape is selected" ); +} - let mut overflow_plan = TopStripPlan::unconstrained(); - overflow_plan.dropped_tools = vec![Tool::Line, Tool::Arrow]; - overflow_plan.dropped_utilities = vec![ +fn assert_overflow_popover_contract(regular: &ToolbarSnapshot) { + let (tx, rx) = std::sync::mpsc::channel(); + let top = TopBar::new_for_test(FeedbackSender::new(tx)); + let mut plan = TopStripPlan::unconstrained(); + plan.dropped_tools = vec![Tool::Line, Tool::Arrow]; + plan.dropped_utilities = vec![ model::TopUtilityButton::Screenshot, model::TopUtilityButton::Highlight, ]; - let overflow_spec = super::strip::top_toolbar_spec(®ular, &overflow_plan); - let overflow_content = shape_top.build_overflow_popover_content( - ®ular, - &overflow_spec, + let spec = super::strip::top_toolbar_spec(regular, &plan); + let content = top.build_overflow_popover_content( + regular, + &spec, (ICON_BUTTON, ICON_BUTTON), ICON_SIZE, true, 1.0, ); - let overflow_widgets = collect_semantic_widgets(overflow_content.upcast_ref()); + let widgets = collect_semantic_widgets(content.upcast_ref()); assert_eq!( - overflow_widgets + widgets .iter() .map(|widget| widget.widget_name().to_string()) .collect::>(), - overflow_spec - .overflow() + spec.overflow() .iter() .map(|control| format!("top.overflow.{}", control.id().render_id())) .collect::>(), "GTK overflow order" ); - for (widget, control) in overflow_widgets.iter().zip(overflow_spec.overflow()) { - let expected = control_record(®ular, SemanticLane::Overflow, *control, true); + for (widget, control) in widgets.iter().zip(spec.overflow()) { + let expected = control_record(regular, SemanticLane::Overflow, *control, true); assert_gtk_control_widget(widget, &expected); } - let first_overflow = first_control_surface(&overflow_widgets[0]) + first_control_surface(&widgets[0]) .downcast::() - .expect("overflow tool button"); - first_overflow.emit_clicked(); + .expect("overflow tool button") + .emit_clicked(); assert_eq!( - shape_rx - .recv_timeout(Duration::from_secs(1)) + rx.recv_timeout(Duration::from_secs(1)) .expect("GTK overflow event"), GtkToolbarFeedback::Event { - event: overflow_spec.overflow()[0].event(®ular), + event: spec.overflow()[0].event(regular), rebind_requested: false, } ); +} +fn assert_gtk_toggle_events(regular: &ToolbarSnapshot, highlighted: &ToolbarSnapshot) { let (tx, rx) = std::sync::mpsc::channel(); - let mut event_top = TopBar::new_for_test(FeedbackSender::new(tx)); - let shape = event_top.shapes_picker_button( - ®ular, + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + let shape = top.shapes_picker_button( + regular, model::TopToolbarControl::ShapePicker, (ICON_BUTTON, ICON_BUTTON), ICON_SIZE, true, ); - let highlight = event_top.action_button( - ®ular, + let highlight = top.action_button( + regular, model::TopToolbarControl::Utility(model::TopToolbarUtility::Highlight), (ICON_BUTTON, ICON_BUTTON), ICON_SIZE, true, true, ); - let pin = event_top.pin_button(®ular, model::TopToolbarControl::Pin, PIN_BUTTON_SIZE); - let overflow = event_top.overflow_button( - ®ular, + let pin = top.pin_button(regular, model::TopToolbarControl::Pin, PIN_BUTTON_SIZE); + let overflow = top.overflow_button( + regular, model::TopToolbarControl::Overflow, (ICON_BUTTON, ICON_BUTTON), ICON_SIZE, ); + assert_test_popover_capture_surfaces(&top); + assert_gtk_button_events( + &rx, + [ + ( + &shape, + ToolbarEvent::ToggleShapePicker(!regular.shape_picker_open), + ), + ( + &highlight, + ToolbarEvent::ToggleAllHighlight(!regular.any_highlight_active), + ), + (&pin, ToolbarEvent::PinTopToolbar(!regular.top_pinned)), + ( + &overflow, + ToolbarEvent::ToggleTopOverflow(!regular.top_overflow_open), + ), + ], + ); + + let mut active = regular.clone(); + active.shape_picker_open = true; + active.any_highlight_active = true; + active.top_pinned = true; + active.top_overflow_open = true; + top.shapes_expected_open.set(true); + top.overflow_expected_open.set(true); + for updater in top.updaters.borrow().iter() { + updater(&active); + } + assert_gtk_button_events( + &rx, + [ + (&shape, ToolbarEvent::ToggleShapePicker(false)), + (&highlight, ToolbarEvent::ToggleAllHighlight(false)), + (&pin, ToolbarEvent::PinTopToolbar(false)), + (&overflow, ToolbarEvent::ToggleTopOverflow(false)), + ], + ); + top.shapes_expected_open.set(false); + top.overflow_expected_open.set(false); + detach_test_popovers(&mut top); + assert_highlight_ring_event(&mut top, highlighted, &rx); +} + +fn assert_test_popover_capture_surfaces(top: &TopBar) { for (popover, capture_surface) in [ ( - event_top.shapes_popover.as_ref().unwrap(), - event_top.shapes_capture_surface.as_ref().unwrap(), + top.shapes_popover.as_ref().unwrap(), + top.shapes_capture_surface.as_ref().unwrap(), ), ( - event_top.overflow_popover.as_ref().unwrap(), - event_top.overflow_capture_surface.as_ref().unwrap(), + top.overflow_popover.as_ref().unwrap(), + top.overflow_capture_surface.as_ref().unwrap(), ), ] { let content = gtk4::Box::new(gtk4::Orientation::Vertical, 0); capture_surface.set_content(&content); super::popovers::set_popover_capture_transparent(popover, capture_surface, true, false); - assert!( - popover.has_css_class(crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS), - "capture suppression must clear native popover chrome" - ); - assert!( - !popover.can_target(), - "capture suppression must stop invisible popovers from accepting input immediately" - ); + assert!(popover.has_css_class(crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS)); + assert!(!popover.can_target()); assert_eq!(capture_surface.content_opacity(), Some(0.0)); assert!(capture_surface.proof_visible()); - super::popovers::set_popover_capture_transparent(popover, capture_surface, false, true); assert!(!popover.has_css_class(crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS)); assert!(popover.can_target()); assert_eq!(capture_surface.content_opacity(), Some(1.0)); assert!(!capture_surface.proof_visible()); } - for (button, expected) in [ - ( - &shape, - ToolbarEvent::ToggleShapePicker(!regular.shape_picker_open), - ), - ( - &highlight, - ToolbarEvent::ToggleAllHighlight(!regular.any_highlight_active), - ), - (&pin, ToolbarEvent::PinTopToolbar(!regular.top_pinned)), - ( - &overflow, - ToolbarEvent::ToggleTopOverflow(!regular.top_overflow_open), - ), - ] { - button.emit_clicked(); - assert_eq!( - rx.recv_timeout(Duration::from_secs(1)).expect("GTK event"), - GtkToolbarFeedback::Event { - event: expected, - rebind_requested: false, - } - ); - } +} - let mut active = regular.clone(); - active.shape_picker_open = true; - active.any_highlight_active = true; - active.top_pinned = true; - active.top_overflow_open = true; - event_top.shapes_expected_open.set(true); - event_top.overflow_expected_open.set(true); - for updater in event_top.updaters.borrow().iter() { - updater(&active); - } - for (button, expected) in [ - (&shape, ToolbarEvent::ToggleShapePicker(false)), - (&highlight, ToolbarEvent::ToggleAllHighlight(false)), - (&pin, ToolbarEvent::PinTopToolbar(false)), - (&overflow, ToolbarEvent::ToggleTopOverflow(false)), - ] { +fn assert_gtk_button_events( + rx: &std::sync::mpsc::Receiver, + cases: [(>k4::Button, ToolbarEvent); 4], +) { + for (button, event) in cases { button.emit_clicked(); assert_eq!( rx.recv_timeout(Duration::from_secs(1)).expect("GTK event"), GtkToolbarFeedback::Event { - event: expected, + event, rebind_requested: false, } ); } - event_top.shapes_expected_open.set(false); - event_top.overflow_expected_open.set(false); - - // The direct factories parent popovers to their buttons. Detach those - // before rebuilding the test bar, matching the production rebuild path. - detach_test_popovers(&mut event_top); +} - event_top.build_strip(&highlighted, &plan_top_strip(&highlighted)); - let ring = collect_semantic_widgets(event_top.root.upcast_ref()) +fn assert_highlight_ring_event( + top: &mut TopBar, + highlighted: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + top.build_strip(highlighted, &plan_top_strip(highlighted)); + let ring = collect_semantic_widgets(top.root.upcast_ref()) .into_iter() - .find(|widget| { - widget.widget_name() - == crate::config::toolbar_item_ids::TOP_UTILITY_HIGHLIGHT_RING.as_str() - }) + .find(|widget| widget.widget_name() == ids::TOP_UTILITY_HIGHLIGHT_RING.as_str()) .expect("GTK highlight-ring widget") .downcast::() .expect("highlight ring check button"); @@ -2282,32 +2305,29 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { rebind_requested: false, } ); - detach_test_popovers(&mut event_top); + detach_test_popovers(top); +} - // --- Style pill interactions -------------------------------------------- - fn find_widget_named(root: >k4::Widget, name: &str) -> Option { - if root.widget_name() == name { - return Some(root.clone()); - } - let mut child = root.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - if let Some(found) = find_widget_named(¤t, name) { - return Some(found); - } - } - None - } - let pill_widget = |top: &TopBar, id: &str| { - find_widget_named(top.root.upcast_ref(), id) - .unwrap_or_else(|| panic!("style pill widget {id}")) - }; +fn pill_widget(top: &TopBar, id: &str) -> gtk4::Widget { + find_widget_named(top.root.upcast_ref(), id).unwrap_or_else(|| panic!("style pill widget {id}")) +} - // Eraser morph: the Brush/Stroke segment emits SetEraserMode per half; - // the size numeral opens the overlay precise-entry popup. - let eraser = style_pill_tool_snapshot(®ular, Tool::Eraser); - event_top.build_strip(&eraser, &plan_top_strip(&eraser)); - let segment_row = pill_widget(&event_top, "top.style.eraser-mode"); +fn assert_style_pill_interactions(regular: &ToolbarSnapshot) { + let (tx, rx) = std::sync::mpsc::channel(); + let mut top = TopBar::new_for_test(FeedbackSender::new(tx)); + assert_eraser_pill_interactions(&mut top, regular, &rx); + assert_pen_pill_interactions(&mut top, regular, &rx); + assert_shape_pill_interaction(&mut top, regular, &rx); +} + +fn assert_eraser_pill_interactions( + top: &mut TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let eraser = style_pill_tool_snapshot(regular, Tool::Eraser); + top.build_strip(&eraser, &plan_top_strip(&eraser)); + let segment_row = pill_widget(top, "top.style.eraser-mode"); let mut halves = Vec::new(); let mut child = segment_row.first_child(); while let Some(current) = child { @@ -2333,10 +2353,10 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { } ); } - let numeral = pill_widget(&event_top, "top.style.thickness-value") + pill_widget(top, "top.style.thickness-value") .downcast::() - .expect("numeral button"); - numeral.emit_clicked(); + .expect("numeral button") + .emit_clicked(); assert_eq!( rx.recv_timeout(Duration::from_secs(1)) .expect("GTK numeral event"), @@ -2345,16 +2365,19 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { crate::ui::toolbar::PrecisionEntryTarget::Thickness ), rebind_requested: false, - }, - "the numeral opens the overlay precise-entry popup" + } ); - detach_test_popovers(&mut event_top); + detach_test_popovers(top); +} - // Stroke morph: the chip opens the one true picker popup, swatches set - // quick colors, and the updaters drive the live numeral and idle fade. - let pen = style_pill_tool_snapshot(®ular, Tool::Pen); - event_top.build_strip(&pen, &plan_top_strip(&pen)); - pill_widget(&event_top, "top.style.color-chip") +fn assert_pen_pill_interactions( + top: &mut TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let pen = style_pill_tool_snapshot(regular, Tool::Pen); + top.build_strip(&pen, &plan_top_strip(&pen)); + pill_widget(top, "top.style.color-chip") .downcast::() .expect("chip button") .emit_clicked(); @@ -2366,7 +2389,7 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { rebind_requested: false, } ); - let swatch = pill_widget(&event_top, "top.style.swatch.1") + let swatch = pill_widget(top, "top.style.swatch.1") .downcast::() .expect("swatch button"); swatch.emit_clicked(); @@ -2382,8 +2405,6 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { rebind_requested: false, } ); - // A GTK Button activates on the primary button only, so the recolor - // gesture is a controller of its own; it must target the same slot. emit_secondary_press(swatch.upcast_ref()); assert_eq!( rx.recv_timeout(Duration::from_secs(1)) @@ -2393,18 +2414,16 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { rebind_requested: false, } ); - // The chip is not a palette slot, so it has nothing to recolor. assert!( - secondary_click_gesture(pill_widget(&event_top, "top.style.color-chip").upcast_ref()) - .is_none() + secondary_click_gesture(pill_widget(top, "top.style.color-chip").upcast_ref()).is_none() ); - let mut churned = pen.clone(); + let mut churned = pen; churned.thickness += 3.0; churned.top_fade = 0.4; - for updater in event_top.updaters.borrow().iter() { + for updater in top.updaters.borrow().iter() { updater(&churned); } - let numeral = pill_widget(&event_top, "top.style.thickness-value") + let numeral = pill_widget(top, "top.style.thickness-value") .downcast::() .expect("numeral button"); assert_eq!( @@ -2412,21 +2431,23 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { Some(format!("{:.0}px", churned.thickness).as_str()), "the numeral tracks the live thickness" ); - let pill_box = find_widget_named(event_top.root.upcast_ref(), "island.style") - .expect("style pill container"); - assert!( - (pill_box.opacity() - 0.4).abs() < 1e-6, - "the pill fades with the strip" - ); - detach_test_popovers(&mut event_top); + let pill_box = + find_widget_named(top.root.upcast_ref(), "island.style").expect("style pill container"); + assert!((pill_box.opacity() - 0.4).abs() < 1e-6); + detach_test_popovers(top); +} - // Shape morph: the Fill mini-toggle emits the requested state. - let shape = style_pill_tool_snapshot(®ular, Tool::Rect); - event_top.build_strip(&shape, &plan_top_strip(&shape)); - let fill = pill_widget(&event_top, "top.style.fill") +fn assert_shape_pill_interaction( + top: &mut TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let shape = style_pill_tool_snapshot(regular, Tool::Rect); + top.build_strip(&shape, &plan_top_strip(&shape)); + pill_widget(top, "top.style.fill") .downcast::() - .expect("fill check button"); - fill.set_active(!shape.fill_enabled); + .expect("fill check button") + .set_active(!shape.fill_enabled); assert_eq!( rx.recv_timeout(Duration::from_secs(1)) .expect("GTK fill event"), @@ -2435,20 +2456,55 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { rebind_requested: false, } ); - detach_test_popovers(&mut event_top); + detach_test_popovers(top); +} - // --- Session/Settings popovers: the re-hosted pane content --------------- - fn collect_descendants>(root: >k4::Widget, out: &mut Vec) { - if let Ok(widget) = root.clone().downcast::() { - out.push(widget); - } - let mut child = root.first_child(); - while let Some(current) = child { - child = current.next_sibling(); - collect_descendants(¤t, out); - } +#[test] +fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { + const CHILD_ENV: &str = "WAYSCRIBER_GTK_WIDGET_CONTRACT_CHILD"; + const TEST_NAME: &str = "toolbar_gtk::view::top_bar::tests::actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window"; + + if std::env::var_os(CHILD_ENV).is_none() { + let status = std::process::Command::new(std::env::current_exe().expect("test binary")) + .arg(TEST_NAME) + .arg("--exact") + .arg("--test-threads=1") + .env(CHILD_ENV, "1") + .status() + .expect("run isolated GTK widget contract test"); + assert!(status.success(), "isolated GTK widget contract test failed"); + return; } + if let Err(error) = gtk4::init() { + eprintln!("skipping GTK widget contract test: {error}"); + return; + } + + install_gtk_contract_metrics(); + let (regular, highlighted, scenarios) = gtk_widget_contract_scenarios(); + let font_button_widths = assert_gtk_widget_scenarios(scenarios); + + assert_font_button_width_stable(&font_button_widths); + + // Compact plans normally drop quick colors before reaching the last + // degradation step. Keep a direct adapter case so the presentation + // contract cannot silently diverge if that planner policy changes. + // Colors left the strip (M7-C1) and the presets island yields under the + // compact plan (M7-C2): assert neither renders in a compact build. + assert_compact_gtk_widget_contract(®ular); + + assert_shapes_and_overflow_contract(®ular); + + assert_gtk_toggle_events(®ular, &highlighted); + + assert_style_pill_interactions(®ular); + + assert_menu_popover_contracts(®ular); +} + +fn assert_menu_popover_contracts(regular: &ToolbarSnapshot) { + // --- Session/Settings popovers: the re-hosted pane content --------------- let mut session_snapshot = regular.clone(); session_snapshot.session_popover_open = true; session_snapshot.active_session_name = Some("lecture.wayscriber-session".to_string()); @@ -2468,84 +2524,92 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { "settings popover exists" ); - let session_model = - model::ToolbarSessionModel::for_popover(&session_snapshot).expect("session model"); - let session_content = menu_top.build_session_popover_content(&session_snapshot, 1.0); - let session_panel = find_widget_named(&session_content, "top.menu.session.panel") - .expect("session popover panel box"); - let mut session_buttons: Vec = Vec::new(); - collect_descendants(&session_panel, &mut session_buttons); + assert_session_popover_contract(&menu_top, &session_snapshot, &menu_rx); + assert_settings_popover_contract(&menu_top, regular, &menu_rx); + assert_canvas_popover_contract(&menu_top, regular, &menu_rx); + + detach_test_popovers(&mut menu_top); +} + +fn assert_session_popover_contract( + top: &TopBar, + snapshot: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let model = model::ToolbarSessionModel::for_popover(snapshot).expect("session model"); + let content = top.build_session_popover_content(snapshot, 1.0); + let panel = + find_widget_named(&content, "top.menu.session.panel").expect("session popover panel box"); + let mut buttons: Vec = Vec::new(); + collect_descendants(&panel, &mut buttons); assert_eq!( - session_buttons.len(), - session_model.buttons.len() + session_model.recents.len(), + buttons.len(), + model.buttons.len() + model.recents.len(), "the popover exposes exactly the pane's controls" ); - for (button, button_model) in session_buttons.iter().zip(session_model.buttons.iter()) { - assert_eq!( - button.tooltip_text().as_deref(), - Some(button_model.label), - "session button tooltip" - ); + for (button, button_model) in buttons.iter().zip(model.buttons.iter()) { + assert_eq!(button.tooltip_text().as_deref(), Some(button_model.label)); assert_eq!(button.is_sensitive(), button_model.enabled); } - session_buttons[0].emit_clicked(); + buttons[0].emit_clicked(); assert_eq!( - menu_rx - .recv_timeout(Duration::from_secs(1)) + rx.recv_timeout(Duration::from_secs(1)) .expect("GTK session open event"), GtkToolbarFeedback::Event { - event: session_model.buttons[0].event.clone(), + event: model.buttons[0].event.clone(), rebind_requested: false, } ); - session_buttons - .last() - .expect("recent row button") - .emit_clicked(); + buttons.last().expect("recent row button").emit_clicked(); assert_eq!( - menu_rx - .recv_timeout(Duration::from_secs(1)) + rx.recv_timeout(Duration::from_secs(1)) .expect("GTK recent event"), GtkToolbarFeedback::Event { - event: session_model.recents[0].event(), + event: model.recents[0].event(), rebind_requested: false, } ); +} - let mut settings_snapshot = regular.clone(); - settings_snapshot.layout_mode = ToolbarLayoutMode::Advanced; - settings_snapshot.settings_popover_open = true; - settings_snapshot.runtime_ui_persistence = Some(RuntimeUiPersistenceSnapshot { +fn assert_settings_popover_contract( + top: &TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let mut snapshot = regular.clone(); + snapshot.layout_mode = ToolbarLayoutMode::Advanced; + snapshot.settings_popover_open = true; + snapshot.runtime_ui_persistence = Some(RuntimeUiPersistenceSnapshot { path: "/home/user/.local/share/wayscriber/runtime-ui.toml".into(), mode: RuntimeUiPersistenceMode::Supported, detail: None, recovery_artifacts: Vec::new(), }); - let settings_model = - model::ToolbarSettingsModel::for_popover(&settings_snapshot).expect("settings model"); - let (settings_content, settings_updaters) = - menu_top.build_settings_popover_content(&settings_snapshot, 1.0); - // Production parents popover content under the classed bar; measured - // standalone, the scoped stylesheet would never reach it. - settings_content.add_css_class("wayscriber-toolbar"); - let settings_scroller = settings_content + let model = model::ToolbarSettingsModel::for_popover(&snapshot).expect("settings model"); + let (content, updaters) = top.build_settings_popover_content(&snapshot, 1.0); + content.add_css_class("wayscriber-toolbar"); + let scroller = content .clone() .downcast::() .expect("settings popover scroll viewport"); - let settings_panel = find_widget_named(&settings_content, "top.menu.settings.panel") - .expect("settings popover panel box"); - let (_, settings_natural_height, _, _) = - settings_panel.measure(gtk4::Orientation::Vertical, -1); - assert!( - settings_natural_height <= settings_scroller.max_content_height(), - "Advanced settings runtime footer should fit without clipping: natural={settings_natural_height}, cap={}", - settings_scroller.max_content_height() - ); - - // Toggle parity: one check button per pane toggle, same order/state. + let panel = + find_widget_named(&content, "top.menu.settings.panel").expect("settings popover panel box"); + let (_, natural_height, _, _) = panel.measure(gtk4::Orientation::Vertical, -1); + assert!(natural_height <= scroller.max_content_height()); + assert_settings_toggle_contract(&mut snapshot, &model, &panel, &updaters, rx); + assert_settings_button_contract(&snapshot, &model, &panel, rx); +} + +fn assert_settings_toggle_contract( + snapshot: &mut ToolbarSnapshot, + model: &model::ToolbarSettingsModel, + panel: >k4::Widget, + updaters: &[Updater], + rx: &std::sync::mpsc::Receiver, +) { let mut checks: Vec = Vec::new(); - collect_descendants(&settings_panel, &mut checks); - let toggles: Vec<_> = settings_model.toggle_rows().into_iter().flatten().collect(); + collect_descendants(panel, &mut checks); + let toggles: Vec<_> = model.toggle_rows().into_iter().flatten().collect(); assert_eq!(checks.len(), toggles.len(), "settings toggle parity"); for (check, toggle) in checks.iter().zip(&toggles) { assert_eq!(check.label().as_deref(), Some(toggle.label.as_ref())); @@ -2553,245 +2617,211 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { } checks[0].set_active(!toggles[0].checked); assert_eq!( - menu_rx - .recv_timeout(Duration::from_secs(1)) + rx.recv_timeout(Duration::from_secs(1)) .expect("GTK settings toggle event"), GtkToolbarFeedback::Event { event: toggles[0].activation.clone(), rebind_requested: false, } ); - - // Backend echoes update the existing checkbox and replace its next event; - // the top popover must depend only on unified-top snapshot state. - let updated_context_aware = !toggles[0].checked; - settings_snapshot.context_aware_ui = updated_context_aware; - for updater in &settings_updaters { - updater(&settings_snapshot); + let updated = !toggles[0].checked; + snapshot.context_aware_ui = updated; + for updater in updaters { + updater(snapshot); } - assert_eq!(checks[0].is_active(), updated_context_aware); - checks[0].set_active(!updated_context_aware); + assert_eq!(checks[0].is_active(), updated); + checks[0].set_active(!updated); assert_eq!( - menu_rx - .recv_timeout(Duration::from_secs(1)) + rx.recv_timeout(Duration::from_secs(1)) .expect("updated GTK settings toggle event"), GtkToolbarFeedback::Event { - event: ToolbarEvent::ToggleContextAwareUi(!updated_context_aware), + event: ToolbarEvent::ToggleContextAwareUi(!updated), rebind_requested: false, } ); +} - // Layout-mode segments and the settings button grid carry the pane's - // events. +fn assert_settings_button_contract( + snapshot: &ToolbarSnapshot, + model: &model::ToolbarSettingsModel, + panel: >k4::Widget, + rx: &std::sync::mpsc::Receiver, +) { let mut buttons: Vec = Vec::new(); - collect_descendants(&settings_panel, &mut buttons); + collect_descendants(panel, &mut buttons); let tabs: Vec<_> = buttons .iter() .filter(|button| button.has_css_class("tab")) .collect(); - let control = model::layout_mode_control(settings_snapshot.layout_mode); + let control = model::layout_mode_control(snapshot.layout_mode); let model::ToolbarControlKind::Segmented(segmented) = &control.kind else { panic!("layout mode control is segmented"); }; assert_eq!(tabs.len(), segmented.segments().len()); tabs[0].emit_clicked(); assert_eq!( - menu_rx - .recv_timeout(Duration::from_secs(1)) + rx.recv_timeout(Duration::from_secs(1)) .expect("GTK layout mode event"), GtkToolbarFeedback::Event { event: segmented.segments()[0].activation.clone(), rebind_requested: false, } ); - let plain_buttons: Vec<_> = buttons + let plain: Vec<_> = buttons .iter() .filter(|button| !button.has_css_class("tab")) .collect(); + assert_eq!(plain.len(), model.buttons().len(), "settings button parity"); + plain[0].emit_clicked(); assert_eq!( - plain_buttons.len(), - settings_model.buttons().len(), - "settings button parity" - ); - plain_buttons[0].emit_clicked(); - assert_eq!( - menu_rx - .recv_timeout(Duration::from_secs(1)) + rx.recv_timeout(Duration::from_secs(1)) .expect("GTK settings button event"), GtkToolbarFeedback::Event { - event: settings_model.buttons()[0].event.clone(), + event: model.buttons()[0].event.clone(), rebind_requested: false, } ); +} - // Canvas popover parity: the command sections render buttons, the Step - // config renders its two toggles, and toggling every section off leaves - // the popover empty. - assert!(menu_top.canvas_popover.is_some(), "canvas popover exists"); - let mut canvas_snapshot = regular.clone(); - canvas_snapshot.canvas_popover_open = true; - canvas_snapshot.show_actions_section = true; - canvas_snapshot.show_boards_section = true; - canvas_snapshot.show_pages_section = true; - canvas_snapshot.show_zoom_actions = true; - canvas_snapshot.show_actions_advanced = true; - canvas_snapshot.show_step_section = true; - let (canvas_content, _canvas_updaters) = - menu_top.build_canvas_popover_content(&canvas_snapshot, 1.0); - let canvas_panel = find_widget_named(&canvas_content, "top.menu.canvas.panel") - .expect("canvas popover panel box"); +fn assert_canvas_popover_contract( + top: &TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + assert!(top.canvas_popover.is_some(), "canvas popover exists"); + assert_canvas_command_sections(top, regular, rx); + assert_canvas_delay_updates(top, regular); + assert_empty_canvas_popover(top, regular); +} + +fn assert_canvas_command_sections( + top: &TopBar, + regular: &ToolbarSnapshot, + rx: &std::sync::mpsc::Receiver, +) { + let mut snapshot = regular.clone(); + snapshot.canvas_popover_open = true; + snapshot.show_actions_section = true; + snapshot.show_boards_section = true; + snapshot.show_pages_section = true; + snapshot.show_zoom_actions = true; + snapshot.show_actions_advanced = true; + snapshot.show_step_section = true; + let (content, _) = top.build_canvas_popover_content(&snapshot, 1.0); + let panel = + find_widget_named(&content, "top.menu.canvas.panel").expect("canvas popover panel box"); assert_eq!( - canvas_panel.width_request(), - crate::ui::theme::toolbar::CANVAS_MENU_CONTENT_W as i32, - "GTK Canvas popover uses the shared roomy content width" - ); - assert_eq!(canvas_panel.margin_start(), 10, "Canvas left content inset"); - assert_eq!(canvas_panel.margin_end(), 10, "Canvas right content inset"); - let mut canvas_buttons: Vec = Vec::new(); - collect_descendants(&canvas_panel, &mut canvas_buttons); - assert!( - canvas_buttons.len() >= 4, - "the canvas popover exposes the command-section buttons" + panel.width_request(), + crate::ui::theme::toolbar::CANVAS_MENU_CONTENT_W as i32 ); + assert_eq!(panel.margin_start(), 10); + assert_eq!(panel.margin_end(), 10); + let mut buttons: Vec = Vec::new(); + collect_descendants(&panel, &mut buttons); + assert!(buttons.len() >= 4); for noun in ["Board", "Page"] { - let button_with_tooltip = |prefix: &str| { - canvas_buttons - .iter() - .find(|button| { - button - .tooltip_text() - .is_some_and(|tooltip| tooltip.starts_with(prefix)) - }) - .unwrap_or_else(|| panic!("{prefix} Canvas button")) - }; - let duplicate = button_with_tooltip(&format!("Duplicate {noun}")); - let delete = button_with_tooltip(&format!("Delete {noun}")); - for button in [duplicate, delete] { - assert_eq!( - button.width_request(), - 32, - "{noun} icon buttons stay compact instead of filling their slots" - ); - assert_eq!( - button.halign(), - gtk4::Align::Center, - "{noun} icon buttons are centered in their allocated slots" - ); - assert!(!button.hexpands(), "{noun} icon buttons do not stretch"); - } - assert_eq!( - delete.margin_end(), - 6, - "{noun} delete stays clear of the overlay scrollbar" - ); - let safe_parent = duplicate.parent().expect("safe-action group"); - let destructive_parent = delete.parent().expect("destructive-action row"); - assert_ne!( - safe_parent, destructive_parent, - "{noun} delete stays outside the homogeneous safe-action group" - ); - let safe_group = safe_parent - .downcast::() - .expect("homogeneous safe-action box"); - assert!(safe_group.is_homogeneous()); - let command_row = destructive_parent - .downcast::() - .expect("guarded command row"); - assert_eq!( - command_row.spacing(), - 12, - "{noun} delete keeps the explicit safety gap" - ); + assert_canvas_command_button_layout(&buttons, noun); } - // The Step config exposes exactly its two toggles; toggling the first - // emits ToggleCustomSection with the live state. - let mut canvas_checks: Vec = Vec::new(); - collect_descendants(&canvas_panel, &mut canvas_checks); - assert_eq!( - canvas_checks.len(), - 2, - "Step buttons + Delay sliders toggles" - ); - canvas_checks[0].set_active(!canvas_snapshot.custom_section_enabled); + let mut checks: Vec = Vec::new(); + collect_descendants(&panel, &mut checks); + assert_eq!(checks.len(), 2, "Step buttons + Delay sliders toggles"); + checks[0].set_active(!snapshot.custom_section_enabled); assert_eq!( - menu_rx - .recv_timeout(Duration::from_secs(1)) + rx.recv_timeout(Duration::from_secs(1)) .expect("GTK canvas step toggle event"), GtkToolbarFeedback::Event { - event: ToolbarEvent::ToggleCustomSection(!canvas_snapshot.custom_section_enabled), + event: ToolbarEvent::ToggleCustomSection(!snapshot.custom_section_enabled), rebind_requested: false, } ); +} - // Delay-slider live update WITHOUT a subtree rebuild: the Canvas popover's - // delay sliders ride the persistent value-updaters this builder returns - // (the content key omits their values), so an external delay change flows - // through an updater, not a rebuild. Build with the delay sliders shown, - // then run the returned updaters with a bumped delay and confirm the slider - // reflects it in place. - let mut delay_snapshot = regular.clone(); - delay_snapshot.canvas_popover_open = true; - delay_snapshot.show_step_section = true; - delay_snapshot.show_delay_sliders = true; - delay_snapshot.custom_section_enabled = false; - delay_snapshot.undo_all_delay_ms = 1000; - let (delay_content, delay_updaters) = - menu_top.build_canvas_popover_content(&delay_snapshot, 1.0); - assert!( - !delay_updaters.is_empty(), - "the delay sliders register persistent value-updaters" - ); - let delay_panel = find_widget_named(&delay_content, "top.menu.canvas.panel") - .expect("delay canvas popover panel box"); - let mut delay_boxes: Vec = Vec::new(); - collect_descendants(&delay_panel, &mut delay_boxes); - let undo_all_slider_tooltip = |boxes: &[gtk4::Box]| -> String { - boxes +fn assert_canvas_command_button_layout(buttons: &[gtk4::Button], noun: &str) { + let button_with_tooltip = |prefix: &str| { + buttons .iter() - .find_map(|widget| { - widget + .find(|button| { + button .tooltip_text() - .filter(|tooltip| tooltip.contains("Undo-all delay")) - .map(|tooltip| tooltip.to_string()) + .is_some_and(|tooltip| tooltip.starts_with(prefix)) }) - .expect("undo-all delay slider tooltip") + .unwrap_or_else(|| panic!("{prefix} Canvas button")) }; + let duplicate = button_with_tooltip(&format!("Duplicate {noun}")); + let delete = button_with_tooltip(&format!("Delete {noun}")); + for button in [duplicate, delete] { + assert_eq!(button.width_request(), 32); + assert_eq!(button.halign(), gtk4::Align::Center); + assert!(!button.hexpands()); + } + assert_eq!(delete.margin_end(), 6); + let safe_parent = duplicate.parent().expect("safe-action group"); + let destructive_parent = delete.parent().expect("destructive-action row"); + assert_ne!(safe_parent, destructive_parent); assert!( - undo_all_slider_tooltip(&delay_boxes).contains("1.0s"), - "the delay slider starts at the built value" + safe_parent + .downcast::() + .expect("homogeneous safe-action box") + .is_homogeneous() ); - // A backend echo of a new delay flows through the persistent updater — - // never a rebuild — and the same slider widget updates in place. - let mut bumped_delay = delay_snapshot.clone(); - bumped_delay.undo_all_delay_ms = 2500; - for updater in &delay_updaters { - updater(&bumped_delay); - } - assert!( - undo_all_slider_tooltip(&delay_boxes).contains("2.5s"), - "the persistent updater sets the new delay in place (no subtree rebuild)" - ); - - let mut empty_canvas = regular.clone(); - empty_canvas.canvas_popover_open = true; - empty_canvas.show_actions_section = false; - empty_canvas.show_boards_section = false; - empty_canvas.show_pages_section = false; - empty_canvas.show_zoom_actions = false; - empty_canvas.show_actions_advanced = false; - empty_canvas.show_step_section = false; - let (empty_content, _empty_updaters) = - menu_top.build_canvas_popover_content(&empty_canvas, 1.0); - let empty_panel = find_widget_named(&empty_content, "top.menu.canvas.panel") - .expect("empty canvas popover panel box"); - let mut empty_buttons: Vec = Vec::new(); - collect_descendants(&empty_panel, &mut empty_buttons); - assert!( - empty_buttons.is_empty(), - "every section toggled off leaves no canvas command buttons" + assert_eq!( + destructive_parent + .downcast::() + .expect("guarded command row") + .spacing(), + 12 ); +} - detach_test_popovers(&mut menu_top); +fn undo_all_slider_tooltip(boxes: &[gtk4::Box]) -> String { + boxes + .iter() + .find_map(|widget| { + widget + .tooltip_text() + .filter(|tooltip| tooltip.contains("Undo-all delay")) + .map(|tooltip| tooltip.to_string()) + }) + .expect("undo-all delay slider tooltip") +} + +fn assert_canvas_delay_updates(top: &TopBar, regular: &ToolbarSnapshot) { + let mut snapshot = regular.clone(); + snapshot.canvas_popover_open = true; + snapshot.show_step_section = true; + snapshot.show_delay_sliders = true; + snapshot.custom_section_enabled = false; + snapshot.undo_all_delay_ms = 1000; + let (content, updaters) = top.build_canvas_popover_content(&snapshot, 1.0); + assert!(!updaters.is_empty()); + let panel = find_widget_named(&content, "top.menu.canvas.panel") + .expect("delay canvas popover panel box"); + let mut boxes: Vec = Vec::new(); + collect_descendants(&panel, &mut boxes); + assert!(undo_all_slider_tooltip(&boxes).contains("1.0s")); + let mut bumped = snapshot; + bumped.undo_all_delay_ms = 2500; + for updater in &updaters { + updater(&bumped); + } + assert!(undo_all_slider_tooltip(&boxes).contains("2.5s")); +} + +fn assert_empty_canvas_popover(top: &TopBar, regular: &ToolbarSnapshot) { + let mut snapshot = regular.clone(); + snapshot.canvas_popover_open = true; + snapshot.show_actions_section = false; + snapshot.show_boards_section = false; + snapshot.show_pages_section = false; + snapshot.show_zoom_actions = false; + snapshot.show_actions_advanced = false; + snapshot.show_step_section = false; + let (content, _) = top.build_canvas_popover_content(&snapshot, 1.0); + let panel = find_widget_named(&content, "top.menu.canvas.panel") + .expect("empty canvas popover panel box"); + let mut buttons: Vec = Vec::new(); + collect_descendants(&panel, &mut buttons); + assert!(buttons.is_empty()); } #[test] From 7e4b0aac0b68c532409148631d5a272fab56c0a1 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 21:32:05 +0200 Subject: [PATCH 53/53] chore: enforce cognitive complexity budget --- Cargo.toml | 3 ++- clippy.toml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 clippy.toml diff --git a/Cargo.toml b/Cargo.toml index 9f5f2000..b1fcd4c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,8 @@ default-run = "wayscriber" members = ["configurator"] default-members = ["."] -[workspace.lints] +[workspace.lints.clippy] +cognitive_complexity = "warn" [lints] workspace = true diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..c81a6d75 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,3 @@ +# Keep functions within the agreed cognitive-complexity budget. Clippy emits a +# warning above this value, and CI promotes warnings to errors. +cognitive-complexity-threshold = 20