From acda25dc79fc0edb5154d4eba830baebbbb3bb32 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:19:11 +0200 Subject: [PATCH 01/10] perf(draw): share embedded image payloads --- .../wayland/backend/event_loop/capture.rs | 2 +- src/backend/wayland/clipboard/image.rs | 2 +- .../wayland/clipboard/transfer/tests.rs | 2 +- .../state/clipboard/session_paste/tests.rs | 2 +- src/draw/frame/tests/history/basics.rs | 6 +-- src/draw/frame/tests/serialization.rs | 2 +- src/draw/render/image.rs | 50 +++++++++++++++---- src/draw/shape/tests.rs | 22 ++++++-- src/draw/shape/types.rs | 11 ++-- src/input/hit_test/tests.rs | 2 +- src/input/state/core/captured_image.rs | 2 +- src/input/state/tests/boards.rs | 2 +- src/input/state/tests/pages.rs | 2 +- src/input/state/tests/session_preflight.rs | 2 +- src/session/tests/limits.rs | 6 +-- 15 files changed, 80 insertions(+), 35 deletions(-) diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index caa0de991..161586cd9 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -356,7 +356,7 @@ fn resolve_board_capture_outcome( mime_type: image.format.mime_type, width: image.width, height: image.height, - bytes: image.bytes, + bytes: image.bytes.into(), }; state .input_state diff --git a/src/backend/wayland/clipboard/image.rs b/src/backend/wayland/clipboard/image.rs index 2d2e188d7..ff59493ef 100644 --- a/src/backend/wayland/clipboard/image.rs +++ b/src/backend/wayland/clipboard/image.rs @@ -57,7 +57,7 @@ pub(super) fn decode_clipboard_image(mime_type: &str, bytes: Vec) -> Clipboa mime_type: canonical_image_mime_type(format).to_string(), width: dimensions.0, height: dimensions.1, - bytes, + bytes: bytes.into(), }) } diff --git a/src/backend/wayland/clipboard/transfer/tests.rs b/src/backend/wayland/clipboard/transfer/tests.rs index f14baef66..c7110914b 100644 --- a/src/backend/wayland/clipboard/transfer/tests.rs +++ b/src/backend/wayland/clipboard/transfer/tests.rs @@ -98,7 +98,7 @@ fn clipboard_terminal_outcomes_keep_their_existing_event_loop_policy() { mime_type: "image/png".to_string(), width: 1, height: 1, - bytes: vec![0, 0, 0, 255], + bytes: vec![0, 0, 0, 255].into(), }), }, Some(42), diff --git a/src/backend/wayland/state/clipboard/session_paste/tests.rs b/src/backend/wayland/state/clipboard/session_paste/tests.rs index add75aa75..d00a474ff 100644 --- a/src/backend/wayland/state/clipboard/session_paste/tests.rs +++ b/src/backend/wayland/state/clipboard/session_paste/tests.rs @@ -209,7 +209,7 @@ fn test_image(bytes: usize) -> EmbeddedImage { mime_type: "image/png".to_string(), width: 16, height: 16, - bytes: vec![7; bytes], + bytes: vec![7; bytes].into(), } } diff --git a/src/draw/frame/tests/history/basics.rs b/src/draw/frame/tests/history/basics.rs index ac51a26d7..64276ff73 100644 --- a/src/draw/frame/tests/history/basics.rs +++ b/src/draw/frame/tests/history/basics.rs @@ -159,7 +159,7 @@ fn modify_image_bounds_undo_redo_changes_geometry_without_replacing_payload() { mime_type: "image/png".to_string(), width: 10, height: 8, - bytes: vec![1, 2, 3, 4], + bytes: vec![1, 2, 3, 4].into(), }, }); if let Shape::Image { x, y, w, h, .. } = &mut frame.shape_mut(id).unwrap().shape { @@ -194,7 +194,7 @@ fn modify_image_bounds_undo_redo_changes_geometry_without_replacing_payload() { match &frame.shape(id).unwrap().shape { Shape::Image { x, y, w, h, data } => { assert_eq!((*x, *y, *w, *h), (0, 0, 10, 8)); - assert_eq!(data.bytes, vec![1, 2, 3, 4]); + assert_eq!(data.bytes.as_ref(), [1, 2, 3, 4]); } _ => panic!("expected image"), } @@ -203,7 +203,7 @@ fn modify_image_bounds_undo_redo_changes_geometry_without_replacing_payload() { match &frame.shape(id).unwrap().shape { Shape::Image { x, y, w, h, data } => { assert_eq!((*x, *y, *w, *h), (20, 30, 40, 32)); - assert_eq!(data.bytes, vec![1, 2, 3, 4]); + assert_eq!(data.bytes.as_ref(), [1, 2, 3, 4]); } _ => panic!("expected image"), } diff --git a/src/draw/frame/tests/serialization.rs b/src/draw/frame/tests/serialization.rs index 6ca265b85..c0d30ba84 100644 --- a/src/draw/frame/tests/serialization.rs +++ b/src/draw/frame/tests/serialization.rs @@ -136,7 +136,7 @@ fn image_bounds_history_serializes_without_duplicate_image_payloads() { mime_type: "image/png".to_string(), width: 16, height: 16, - bytes, + bytes: bytes.into(), }, }); let index = frame.find_index(id).unwrap(); diff --git a/src/draw/render/image.rs b/src/draw/render/image.rs index 11e5a7036..120a7311d 100644 --- a/src/draw/render/image.rs +++ b/src/draw/render/image.rs @@ -2,17 +2,35 @@ use crate::draw::shape::EmbeddedImage; use crate::image_decode::{decode_rgba, format_from_mime_or_bytes}; use cairo::{Format, ImageSurface}; use std::cell::RefCell; -use std::collections::{HashMap, VecDeque, hash_map::DefaultHasher}; +use std::collections::{HashMap, VecDeque}; use std::hash::{Hash, Hasher}; use std::rc::Rc; +use std::sync::Arc; const IMAGE_CACHE_ENTRIES: usize = 32; +#[derive(Clone, Debug)] +struct ImageBytesIdentity(Arc<[u8]>); + +impl PartialEq for ImageBytesIdentity { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +impl Eq for ImageBytesIdentity {} + +impl Hash for ImageBytesIdentity { + fn hash(&self, state: &mut H) { + self.0.as_ptr().hash(state); + self.0.len().hash(state); + } +} + #[derive(Clone, Debug, Hash, PartialEq, Eq)] struct ImageCacheKey { mime_type: String, - len: usize, - hash: u64, + bytes: ImageBytesIdentity, width: u32, height: u32, } @@ -92,8 +110,7 @@ pub fn render_image_shape( fn cached_surface(data: &EmbeddedImage) -> Option> { let key = ImageCacheKey { mime_type: data.mime_type.clone(), - len: data.bytes.len(), - hash: content_hash(&data.bytes), + bytes: ImageBytesIdentity(Arc::clone(&data.bytes)), width: data.width, height: data.height, }; @@ -148,12 +165,6 @@ fn decode_surface(data: &EmbeddedImage) -> Option { .ok() } -fn content_hash(bytes: &[u8]) -> u64 { - let mut hasher = DefaultHasher::new(); - bytes.hash(&mut hasher); - hasher.finish() -} - fn render_missing_image_placeholder(ctx: &cairo::Context, x: i32, y: i32, w: i32, h: i32) { let width = w.saturating_abs().max(1) as f64; let height = h.saturating_abs().max(1) as f64; @@ -174,3 +185,20 @@ fn render_missing_image_placeholder(ctx: &cairo::Context, x: i32, y: i32, w: i32 let _ = ctx.stroke(); let _ = ctx.restore(); } + +#[cfg(test)] +mod tests { + use super::ImageBytesIdentity; + use std::sync::Arc; + + #[test] + fn cache_identity_follows_shared_payload_allocation() { + let bytes: Arc<[u8]> = vec![1, 2, 3].into(); + let shared = ImageBytesIdentity(Arc::clone(&bytes)); + let same_allocation = ImageBytesIdentity(Arc::clone(&bytes)); + let equal_bytes_in_another_allocation = ImageBytesIdentity(vec![1, 2, 3].into()); + + assert_eq!(shared, same_allocation); + assert_ne!(shared, equal_bytes_in_another_allocation); + } +} diff --git a/src/draw/shape/tests.rs b/src/draw/shape/tests.rs index 272cdeeca..78316812f 100644 --- a/src/draw/shape/tests.rs +++ b/src/draw/shape/tests.rs @@ -453,7 +453,7 @@ fn image_bounding_box_and_kind_name_use_display_bounds() { mime_type: "image/png".to_string(), width: 2, height: 1, - bytes: vec![1, 2, 3], + bytes: vec![1, 2, 3].into(), }, }; @@ -482,7 +482,7 @@ fn pressure_and_image_bounds_handle_extreme_coordinates() { mime_type: "image/png".to_string(), width: 1, height: 1, - bytes: vec![1], + bytes: vec![1].into(), }, }; let image_bounds = image @@ -593,7 +593,7 @@ fn image_serialization_uses_base64_bytes() { mime_type: "image/jpeg".to_string(), width: 3, height: 4, - bytes: vec![1, 2, 3, 4], + bytes: vec![1, 2, 3, 4].into(), }, }; @@ -604,8 +604,22 @@ fn image_serialization_uses_base64_bytes() { match restored { Shape::Image { data, .. } => { assert_eq!(data.mime_type, "image/jpeg"); - assert_eq!(data.bytes, vec![1, 2, 3, 4]); + assert_eq!(data.bytes.as_ref(), [1, 2, 3, 4]); } other => panic!("expected image shape, got {:?}", other), } } + +#[test] +fn embedded_image_clones_share_the_encoded_payload() { + let image = EmbeddedImage { + mime_type: "image/png".to_string(), + width: 1, + height: 1, + bytes: vec![1, 2, 3, 4].into(), + }; + + let cloned = image.clone(); + + assert!(std::sync::Arc::ptr_eq(&image.bytes, &cloned.bytes)); +} diff --git a/src/draw/shape/types.rs b/src/draw/shape/types.rs index c43bd3a76..fae665b64 100644 --- a/src/draw/shape/types.rs +++ b/src/draw/shape/types.rs @@ -10,6 +10,7 @@ use crate::draw::color::Color; use crate::draw::font::FontDescriptor; use crate::util::Rect; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::sync::Arc; /// Encoded image payload stored directly on an image shape. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -18,7 +19,7 @@ pub struct EmbeddedImage { pub width: u32, pub height: u32, #[serde(with = "base64_bytes")] - pub bytes: Vec, + pub bytes: Arc<[u8]>, } /// Brush options for eraser strokes. @@ -561,18 +562,20 @@ fn normalized_rect(x: i32, y: i32, w: i32, h: i32) -> Option { mod base64_bytes { use super::*; - pub fn serialize(bytes: &[u8], serializer: S) -> Result + pub fn serialize(bytes: &Arc<[u8]>, serializer: S) -> Result where S: Serializer, { crate::base64::encode_standard(bytes).serialize(serializer) } - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let encoded = String::deserialize(deserializer)?; - crate::base64::decode_standard(&encoded).map_err(serde::de::Error::custom) + crate::base64::decode_standard(&encoded) + .map(Arc::from) + .map_err(serde::de::Error::custom) } } diff --git a/src/input/hit_test/tests.rs b/src/input/hit_test/tests.rs index 8308d9589..bfda02689 100644 --- a/src/input/hit_test/tests.rs +++ b/src/input/hit_test/tests.rs @@ -426,7 +426,7 @@ fn image_hit_test_uses_display_rectangle() { mime_type: "image/png".to_string(), width: 4, height: 3, - bytes: vec![1, 2, 3], + bytes: vec![1, 2, 3].into(), }, }, 0, diff --git a/src/input/state/core/captured_image.rs b/src/input/state/core/captured_image.rs index 718d53264..2ce9cb859 100644 --- a/src/input/state/core/captured_image.rs +++ b/src/input/state/core/captured_image.rs @@ -109,7 +109,7 @@ mod tests { mime_type: "image/png".to_string(), width: 10, height: 8, - bytes: vec![0; bytes], + bytes: vec![0; bytes].into(), } } diff --git a/src/input/state/tests/boards.rs b/src/input/state/tests/boards.rs index b5adf90f7..1e8a12230 100644 --- a/src/input/state/tests/boards.rs +++ b/src/input/state/tests/boards.rs @@ -393,7 +393,7 @@ fn add_active_image_shape(state: &mut InputState, bytes: usize) -> ShapeId { mime_type: "image/png".to_string(), width: 240, height: 180, - bytes: pseudo_random_bytes(bytes), + bytes: pseudo_random_bytes(bytes).into(), }, }) } diff --git a/src/input/state/tests/pages.rs b/src/input/state/tests/pages.rs index 11e770e2b..8521be2d3 100644 --- a/src/input/state/tests/pages.rs +++ b/src/input/state/tests/pages.rs @@ -484,7 +484,7 @@ fn add_active_image_shape(state: &mut InputState, bytes: usize) -> ShapeId { mime_type: "image/png".to_string(), width: 240, height: 180, - bytes: pseudo_random_bytes(bytes), + bytes: pseudo_random_bytes(bytes).into(), }, }) } diff --git a/src/input/state/tests/session_preflight.rs b/src/input/state/tests/session_preflight.rs index 805e37919..d23910861 100644 --- a/src/input/state/tests/session_preflight.rs +++ b/src/input/state/tests/session_preflight.rs @@ -69,7 +69,7 @@ fn add_active_image_shape(state: &mut InputState) -> ShapeId { mime_type: "image/png".to_string(), width: 240, height: 180, - bytes: Vec::new(), + bytes: Vec::new().into(), }, }) } diff --git a/src/session/tests/limits.rs b/src/session/tests/limits.rs index 73e3eaba3..77b3d88d8 100644 --- a/src/session/tests/limits.rs +++ b/src/session/tests/limits.rs @@ -195,7 +195,7 @@ fn image_frame(bytes: usize) -> crate::draw::Frame { mime_type: "image/png".to_string(), width: 640, height: 360, - bytes: pseudo_random_bytes(bytes), + bytes: pseudo_random_bytes(bytes).into(), }, }); frame @@ -625,7 +625,7 @@ fn save_snapshot_keeps_depth_one_when_visible_payload_is_near_limit() { mime_type: "image/png".to_string(), width: 640, height: 360, - bytes: vec![0x35; 96 * 1024], + bytes: vec![0x35; 96 * 1024].into(), }, }); let id = frame.add_shape(large_freehand(40, 0)); @@ -752,7 +752,7 @@ fn add_image_and_annotations(frame: &mut crate::draw::Frame, page_index: usize, mime_type: "image/png".to_string(), width: 640, height: 360, - bytes: vec![0x5a; bytes], + bytes: vec![0x5a; bytes].into(), }, }); let (image_index, image_shape) = frame From 95036d1305f24a704c0aaf88ced71cbadd5caaf6 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:21:45 +0200 Subject: [PATCH 02/10] perf(draw): render packed pressure samples directly --- src/draw/render/pressure_strokes.rs | 80 ++++++++++++++++++++++++++--- src/draw/render/selection.rs | 12 +---- src/draw/render/shapes.rs | 6 +-- 3 files changed, 78 insertions(+), 20 deletions(-) diff --git a/src/draw/render/pressure_strokes.rs b/src/draw/render/pressure_strokes.rs index 6c08912e3..25aeca664 100644 --- a/src/draw/render/pressure_strokes.rs +++ b/src/draw/render/pressure_strokes.rs @@ -5,7 +5,7 @@ const PRESSURE_STROKE_MAX_SUBDIVISIONS: usize = 128; const PRESSURE_STROKE_MIN_WIDTH: f64 = 0.1; const PRESSURE_STROKE_EPSILON: f64 = 0.000_001; -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq)] struct PressureStrokeSample { x: f64, y: f64, @@ -67,11 +67,35 @@ fn pressure_stroke_samples( thicknesses: &[f32], ) -> Vec { let len = points.len().min(thicknesses.len()); - let mut samples = Vec::with_capacity(len); + pressure_stroke_samples_from_iter( + points + .iter() + .zip(thicknesses) + .map(|(&(x, y), &width)| (x, y, width)), + len, + ) +} - for i in 0..len { - let (x, y) = points[i]; - let width = (thicknesses[i] as f64).max(PRESSURE_STROKE_MIN_WIDTH); +fn packed_pressure_stroke_samples( + points: &[(i32, i32, f32)], + thickness_delta: f32, +) -> Vec { + pressure_stroke_samples_from_iter( + points + .iter() + .map(|&(x, y, width)| (x, y, width + thickness_delta)), + points.len(), + ) +} + +fn pressure_stroke_samples_from_iter( + points: impl Iterator, + capacity: usize, +) -> Vec { + let mut samples = Vec::with_capacity(capacity); + + for (x, y, width) in points { + let width = f64::from(width).max(PRESSURE_STROKE_MIN_WIDTH); let current = PressureStrokeSample { x: x as f64, y: y as f64, @@ -226,6 +250,26 @@ pub fn render_freehand_pressure_borrowed( } let samples = pressure_stroke_samples(points, thicknesses); + render_pressure_samples(ctx, &samples, color); +} + +/// Render packed `(x, y, thickness)` pressure samples without first splitting +/// the stored stroke into parallel coordinate and thickness buffers. +pub(crate) fn render_packed_freehand_pressure_borrowed( + ctx: &cairo::Context, + points: &[(i32, i32, f32)], + thickness_delta: f32, + color: Color, +) { + if points.is_empty() { + return; + } + + let samples = packed_pressure_stroke_samples(points, thickness_delta); + render_pressure_samples(ctx, &samples, color); +} + +fn render_pressure_samples(ctx: &cairo::Context, samples: &[PressureStrokeSample], color: Color) { if samples.is_empty() { return; } @@ -234,7 +278,7 @@ pub fn render_freehand_pressure_borrowed( ctx.push_group_with_content(cairo::Content::Alpha); ctx.set_operator(cairo::Operator::Over); ctx.set_source_rgba(1.0, 1.0, 1.0, 1.0); - fill_pressure_geometry(ctx, &samples); + fill_pressure_geometry(ctx, samples); if let Ok(mask) = ctx.pop_group() { ctx.set_source_rgba(color.r, color.g, color.b, color.a); @@ -283,6 +327,30 @@ mod tests { .any(|pixel| pixel[3] > 0) } + #[test] + fn packed_samples_match_parallel_pressure_buffers() { + let packed = [(10, 20, 4.0), (30, 40, 8.0), (50, 25, 2.0)]; + let points = [(10, 20), (30, 40), (50, 25)]; + let thicknesses = [4.0, 8.0, 2.0]; + + assert_eq!( + packed_pressure_stroke_samples(&packed, 0.0), + pressure_stroke_samples(&points, &thicknesses) + ); + } + + #[test] + fn packed_samples_apply_selection_thickness_delta() { + let packed = [(10, 20, 4.0), (30, 40, 8.0)]; + let points = [(10, 20), (30, 40)]; + let thicknesses = [8.0, 12.0]; + + assert_eq!( + packed_pressure_stroke_samples(&packed, 4.0), + pressure_stroke_samples(&points, &thicknesses) + ); + } + #[test] fn pressure_stroke_preview_renders_nonblank_output() { let points = [(20, 140), (100, 50), (220, 50), (320, 110)]; diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs index 06ba1aac2..0694c2746 100644 --- a/src/draw/render/selection.rs +++ b/src/draw/render/selection.rs @@ -40,16 +40,8 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { render_freehand_borrowed(ctx, points, glow, thick + outline_width); } Shape::FreehandPressure { points, .. } => { - // For pressure lines, we render the same variable-width line but with extra thickness - // Split points into coords and thickness - let coords: Vec<(i32, i32)> = points.iter().map(|&(x, y, _)| (x, y)).collect(); - let thickness: Vec = points - .iter() - .map(|&(_, _, t)| t + outline_width as f32) - .collect(); - - use super::pressure_strokes::render_freehand_pressure_borrowed; - render_freehand_pressure_borrowed(ctx, &coords, &thickness, glow); + use super::pressure_strokes::render_packed_freehand_pressure_borrowed; + render_packed_freehand_pressure_borrowed(ctx, points, outline_width as f32, glow); } Shape::Line { x1, diff --git a/src/draw/render/shapes.rs b/src/draw/render/shapes.rs index 1bee4aa2d..70f99ba91 100644 --- a/src/draw/render/shapes.rs +++ b/src/draw/render/shapes.rs @@ -1,7 +1,7 @@ use super::blur::{render_black_out_rect, render_blur_placeholder}; use super::highlight::render_click_highlight; use super::image::render_image_shape; -use super::pressure_strokes::render_freehand_pressure_borrowed; +use super::pressure_strokes::render_packed_freehand_pressure_borrowed; use super::primitives::{render_arrow, render_ellipse, render_line, render_polygon, render_rect}; use super::strokes::{render_freehand_borrowed, render_marker_stroke_borrowed}; use super::text::{render_sticky_note, render_text_over_with_halo}; @@ -57,9 +57,7 @@ pub fn render_shape_over_with_halo( render_freehand_borrowed(ctx, points, *color, *thick); } Shape::FreehandPressure { points, color } => { - let coords: Vec<(i32, i32)> = points.iter().map(|&(x, y, _)| (x, y)).collect(); - let thickness: Vec = points.iter().map(|&(_, _, t)| t).collect(); - render_freehand_pressure_borrowed(ctx, &coords, &thickness, *color); + render_packed_freehand_pressure_borrowed(ctx, points, 0.0, *color); } Shape::Line { x1, From 77bcfbe6aa9a6447fee700283b5dad278364acff Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:24:03 +0200 Subject: [PATCH 03/10] perf(capture): cache window snap display geometry --- .../state/region_capture/window_snap.rs | 48 +++++++++++++++---- .../state/region_capture/window_snap/tests.rs | 7 +++ src/backend/wayland/state/render/ui.rs | 16 +------ 3 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/backend/wayland/state/region_capture/window_snap.rs b/src/backend/wayland/state/region_capture/window_snap.rs index 34dc8838e..4856857df 100644 --- a/src/backend/wayland/state/region_capture/window_snap.rs +++ b/src/backend/wayland/state/region_capture/window_snap.rs @@ -1,7 +1,7 @@ use crate::backend::wayland::state::WaylandState; use crate::backend::wayland::state::screen_image::{ScreenSourceToken, screen_rect_for_image_rect}; use crate::capture::window_geometry::{WindowQueryContext, WindowTarget}; -use crate::input::state::{RegionInputSource, RegionPurposeTag}; +use crate::input::state::{RegionInputSource, RegionPurposeTag, RegionSelection}; use crate::screen_pixels::{ImagePixelRect, ImagePoint}; use crate::util::Rect; @@ -38,15 +38,19 @@ impl WindowSnapTarget { self.image_rect } + #[cfg(test)] pub(in crate::backend::wayland) const fn screen_rect(&self) -> Rect { self.screen_rect } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] enum WindowSnapAvailability { Pending(WindowSnapQueryStage), - Ready(Vec), + Ready { + targets: Vec, + display_selections: Vec, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -121,7 +125,7 @@ impl WindowSnapSession { } pub(super) fn is_ready(&self) -> bool { - matches!(self.availability, WindowSnapAvailability::Ready(_)) + matches!(self.availability, WindowSnapAvailability::Ready { .. }) } pub(super) const fn mode_active(&self) -> bool { @@ -131,7 +135,16 @@ impl WindowSnapSession { pub(super) fn targets(&self) -> &[WindowSnapTarget] { match &self.availability { WindowSnapAvailability::Pending(_) => &[], - WindowSnapAvailability::Ready(targets) => targets, + WindowSnapAvailability::Ready { targets, .. } => targets, + } + } + + pub(super) fn display_selections(&self) -> &[RegionSelection] { + match &self.availability { + WindowSnapAvailability::Pending(_) => &[], + WindowSnapAvailability::Ready { + display_selections, .. + } => display_selections, } } @@ -251,10 +264,27 @@ pub(super) fn apply_window_query_completion( let current = session .as_mut() .expect("a correlated pending window-snap session still exists"); - current.availability = WindowSnapAvailability::Ready(mapped); + let display_selections = mapped + .iter() + .map(|target| region_selection_for_rect(target.screen_rect)) + .collect(); + current.availability = WindowSnapAvailability::Ready { + targets: mapped, + display_selections, + }; WindowQueryApply::Ready } +fn region_selection_for_rect(rect: Rect) -> RegionSelection { + RegionSelection { + start: (f64::from(rect.x), f64::from(rect.y)), + end: ( + f64::from(rect.x) + f64::from(rect.width), + f64::from(rect.y) + f64::from(rect.height), + ), + } +} + fn map_window_target(source: ScreenSourceToken, target: WindowTarget) -> Option { let logical_rect = target.logical_rect; let first = output_logical_to_image_point(source, (logical_rect.x, logical_rect.y))?; @@ -300,11 +330,13 @@ impl WaylandState { .is_some_and(WindowSnapSession::mode_active) } - pub(in crate::backend::wayland) fn region_window_snap_targets(&self) -> &[WindowSnapTarget] { + pub(in crate::backend::wayland) fn region_window_snap_display_selections( + &self, + ) -> &[RegionSelection] { self.data .window_snap .as_ref() - .map(WindowSnapSession::targets) + .map(WindowSnapSession::display_selections) .unwrap_or_default() } diff --git a/src/backend/wayland/state/region_capture/window_snap/tests.rs b/src/backend/wayland/state/region_capture/window_snap/tests.rs index 13f3937bf..8d866f67c 100644 --- a/src/backend/wayland/state/region_capture/window_snap/tests.rs +++ b/src/backend/wayland/state/region_capture/window_snap/tests.rs @@ -111,6 +111,13 @@ fn output_logical_window_maps_to_authoritative_pixels_then_zoomed_screen() { let mapped = &session.targets()[0]; assert_eq!(mapped.image_rect().size(), (450, 300)); assert_eq!(mapped.screen_rect(), Rect::new(150, 75, 450, 300).unwrap()); + assert_eq!( + session.display_selections(), + &[RegionSelection { + start: (150.0, 75.0), + end: (600.0, 375.0), + }] + ); } #[test] diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index f5795f312..d01e377b7 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -447,24 +447,10 @@ impl WaylandState { (image_point.x, image_point.y), ) }); - let window_targets: Vec<_> = self - .region_window_snap_targets() - .iter() - .map(|target| { - let rect = target.screen_rect(); - crate::input::state::RegionSelection { - start: (f64::from(rect.x), f64::from(rect.y)), - end: ( - f64::from(rect.x + rect.width), - f64::from(rect.y + rect.height), - ), - } - }) - .collect(); let window = crate::ui::RegionCaptureWindowVisual { available: self.region_window_snap_available(), active: self.region_window_snap_active(), - targets: &window_targets, + targets: self.region_window_snap_display_selections(), highlighted_target: self.region_window_snap_highlighted_index(), }; From a18bd7f2971c7c0400e05e6ea5461984c166c262 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:26:19 +0200 Subject: [PATCH 04/10] perf(board-picker): borrow layout through hot paths --- .../state/core/board_picker/layout/cursor.rs | 2 +- .../state/core/board_picker/layout/helpers.rs | 12 ++--- .../core/board_picker/layout/hit_test.rs | 47 ++++++++++--------- .../core/board_picker/state/lifecycle.rs | 2 +- src/ui/board_picker.rs | 2 +- src/ui/board_picker/page_panel.rs | 4 +- src/ui/board_picker/palette.rs | 2 +- src/ui/board_picker/rows.rs | 6 +-- 8 files changed, 40 insertions(+), 37 deletions(-) diff --git a/src/input/state/core/board_picker/layout/cursor.rs b/src/input/state/core/board_picker/layout/cursor.rs index f53dbe96b..f89c2214d 100644 --- a/src/input/state/core/board_picker/layout/cursor.rs +++ b/src/input/state/core/board_picker/layout/cursor.rs @@ -113,7 +113,7 @@ impl InputState { Some(BoardPickerCursorHint::Default) } - pub(crate) fn mark_board_picker_region(&mut self, layout: BoardPickerLayout) { + pub(crate) fn mark_board_picker_region(&mut self, layout: &BoardPickerLayout) { let x = layout.origin_x.floor() as i32; let y = layout.origin_y.floor() as i32; let width = layout.width.ceil() as i32 + 2; diff --git a/src/input/state/core/board_picker/layout/helpers.rs b/src/input/state/core/board_picker/layout/helpers.rs index 15ab67604..5f3d61672 100644 --- a/src/input/state/core/board_picker/layout/helpers.rs +++ b/src/input/state/core/board_picker/layout/helpers.rs @@ -18,7 +18,7 @@ pub(super) struct PagePanelInfo { impl InputState { pub(super) fn board_picker_page_panel_info( &self, - layout: BoardPickerLayout, + layout: &BoardPickerLayout, board_index: usize, ) -> Option { if !layout.page_panel_enabled { @@ -53,13 +53,13 @@ impl InputState { }) } - pub(super) const fn board_picker_page_row_stride(layout: BoardPickerLayout) -> f64 { + pub(super) const fn board_picker_page_row_stride(layout: &BoardPickerLayout) -> f64 { layout.page_thumb_height + PAGE_NAME_HEIGHT + PAGE_NAME_PADDING + layout.page_thumb_gap } pub(super) fn board_picker_slot_to_page_index( &self, - layout: BoardPickerLayout, + layout: &BoardPickerLayout, board_index: usize, slot: usize, ) -> Option { @@ -73,7 +73,7 @@ impl InputState { pub(super) fn board_picker_page_index_to_slot( &self, - layout: BoardPickerLayout, + layout: &BoardPickerLayout, board_index: usize, page_index: usize, ) -> Option { @@ -87,7 +87,7 @@ impl InputState { pub(super) fn board_picker_page_thumb_origin( &self, - layout: BoardPickerLayout, + layout: &BoardPickerLayout, board_index: usize, page_index: usize, ) -> Option<(PagePanelInfo, usize, usize, f64, f64)> { @@ -98,7 +98,7 @@ impl InputState { pub(super) fn board_picker_page_thumb_origin_for_slot( &self, - layout: BoardPickerLayout, + layout: &BoardPickerLayout, info: PagePanelInfo, slot: usize, ) -> Option<(PagePanelInfo, usize, usize, f64, f64)> { diff --git a/src/input/state/core/board_picker/layout/hit_test.rs b/src/input/state/core/board_picker/layout/hit_test.rs index 4fdabdb0d..6b65f7281 100644 --- a/src/input/state/core/board_picker/layout/hit_test.rs +++ b/src/input/state/core/board_picker/layout/hit_test.rs @@ -15,8 +15,8 @@ struct FloatRect { } #[derive(Debug, Clone, Copy)] -struct BoardPickerPagePanelHitContext { - layout: super::super::BoardPickerLayout, +struct BoardPickerPagePanelHitContext<'a> { + layout: &'a super::super::BoardPickerLayout, board_index: usize, info: PagePanelInfo, } @@ -30,7 +30,7 @@ impl FloatRect { impl InputState { fn with_visible_page_context( &self, - f: impl FnOnce(&BoardPickerPagePanelHitContext) -> Option, + f: impl FnOnce(&BoardPickerPagePanelHitContext<'_>) -> Option, ) -> Option { let context = self.board_picker_page_panel_context()?; if context.info.visible_pages == 0 { @@ -52,8 +52,8 @@ impl InputState { layout.padding_y + layout.header_height + row as f64 * layout.row_height } - fn board_picker_page_panel_context(&self) -> Option { - let layout = self.board_picker_layout?; + fn board_picker_page_panel_context(&self) -> Option> { + let layout = self.board_picker_layout.as_ref()?; let board_index = layout.page_board_index?; let info = self.board_picker_page_panel_info(layout, board_index)?; Some(BoardPickerPagePanelHitContext { @@ -67,7 +67,7 @@ impl InputState { &self, x: f64, y: f64, - context: &BoardPickerPagePanelHitContext, + context: &BoardPickerPagePanelHitContext<'_>, mut rect_for_thumb: impl FnMut(f64, f64) -> FloatRect, ) -> Option { for slot in 0..context.info.visible_slots { @@ -88,7 +88,7 @@ impl InputState { } pub(crate) fn board_picker_index_at(&self, x: i32, y: i32) -> Option { - let layout = self.board_picker_layout?; + let layout = self.board_picker_layout.as_ref()?; let local_x = x as f64 - layout.origin_x; let local_y = y as f64 - layout.origin_y; let row_top = layout.padding_y + layout.header_height; @@ -108,7 +108,7 @@ impl InputState { } pub(crate) fn board_picker_contains_point(&self, x: i32, y: i32) -> bool { - if let Some(layout) = self.board_picker_layout { + if let Some(layout) = self.board_picker_layout.as_ref() { let rect = FloatRect { x: layout.origin_x, y: layout.origin_y, @@ -126,9 +126,9 @@ impl InputState { if self.board_picker_is_new_row(row) { return None; } - let layout = self.board_picker_layout?; - let (local_x, local_y) = Self::board_picker_layout_point(&layout, x, y); - let row_top = Self::board_picker_row_top(&layout, row); + let layout = self.board_picker_layout.as_ref()?; + let (local_x, local_y) = Self::board_picker_layout_point(layout, x, y); + let row_top = Self::board_picker_row_top(layout, row); let swatch_y = row_top + (layout.row_height - layout.swatch_size) * 0.5; let swatch_x = layout.padding_x; let rect = FloatRect { @@ -145,7 +145,7 @@ impl InputState { } pub(crate) fn board_picker_palette_color_at(&self, x: i32, y: i32) -> Option { - let layout = self.board_picker_layout?; + let layout = self.board_picker_layout.as_ref()?; if layout.palette_rows == 0 || layout.palette_cols == 0 { return None; } @@ -244,7 +244,10 @@ impl InputState { thumb_rect.contains(x as f64, y as f64) } - fn board_picker_end_of_pages_visible(&self, context: &BoardPickerPagePanelHitContext) -> bool { + fn board_picker_end_of_pages_visible( + &self, + context: &BoardPickerPagePanelHitContext<'_>, + ) -> bool { context.info.first_visible_page + context.info.visible_pages >= context.info.page_count && context.info.visible_pages < context.info.slot_count } @@ -363,13 +366,13 @@ impl InputState { if self.board_picker_is_new_row(row) { return None; } - let layout = self.board_picker_layout?; + let layout = self.board_picker_layout.as_ref()?; if layout.handle_width <= 0.0 || self.board_picker_is_quick() { return None; } - let (local_x, local_y) = Self::board_picker_layout_point(&layout, x, y); - let row_top = Self::board_picker_row_top(&layout, row); + let (local_x, local_y) = Self::board_picker_layout_point(layout, x, y); + let row_top = Self::board_picker_row_top(layout, row); let list_right = layout.list_width; let handle_x = list_right - layout.padding_x - layout.handle_width; let rect = FloatRect { @@ -390,13 +393,13 @@ impl InputState { if self.board_picker_is_new_row(row) { return None; } - let layout = self.board_picker_layout?; + let layout = self.board_picker_layout.as_ref()?; if layout.open_icon_size <= 0.0 || self.board_picker_is_quick() { return None; } - let (local_x, local_y) = Self::board_picker_layout_point(&layout, x, y); - let row_top = Self::board_picker_row_top(&layout, row); + let (local_x, local_y) = Self::board_picker_layout_point(layout, x, y); + let row_top = Self::board_picker_row_top(layout, row); let list_right = layout.list_width; let handle_x = list_right - layout.padding_x - layout.handle_width; let open_x = handle_x - layout.open_icon_gap - layout.open_icon_size; @@ -418,10 +421,10 @@ impl InputState { if self.board_picker_is_new_row(row) { return None; } - let layout = self.board_picker_layout?; + let layout = self.board_picker_layout.as_ref()?; - let (local_x, local_y) = Self::board_picker_layout_point(&layout, x, y); - let row_top = Self::board_picker_row_top(&layout, row); + let (local_x, local_y) = Self::board_picker_layout_point(layout, x, y); + let row_top = Self::board_picker_row_top(layout, row); let pin_size = layout.swatch_size * super::super::PIN_OFFSET_FACTOR; let pin_x = layout.padding_x + layout.swatch_size + layout.swatch_padding - pin_size * 0.25; let pin_y = row_top + (layout.row_height - pin_size) * 0.5 - pin_size * 0.25; diff --git a/src/input/state/core/board_picker/state/lifecycle.rs b/src/input/state/core/board_picker/state/lifecycle.rs index 81b101628..3c4bd1ef1 100644 --- a/src/input/state/core/board_picker/state/lifecycle.rs +++ b/src/input/state/core/board_picker/state/lifecycle.rs @@ -62,7 +62,7 @@ impl InputState { pub(crate) fn close_board_picker(&mut self) { if let Some(layout) = self.board_picker_layout { - self.mark_board_picker_region(layout); + self.mark_board_picker_region(&layout); } // Board picker dims the entire screen; ensure full redraw when closing. self.dirty_tracker.mark_full(); diff --git a/src/ui/board_picker.rs b/src/ui/board_picker.rs index 081928092..e423c6a28 100644 --- a/src/ui/board_picker.rs +++ b/src/ui/board_picker.rs @@ -37,7 +37,7 @@ pub(crate) fn render_board_picker_with_halo( } let layout = match input_state.board_picker_layout() { - Some(layout) => *layout, + Some(layout) => layout, None => return, }; diff --git a/src/ui/board_picker/page_panel.rs b/src/ui/board_picker/page_panel.rs index 3d6ea8cae..6a421538f 100644 --- a/src/ui/board_picker/page_panel.rs +++ b/src/ui/board_picker/page_panel.rs @@ -29,7 +29,7 @@ use thumbnail::{ pub(super) fn render_page_panel( ctx: &cairo::Context, input_state: &InputState, - layout: BoardPickerLayout, + layout: &BoardPickerLayout, screen_width: u32, screen_height: u32, text_halo_enabled: bool, @@ -259,7 +259,7 @@ pub(super) fn render_page_panel( fn render_sticky_add_button( ctx: &cairo::Context, - layout: BoardPickerLayout, + layout: &BoardPickerLayout, pointer_x: i32, pointer_y: i32, ) { diff --git a/src/ui/board_picker/palette.rs b/src/ui/board_picker/palette.rs index 01d82d20b..5d7676aa8 100644 --- a/src/ui/board_picker/palette.rs +++ b/src/ui/board_picker/palette.rs @@ -10,7 +10,7 @@ const PALETTE_SWATCH_GAP: f64 = 6.0; pub(super) fn render_board_palette( ctx: &cairo::Context, input_state: &InputState, - layout: crate::input::state::BoardPickerLayout, + layout: &crate::input::state::BoardPickerLayout, ) { if layout.palette_rows == 0 || layout.palette_cols == 0 { return; diff --git a/src/ui/board_picker/rows.rs b/src/ui/board_picker/rows.rs index 8d9ef445e..b338b09e3 100644 --- a/src/ui/board_picker/rows.rs +++ b/src/ui/board_picker/rows.rs @@ -18,7 +18,7 @@ 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: BoardPickerLayout, + layout: &BoardPickerLayout, board_count: usize, max_count: usize, ) { @@ -28,7 +28,7 @@ pub(super) fn render_board_rows( struct BoardRowsRenderer<'a> { ctx: &'a cairo::Context, input: &'a InputState, - layout: BoardPickerLayout, + layout: &'a BoardPickerLayout, board_count: usize, max_count: usize, rows_top: f64, @@ -49,7 +49,7 @@ impl<'a> BoardRowsRenderer<'a> { fn new( ctx: &'a cairo::Context, input: &'a InputState, - layout: BoardPickerLayout, + layout: &'a BoardPickerLayout, board_count: usize, max_count: usize, ) -> Self { From 8ffaea95d4bd1c380225a3f3d68fcce96b50ba53 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:29:24 +0200 Subject: [PATCH 05/10] perf(capture): borrow region review visuals --- src/backend/wayland/state/render/ui.rs | 2 +- src/ui/region_action_bar.rs | 26 ++++++++++++------------ src/ui/region_capture_picker.rs | 28 +++++++++++++------------- src/ui/region_resize_handles.rs | 8 ++++---- 4 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index d01e377b7..cafd2ca01 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -458,7 +458,7 @@ impl WaylandState { ctx, width, height, - crate::ui::RegionCapturePickerVisual { + &crate::ui::RegionCapturePickerVisual { selection, pointer, measurement: measurement.as_deref(), diff --git a/src/ui/region_action_bar.rs b/src/ui/region_action_bar.rs index 5ef16a283..67225ff0a 100644 --- a/src/ui/region_action_bar.rs +++ b/src/ui/region_action_bar.rs @@ -316,11 +316,11 @@ impl RegionActionBar { /// The painted frame, without its drop shadow. The picker uses it to keep /// the Review size badge out from under the bar. - pub(crate) const fn bounds(self) -> RegionActionRect { + pub(crate) const fn bounds(&self) -> RegionActionRect { self.bounds } - pub(crate) fn hit(self, point: (f64, f64)) -> Option { + pub(crate) fn hit(&self, point: (f64, f64)) -> Option { self.items .iter() .chain(self.edit.iter()) @@ -335,7 +335,7 @@ impl RegionActionBar { } pub(crate) fn enabled_hit( - self, + &self, point: (f64, f64), availability: RegionActionAvailability, ) -> Option { @@ -343,11 +343,11 @@ impl RegionActionBar { .filter(|&action| availability.allows(action)) } - pub(crate) fn contains(self, point: (f64, f64)) -> bool { + pub(crate) fn contains(&self, point: (f64, f64)) -> bool { self.bounds.contains(point) } - fn status_bounds(self) -> Option { + fn status_bounds(&self) -> Option { let toggle = self.toggle.bounds; if toggle.width <= 0.0 { return None; @@ -363,13 +363,13 @@ impl RegionActionBar { pub(crate) fn render_region_action_bar( ctx: &cairo::Context, - bar: RegionActionBar, + bar: &RegionActionBar, visual: RegionActionBarVisual, ) { let _ = ctx.save(); draw_bar_frame(ctx, bar.bounds); - for item in bar.items { + for &item in &bar.items { draw_action( ctx, item, @@ -384,7 +384,7 @@ pub(crate) fn render_region_action_bar( bar.edit[0].bounds.y, bar.toggle.bounds.width, ); - for item in bar.edit { + for &item in &bar.edit { draw_action( ctx, item, @@ -699,7 +699,7 @@ fn draw_checkbox(ctx: &cairo::Context, x: f64, y: f64, size: f64, checked: bool) let _ = ctx.stroke(); } -fn draw_status(ctx: &cairo::Context, bar: RegionActionBar, status: Option) { +fn draw_status(ctx: &cairo::Context, bar: &RegionActionBar, status: Option) { let Some(status) = status else { return; }; @@ -975,7 +975,7 @@ mod tests { let ctx = cairo::Context::new(&surface).unwrap(); render_region_action_bar( &ctx, - bar, + &bar, RegionActionBarVisual::simple(Some(RegionAction::Both), true), ); drop(ctx); @@ -1001,7 +1001,7 @@ mod tests { let row_alpha = |checked: bool| { let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); - render_region_action_bar(&ctx, bar, RegionActionBarVisual::simple(None, checked)); + render_region_action_bar(&ctx, &bar, RegionActionBarVisual::simple(None, checked)); drop(ctx); surface.flush(); let stride = surface.stride() as usize; @@ -1025,7 +1025,7 @@ mod tests { let ctx = cairo::Context::new(&surface).unwrap(); render_region_action_bar( &ctx, - bar, + &bar, RegionActionBarVisual { hovered: None, include_drawings: false, @@ -1061,7 +1061,7 @@ mod tests { let ctx = cairo::Context::new(&surface).unwrap(); render_region_action_bar( &ctx, - bar, + &bar, RegionActionBarVisual { hovered: None, include_drawings: false, diff --git a/src/ui/region_capture_picker.rs b/src/ui/region_capture_picker.rs index 75785f777..e5e027f42 100644 --- a/src/ui/region_capture_picker.rs +++ b/src/ui/region_capture_picker.rs @@ -253,7 +253,7 @@ pub(crate) fn render_region_capture_picker( ctx: &cairo::Context, screen_width: u32, screen_height: u32, - visual: RegionCapturePickerVisual<'_>, + visual: &RegionCapturePickerVisual<'_>, mut sample_loupe: impl FnMut(f64, f64) -> Option, ) { let width = f64::from(screen_width); @@ -291,7 +291,7 @@ pub(crate) fn render_region_capture_picker( // frame drops its arms wherever grips are offered. draw_selection_frame(ctx, x, y, w, h, visual.resize_handles.is_none()); } - if let Some(handles) = visual.resize_handles { + if let Some(handles) = visual.resize_handles.as_ref() { render_region_resize_handles(ctx, handles, visual.hovered_handle); } if let Some(drag) = visual.cut.drag { @@ -311,7 +311,7 @@ pub(crate) fn render_region_capture_picker( READOUT_FONT_SIZE, visual.pointer, anchor, - visual.action_bar.map(RegionActionBar::bounds), + visual.action_bar.as_ref().map(RegionActionBar::bounds), (screen_width, screen_height), cairo::FontWeight::Bold, ); @@ -326,7 +326,7 @@ pub(crate) fn render_region_capture_picker( if let Some(loupe) = visual.loupe { render_region_capture_loupe(ctx, (screen_width, screen_height), loupe, &mut sample_loupe); } - if let Some(action_bar) = visual.action_bar { + if let Some(action_bar) = visual.action_bar.as_ref() { render_region_action_bar( ctx, action_bar, @@ -794,7 +794,7 @@ mod tests { &ctx, 40, 40, - RegionCapturePickerVisual { + &RegionCapturePickerVisual { selection: Some(RegionSelection { start: (10.0, 10.0), end: (30.0, 30.0), @@ -842,7 +842,7 @@ mod tests { &ctx, 40, 40, - RegionCapturePickerVisual { + &RegionCapturePickerVisual { selection: None, pointer: (28.0, 28.0), measurement: None, @@ -883,7 +883,7 @@ mod tests { &ctx, 40, 40, - RegionCapturePickerVisual { + &RegionCapturePickerVisual { selection: None, pointer: (20.0, 20.0), measurement: None, @@ -931,7 +931,7 @@ mod tests { &ctx, 40, 20, - RegionCapturePickerVisual { + &RegionCapturePickerVisual { selection: None, pointer: (20.0, 18.0), measurement: None, @@ -978,7 +978,7 @@ mod tests { &ctx, 40, 40, - RegionCapturePickerVisual { + &RegionCapturePickerVisual { selection: None, pointer: (20.0, 20.0), measurement: Some("20, 20"), @@ -1043,7 +1043,7 @@ mod tests { &ctx, 40, 40, - RegionCapturePickerVisual { + &RegionCapturePickerVisual { selection: Some(RegionSelection { start: (10.0, 10.0), end: (30.0, 30.0), @@ -1087,7 +1087,7 @@ mod tests { &ctx, 800, 600, - RegionCapturePickerVisual { + &RegionCapturePickerVisual { selection: Some(selection), pointer: (200.0, 150.0), measurement: Some("200 × 100"), @@ -1198,7 +1198,7 @@ mod tests { &ctx, 800, 600, - RegionCapturePickerVisual { + &RegionCapturePickerVisual { selection: Some(selection), pointer: (760.0, 575.0), measurement, @@ -1294,7 +1294,7 @@ mod tests { &ctx, 300, 260, - RegionCapturePickerVisual { + &RegionCapturePickerVisual { selection: Some(selection), // Park the pointer in a corner: targeting still paints a // crosshair, and its two lines must not cross the pixels @@ -1406,7 +1406,7 @@ mod tests { &ctx, 60, 40, - RegionCapturePickerVisual { + &RegionCapturePickerVisual { selection: Some(display), pointer: (4.0, 4.0), measurement: None, diff --git a/src/ui/region_resize_handles.rs b/src/ui/region_resize_handles.rs index c5bcaf3b9..36d878f4f 100644 --- a/src/ui/region_resize_handles.rs +++ b/src/ui/region_resize_handles.rs @@ -145,11 +145,11 @@ impl RegionResizeHandles { pub(crate) fn render_region_resize_handles( ctx: &cairo::Context, - handles: RegionResizeHandles, + handles: &RegionResizeHandles, hovered: Option, ) { let _ = ctx.save(); - for chip in handles.chips.into_iter().flatten() { + for chip in handles.chips.iter().flatten() { let (x, y, width, height) = chip.rect(); if width <= 0.0 || height <= 0.0 { continue; @@ -329,7 +329,7 @@ mod tests { let handles = RegionResizeHandles::place(selection(20.0, 20.0, side, side)); let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 60, 60).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); - render_region_resize_handles(&ctx, handles, Some(SelectionHandle::TopLeft)); + render_region_resize_handles(&ctx, &handles, Some(SelectionHandle::TopLeft)); assert_eq!( ctx.status(), Ok(()), @@ -371,7 +371,7 @@ mod tests { let handles = RegionResizeHandles::place(selection(20.0, 20.0, 160.0, 160.0)); let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 200).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); - render_region_resize_handles(&ctx, handles, Some(SelectionHandle::TopLeft)); + render_region_resize_handles(&ctx, &handles, Some(SelectionHandle::TopLeft)); drop(ctx); surface.flush(); let stride = surface.stride() as usize; From 386d7045c9113da917e08354a7527c816099d1a3 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:31:20 +0200 Subject: [PATCH 06/10] refactor(draw): split complex shape renderers --- src/draw/render/shapes.rs | 317 ++++++++++++++++++++++++-------------- 1 file changed, 199 insertions(+), 118 deletions(-) diff --git a/src/draw/render/shapes.rs b/src/draw/render/shapes.rs index 70f99ba91..f3d064bcc 100644 --- a/src/draw/render/shapes.rs +++ b/src/draw/render/shapes.rs @@ -6,12 +6,37 @@ use super::primitives::{render_arrow, render_ellipse, render_line, render_polygo use super::strokes::{render_freehand_borrowed, render_marker_stroke_borrowed}; use super::text::{render_sticky_note, render_text_over_with_halo}; use crate::draw::Color; -use crate::draw::shape::Shape; use crate::draw::shape::{ - ARROW_LABEL_BACKGROUND, arrow_label_ends, arrow_label_layout, measure_text_with_context, - step_marker_outline_thickness, step_marker_radius, + ARROW_LABEL_BACKGROUND, ArrowLabel, ArrowStyle, Shape, StepMarkerLabel, arrow_label_ends, + arrow_label_layout, measure_text_with_context, step_marker_outline_thickness, + step_marker_radius, }; +#[derive(Clone, Copy)] +struct ShapeTextOptions { + known_background_luminance: Option, + halo_enabled: bool, +} + +struct ArrowRenderSpec<'a> { + start: (i32, i32), + end: (i32, i32), + color: Color, + thickness: f64, + arrow_length: f64, + arrow_angle: f64, + head_at_end: bool, + style: ArrowStyle, + bend: f64, + label: Option<&'a ArrowLabel>, +} + +struct StepMarkerRenderSpec<'a> { + center: (i32, i32), + color: Color, + label: &'a StepMarkerLabel, +} + /// Renders a single shape to a Cairo context. /// /// Dispatches to the appropriate internal rendering function based on shape type. @@ -48,6 +73,10 @@ pub fn render_shape_over_with_halo( known_background_luminance: Option, text_halo_enabled: bool, ) { + let text_options = ShapeTextOptions { + known_background_luminance, + halo_enabled: text_halo_enabled, + }; match shape { Shape::Freehand { points, @@ -114,53 +143,22 @@ pub fn render_shape_over_with_halo( bend, label, } => { - // Only the label needs these: `render_arrow` reads `head_at_end` - // itself. `Double` deliberately ignores the flag here, matching the - // outline it draws either way. - let (tip_x, tip_y, tail_x, tail_y) = - arrow_label_ends(*x1, *y1, *x2, *y2, *head_at_end, *style); - render_arrow( + render_arrow_shape( ctx, - *x1, - *y1, - *x2, - *y2, - *color, - *thick, - *arrow_length, - *arrow_angle, - *head_at_end, - *style, - *bend, + ArrowRenderSpec { + start: (*x1, *y1), + end: (*x2, *y2), + color: *color, + thickness: *thick, + arrow_length: *arrow_length, + arrow_angle: *arrow_angle, + head_at_end: *head_at_end, + style: *style, + bend: *bend, + label: label.as_ref(), + }, + text_options, ); - if let Some(label) = label { - let label_text = label.value.to_string(); - if let Some(layout) = arrow_label_layout( - tip_x, - tip_y, - tail_x, - tail_y, - *thick, - style.effective_bend(*bend), - &label_text, - label.size, - &label.font_descriptor, - ) { - render_text_over_with_halo( - ctx, - layout.x, - layout.y, - &label_text, - *color, - label.size, - &label.font_descriptor, - ARROW_LABEL_BACKGROUND, - None, - known_background_luminance, - text_halo_enabled, - ); - } - } } Shape::BlurRect { x, @@ -207,78 +205,15 @@ pub fn render_shape_over_with_halo( ); } Shape::StepMarker { x, y, color, label } => { - let label_text = label.value.to_string(); - let radius = step_marker_radius(label.value, label.size, &label.font_descriptor); - let outline_thickness = step_marker_outline_thickness(label.size); - let alpha = color.a.clamp(0.0, 1.0); - let fill_color = Color { - a: (alpha * 0.9).clamp(0.0, 1.0), - ..*color - }; - let brightness = super::color_luminance(*color); - let (outline_color, text_color) = if brightness > 0.6 { - ( - Color { - r: 0.05, - g: 0.05, - b: 0.05, - a: 0.85 * alpha, - }, - Color { - r: 0.12, - g: 0.12, - b: 0.12, - a: alpha, - }, - ) - } else { - ( - Color { - r: 0.98, - g: 0.98, - b: 0.98, - a: 0.9 * alpha, - }, - Color { - r: 0.98, - g: 0.98, - b: 0.98, - a: alpha, - }, - ) - }; - render_click_highlight( + render_step_marker_shape( ctx, - *x as f64, - *y as f64, - radius, - outline_thickness, - fill_color, - outline_color, - 1.0, + StepMarkerRenderSpec { + center: (*x, *y), + color: *color, + label, + }, + text_options, ); - let font_desc = label.font_descriptor.to_pango_string(label.size); - if let Some(metrics) = - measure_text_with_context(ctx, &label_text, &font_desc, label.size, None) - { - let center_offset_x = metrics.ink_x + metrics.ink_width / 2.0; - let center_offset_y = metrics.ink_y + metrics.ink_height / 2.0; - let baseline_x = (*x as f64 - center_offset_x).round() as i32; - let baseline_y = (*y as f64 - center_offset_y + metrics.baseline).round() as i32; - render_text_over_with_halo( - ctx, - baseline_x, - baseline_y, - &label_text, - text_color, - label.size, - &label.font_descriptor, - false, - None, - known_background_luminance, - text_halo_enabled, - ); - } } Shape::StickyNote { x, @@ -316,6 +251,152 @@ pub fn render_shape_over_with_halo( } } +fn render_arrow_shape(ctx: &cairo::Context, arrow: ArrowRenderSpec<'_>, text: ShapeTextOptions) { + // Only the label needs these: `render_arrow` reads `head_at_end` itself. + // `Double` deliberately ignores the flag here, matching the outline it + // draws either way. + let (tip_x, tip_y, tail_x, tail_y) = arrow_label_ends( + arrow.start.0, + arrow.start.1, + arrow.end.0, + arrow.end.1, + arrow.head_at_end, + arrow.style, + ); + render_arrow( + ctx, + arrow.start.0, + arrow.start.1, + arrow.end.0, + arrow.end.1, + arrow.color, + arrow.thickness, + arrow.arrow_length, + arrow.arrow_angle, + arrow.head_at_end, + arrow.style, + arrow.bend, + ); + let Some(label) = arrow.label else { + return; + }; + let label_text = label.value.to_string(); + let Some(layout) = arrow_label_layout( + tip_x, + tip_y, + tail_x, + tail_y, + arrow.thickness, + arrow.style.effective_bend(arrow.bend), + &label_text, + label.size, + &label.font_descriptor, + ) else { + return; + }; + render_text_over_with_halo( + ctx, + layout.x, + layout.y, + &label_text, + arrow.color, + label.size, + &label.font_descriptor, + ARROW_LABEL_BACKGROUND, + None, + text.known_background_luminance, + text.halo_enabled, + ); +} + +fn render_step_marker_shape( + ctx: &cairo::Context, + marker: StepMarkerRenderSpec<'_>, + text: ShapeTextOptions, +) { + let label_text = marker.label.value.to_string(); + let radius = step_marker_radius( + marker.label.value, + marker.label.size, + &marker.label.font_descriptor, + ); + let outline_thickness = step_marker_outline_thickness(marker.label.size); + let alpha = marker.color.a.clamp(0.0, 1.0); + let fill_color = Color { + a: (alpha * 0.9).clamp(0.0, 1.0), + ..marker.color + }; + let brightness = super::color_luminance(marker.color); + let (outline_color, text_color) = if brightness > 0.6 { + ( + Color { + r: 0.05, + g: 0.05, + b: 0.05, + a: 0.85 * alpha, + }, + Color { + r: 0.12, + g: 0.12, + b: 0.12, + a: alpha, + }, + ) + } else { + ( + Color { + r: 0.98, + g: 0.98, + b: 0.98, + a: 0.9 * alpha, + }, + Color { + r: 0.98, + g: 0.98, + b: 0.98, + a: alpha, + }, + ) + }; + render_click_highlight( + ctx, + f64::from(marker.center.0), + f64::from(marker.center.1), + radius, + outline_thickness, + fill_color, + outline_color, + 1.0, + ); + let font_desc = marker + .label + .font_descriptor + .to_pango_string(marker.label.size); + let Some(metrics) = + measure_text_with_context(ctx, &label_text, &font_desc, marker.label.size, None) + else { + return; + }; + let center_offset_x = metrics.ink_x + metrics.ink_width / 2.0; + let center_offset_y = metrics.ink_y + metrics.ink_height / 2.0; + let baseline_x = (f64::from(marker.center.0) - center_offset_x).round() as i32; + let baseline_y = + (f64::from(marker.center.1) - center_offset_y + metrics.baseline).round() as i32; + render_text_over_with_halo( + ctx, + baseline_x, + baseline_y, + &label_text, + text_color, + marker.label.size, + &marker.label.font_descriptor, + false, + None, + text.known_background_luminance, + text.halo_enabled, + ); +} + #[cfg(test)] mod tests { use super::render_shape_with_halo; From d2e1f82dd68d8efb0c026792ab742da99fdef060 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:32:16 +0200 Subject: [PATCH 07/10] refactor(cli): separate validation concerns --- src/cli.rs | 45 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 10 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index b55dd7347..84bd40b3a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -257,12 +257,23 @@ impl Cli { } fn validate(&self) -> Result<(), String> { + self.validate_runtime_capabilities_command()?; + self.validate_session_flag_pairs()?; + self.validate_overlay_commands()?; + self.validate_session_commands()?; + self.validate_catalog_commands() + } + + fn validate_runtime_capabilities_command(&self) -> Result<(), String> { if self.runtime_capabilities && (self.selects_a_launch_command() || self.about || self.check_update) { return Err("--runtime-capabilities conflicts with launch flags".to_string()); } + Ok(()) + } + fn validate_session_flag_pairs(&self) -> Result<(), String> { if self.exit_after_capture && self.no_exit_after_capture { return Err(conflict("--exit-after-capture", "--no-exit-after-capture")); } @@ -294,7 +305,10 @@ impl Cli { if self.rename_session.is_some() && self.selects_overlay_option() { return Err("--rename-session conflicts with overlay/daemon options".to_string()); } + Ok(()) + } + fn validate_overlay_commands(&self) -> Result<(), String> { if self.freeze_on_show && !self.daemon { return Err("--freeze-on-show requires --daemon".to_string()); } @@ -308,16 +322,7 @@ impl Cli { return Err("--freeze-on-show conflicts with overlay/session commands".to_string()); } - let overlay_action_count = [ - self.daemon_action.is_some(), - self.light_toggle, - self.light_draw_toggle, - self.light_draw_on, - self.light_draw_off, - ] - .into_iter() - .filter(|selected| *selected) - .count(); + let overlay_action_count = self.overlay_action_count(); if self.session_file.is_some() { if overlay_action_count > 0 { @@ -388,7 +393,23 @@ impl Cli { { return Err("daemon overlay actions cannot be combined with launch flags".to_string()); } + Ok(()) + } + + fn overlay_action_count(&self) -> usize { + [ + self.daemon_action.is_some(), + self.light_toggle, + self.light_draw_toggle, + self.light_draw_on, + self.light_draw_off, + ] + .into_iter() + .filter(|selected| *selected) + .count() + } + fn validate_session_commands(&self) -> Result<(), String> { if self.clear_session && (self.daemon || self.active) { return Err("--clear-session conflicts with --daemon/--active".to_string()); } @@ -429,6 +450,10 @@ impl Cli { .to_string(), ); } + Ok(()) + } + + fn validate_catalog_commands(&self) -> Result<(), String> { if self.about && (self.selects_a_launch_command() || self.check_update) { return Err("--about conflicts with the selected command".to_string()); } From 7e0b38fb7583276d55b4b9713eaf78af121e6d4b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:35:16 +0200 Subject: [PATCH 08/10] refactor(config): centralize preset validation --- src/config/tests/validate.rs | 5 + src/config/validate/presets.rs | 248 +++++++++++++++++---------------- 2 files changed, 134 insertions(+), 119 deletions(-) diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index b34612bd5..c874ca892 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -515,6 +515,7 @@ fn validate_clamps_preset_fields() { show_status_bar: None, drag_tools: None, }); + config.presets.slot_5 = config.presets.slot_1.clone(); config.validate_and_clamp(); @@ -536,6 +537,10 @@ fn validate_clamps_preset_fields() { assert_eq!(preset.arrow_length, Some(50.0)); assert_eq!(preset.arrow_angle, Some(15.0)); assert_eq!(preset.polygon_sides, Some(3)); + assert_eq!( + config.presets.slot_5, config.presets.slot_1, + "the same validation must reach the last supported slot" + ); } #[test] diff --git a/src/config/validate/presets.rs b/src/config/validate/presets.rs index e9c1c0f19..8f7f2785b 100644 --- a/src/config/validate/presets.rs +++ b/src/config/validate/presets.rs @@ -4,133 +4,143 @@ use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use super::super::types::{PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, ToolPresetConfig}; -impl Config { - pub(super) fn validate_presets(&mut self) { - if !(PRESET_SLOTS_MIN..=PRESET_SLOTS_MAX).contains(&self.presets.slot_count) { - log::warn!( - "Invalid preset slot_count {}, clamping to {}-{} range", - self.presets.slot_count, - PRESET_SLOTS_MIN, - PRESET_SLOTS_MAX - ); - self.presets.slot_count = self - .presets - .slot_count - .clamp(PRESET_SLOTS_MIN, PRESET_SLOTS_MAX); - } - - let clamp_preset = |slot: usize, preset: &mut ToolPresetConfig| { - let clamp_size = |value: &mut f64, label: &str| { - if !(MIN_STROKE_THICKNESS..=MAX_STROKE_THICKNESS).contains(&*value) { - log::warn!( - "Invalid preset {} {:.1} in slot {}, clamping to {:.1}-{:.1} range", - label, - *value, - slot, - MIN_STROKE_THICKNESS, - MAX_STROKE_THICKNESS - ); - *value = value.clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); - } - }; +#[derive(Clone, Copy)] +struct PresetFloatRange { + label: &'static str, + min: f64, + max: f64, + precision: usize, +} - if !(MIN_STROKE_THICKNESS..=MAX_STROKE_THICKNESS).contains(&preset.size) { - log::warn!( - "Invalid preset size {:.1} in slot {}, clamping to {:.1}-{:.1} range", - preset.size, - slot, - MIN_STROKE_THICKNESS, - MAX_STROKE_THICKNESS - ); - preset.size = preset - .size - .clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); - } +const MARKER_OPACITY: PresetFloatRange = PresetFloatRange { + label: "marker_opacity", + min: 0.05, + max: 0.9, + precision: 2, +}; +const FONT_SIZE: PresetFloatRange = PresetFloatRange { + label: "font_size", + min: 8.0, + max: 72.0, + precision: 1, +}; +const ARROW_LENGTH: PresetFloatRange = PresetFloatRange { + label: "arrow_length", + min: 5.0, + max: 50.0, + precision: 1, +}; +const ARROW_ANGLE: PresetFloatRange = PresetFloatRange { + label: "arrow_angle", + min: 15.0, + max: 60.0, + precision: 1, +}; - if let Some(tool_settings) = preset.tool_settings.as_mut() { - clamp_size(&mut tool_settings.pen.size, "pen size"); - clamp_size(&mut tool_settings.line.size, "line size"); - clamp_size(&mut tool_settings.rect.size, "rect size"); - clamp_size(&mut tool_settings.ellipse.size, "ellipse size"); - clamp_size(&mut tool_settings.arrow.size, "arrow size"); - clamp_size(&mut tool_settings.blur.size, "blur size"); - clamp_size(&mut tool_settings.marker.size, "marker size"); - clamp_size(&mut tool_settings.step_marker.size, "step marker size"); - clamp_size(&mut tool_settings.eraser_size, "eraser size"); +impl Config { + pub(super) fn validate_presets(&mut self) { + validate_slot_count(&mut self.presets.slot_count); + for (slot, preset) in [ + (1, self.presets.slot_1.as_mut()), + (2, self.presets.slot_2.as_mut()), + (3, self.presets.slot_3.as_mut()), + (4, self.presets.slot_4.as_mut()), + (5, self.presets.slot_5.as_mut()), + ] { + if let Some(preset) = preset { + validate_preset(slot, preset); } + } + } +} - if let Some(opacity) = preset.marker_opacity.as_mut() - && !(0.05..=0.9).contains(opacity) - { - log::warn!( - "Invalid marker_opacity {:.2} in preset slot {}, clamping to 0.05-0.90 range", - *opacity, - slot - ); - *opacity = opacity.clamp(0.05, 0.9); - } +fn validate_slot_count(slot_count: &mut usize) { + if (PRESET_SLOTS_MIN..=PRESET_SLOTS_MAX).contains(slot_count) { + return; + } + log::warn!( + "Invalid preset slot_count {}, clamping to {}-{} range", + *slot_count, + PRESET_SLOTS_MIN, + PRESET_SLOTS_MAX + ); + *slot_count = (*slot_count).clamp(PRESET_SLOTS_MIN, PRESET_SLOTS_MAX); +} - if let Some(size) = preset.font_size.as_mut() - && !(8.0..=72.0).contains(size) - { - log::warn!( - "Invalid font_size {:.1} in preset slot {}, clamping to 8.0-72.0 range", - *size, - slot - ); - *size = size.clamp(8.0, 72.0); - } +fn validate_preset(slot: usize, preset: &mut ToolPresetConfig) { + clamp_stroke_size(slot, "size", &mut preset.size); + if let Some(tool_settings) = preset.tool_settings.as_mut() { + for (label, size) in [ + ("pen size", &mut tool_settings.pen.size), + ("line size", &mut tool_settings.line.size), + ("rect size", &mut tool_settings.rect.size), + ("ellipse size", &mut tool_settings.ellipse.size), + ("arrow size", &mut tool_settings.arrow.size), + ("blur size", &mut tool_settings.blur.size), + ("marker size", &mut tool_settings.marker.size), + ("step marker size", &mut tool_settings.step_marker.size), + ("eraser size", &mut tool_settings.eraser_size), + ] { + clamp_stroke_size(slot, label, size); + } + } - if let Some(length) = preset.arrow_length.as_mut() - && !(5.0..=50.0).contains(length) - { - log::warn!( - "Invalid arrow_length {:.1} in preset slot {}, clamping to 5.0-50.0 range", - *length, - slot - ); - *length = length.clamp(5.0, 50.0); - } + clamp_optional_float(slot, &mut preset.marker_opacity, MARKER_OPACITY); + clamp_optional_float(slot, &mut preset.font_size, FONT_SIZE); + clamp_optional_float(slot, &mut preset.arrow_length, ARROW_LENGTH); + clamp_optional_float(slot, &mut preset.arrow_angle, ARROW_ANGLE); + clamp_polygon_sides(slot, &mut preset.polygon_sides); +} - if let Some(angle) = preset.arrow_angle.as_mut() - && !(15.0..=60.0).contains(angle) - { - log::warn!( - "Invalid arrow_angle {:.1} in preset slot {}, clamping to 15.0-60.0 range", - *angle, - slot - ); - *angle = angle.clamp(15.0, 60.0); - } +fn clamp_stroke_size(slot: usize, label: &str, value: &mut f64) { + if (MIN_STROKE_THICKNESS..=MAX_STROKE_THICKNESS).contains(value) { + return; + } + log::warn!( + "Invalid preset {} {:.1} in slot {}, clamping to {:.1}-{:.1} range", + label, + *value, + slot, + MIN_STROKE_THICKNESS, + MAX_STROKE_THICKNESS + ); + *value = value.clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS); +} - if let Some(sides) = preset.polygon_sides.as_mut() - && !(REGULAR_POLYGON_MIN_SIDES..=REGULAR_POLYGON_MAX_SIDES).contains(sides) - { - log::warn!( - "Invalid polygon_sides {} in preset slot {}, clamping to {}-{} range", - *sides, - slot, - REGULAR_POLYGON_MIN_SIDES, - REGULAR_POLYGON_MAX_SIDES - ); - *sides = clamp_regular_sides(*sides); - } - }; +fn clamp_optional_float(slot: usize, value: &mut Option, range: PresetFloatRange) { + let Some(value) = value.as_mut() else { + return; + }; + if (range.min..=range.max).contains(value) { + return; + } + log::warn!( + "Invalid {} {:.*} in preset slot {}, clamping to {:.*}-{:.*} range", + range.label, + range.precision, + *value, + slot, + range.precision, + range.min, + range.precision, + range.max + ); + *value = value.clamp(range.min, range.max); +} - if let Some(preset) = self.presets.slot_1.as_mut() { - clamp_preset(1, preset); - } - if let Some(preset) = self.presets.slot_2.as_mut() { - clamp_preset(2, preset); - } - if let Some(preset) = self.presets.slot_3.as_mut() { - clamp_preset(3, preset); - } - if let Some(preset) = self.presets.slot_4.as_mut() { - clamp_preset(4, preset); - } - if let Some(preset) = self.presets.slot_5.as_mut() { - clamp_preset(5, preset); - } +fn clamp_polygon_sides(slot: usize, sides: &mut Option) { + let Some(sides) = sides.as_mut() else { + return; + }; + if (REGULAR_POLYGON_MIN_SIDES..=REGULAR_POLYGON_MAX_SIDES).contains(sides) { + return; } + log::warn!( + "Invalid polygon_sides {} in preset slot {}, clamping to {}-{} range", + *sides, + slot, + REGULAR_POLYGON_MIN_SIDES, + REGULAR_POLYGON_MAX_SIDES + ); + *sides = clamp_regular_sides(*sides); } From 59f8af74cdf042487adce560fb559cca08c264fa Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:37:09 +0200 Subject: [PATCH 09/10] perf(runtime): defer fallback lookups --- src/backend/wayland/handlers/pointer/cursor.rs | 7 ++++--- src/backend/wayland/state/toolbar/visibility/pointer.rs | 5 ++++- src/backend/wayland/toolbar/main/render.rs | 4 +++- src/cli.rs | 2 +- src/daemon/overlay/mod.rs | 4 +++- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index d6fb7fd83..29afa6d1e 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -373,9 +373,10 @@ impl WaylandState { let Some(pointer) = self.current_pointer() else { return; }; - let serial = pointer - .data::() - .and_then(|data| data.latest_button_serial().or(data.latest_enter_serial())); + let serial = pointer.data::().and_then(|data| { + data.latest_button_serial() + .or_else(|| data.latest_enter_serial()) + }); let Some(serial) = serial else { return; }; diff --git a/src/backend/wayland/state/toolbar/visibility/pointer.rs b/src/backend/wayland/state/toolbar/visibility/pointer.rs index 210acce6b..c0cc129bc 100644 --- a/src/backend/wayland/state/toolbar/visibility/pointer.rs +++ b/src/backend/wayland/state/toolbar/visibility/pointer.rs @@ -65,7 +65,10 @@ impl WaylandState { // Hide the cursor while dragging with pointer lock to avoid visual jitter. if pointer .data::() - .and_then(|data| data.latest_button_serial().or(data.latest_enter_serial())) + .and_then(|data| { + data.latest_button_serial() + .or_else(|| data.latest_enter_serial()) + }) .is_some() { self.hide_pointer_cursor(); diff --git a/src/backend/wayland/toolbar/main/render.rs b/src/backend/wayland/toolbar/main/render.rs index bf6a6c948..3f7bf7a0e 100644 --- a/src/backend/wayland/toolbar/main/render.rs +++ b/src/backend/wayland/toolbar/main/render.rs @@ -16,7 +16,9 @@ impl ToolbarSurfaceManager { return; } self.top.set_ui_scale(snapshot.toolbar_scale); - let top_hover = hover.or(self.top_hover).or(self.top.focused_hover()); + let top_hover = hover + .or(self.top_hover) + .or_else(|| self.top.focused_hover()); let top_hover_start = self.top_hover_start; if let Err(err) = self.top.render( shm, diff --git a/src/cli.rs b/src/cli.rs index 84bd40b3a..e80fd52b2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -521,7 +521,7 @@ fn value_after(args: &[String], index: usize, name: &str) -> Result Result { let value = arg[value_start..] .strip_prefix('=') - .unwrap_or(&arg[value_start..]); + .unwrap_or_else(|| &arg[value_start..]); if value.is_empty() { return Err(format!("{name} requires a value")); } diff --git a/src/daemon/overlay/mod.rs b/src/daemon/overlay/mod.rs index c1602573b..14ac2722e 100644 --- a/src/daemon/overlay/mod.rs +++ b/src/daemon/overlay/mod.rs @@ -42,7 +42,9 @@ impl Daemon { .pending_toggle_request .as_ref() .and_then(|request| request.session_resume_override()); - set_runtime_session_override(request_override.or(self.session_resume_override())); + set_runtime_session_override( + request_override.or_else(|| self.session_resume_override()), + ); let requested_mode = self .pending_toggle_request .as_ref() From 83b6a619fe11e8b83afc44ed3a19228e014bfd93 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 29 Aug 2026 23:39:42 +0200 Subject: [PATCH 10/10] perf(session): reuse transition allocations --- src/backend/wayland/backend/state_init/session.rs | 2 +- src/backend/wayland/state/core/output/focus.rs | 4 +--- src/backend/wayland/state/core/output/transition.rs | 3 +-- src/backend/wayland/state/toolbar/events/session.rs | 2 +- src/input/state/core/session_preflight_exact.rs | 2 +- 5 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/backend/wayland/backend/state_init/session.rs b/src/backend/wayland/backend/state_init/session.rs index db94f3286..35637246f 100644 --- a/src/backend/wayland/backend/state_init/session.rs +++ b/src/backend/wayland/backend/state_init/session.rs @@ -43,7 +43,7 @@ pub(super) fn build_session_options( let default_base = paths::data_dir() .unwrap_or_else(|| config_dir.to_path_buf()) .join("wayscriber"); - let display = display_env.clone().unwrap_or_else(|| "default".to_string()); + let display = display_env.unwrap_or_else(|| "default".to_string()); session_options = Some(session::SessionOptions::new(default_base, display)); } if let Some(options) = session_options.as_mut() { diff --git a/src/backend/wayland/state/core/output/focus.rs b/src/backend/wayland/state/core/output/focus.rs index d8d4d5dd4..a160f878f 100644 --- a/src/backend/wayland/state/core/output/focus.rs +++ b/src/backend/wayland/state/core/output/focus.rs @@ -44,9 +44,7 @@ impl WaylandState { } let surface_current_output = self.surface.current_output(); - let current_output = surface_current_output - .clone() - .or_else(|| self.preferred_fullscreen_output()); + let current_output = surface_current_output.or_else(|| self.preferred_fullscreen_output()); let current_index = current_output .as_ref() .and_then(|current| outputs.iter().position(|output| output == current)) diff --git a/src/backend/wayland/state/core/output/transition.rs b/src/backend/wayland/state/core/output/transition.rs index 97a2152a4..d8748a26f 100644 --- a/src/backend/wayland/state/core/output/transition.rs +++ b/src/backend/wayland/state/core/output/transition.rs @@ -6,10 +6,9 @@ impl WaylandState { physical_output_identity: Option, reason: &str, ) { - let Some(current_options) = self.session_options().cloned() else { + let Some(mut staged_options) = self.session_options().cloned() else { return; }; - let mut staged_options = current_options.clone(); let changed = staged_options.set_output_identity(physical_output_identity.as_deref()); let same_epoch_pending = self .session diff --git a/src/backend/wayland/state/toolbar/events/session.rs b/src/backend/wayland/state/toolbar/events/session.rs index 57b82ba82..ab8c0b521 100644 --- a/src/backend/wayland/state/toolbar/events/session.rs +++ b/src/backend/wayland/state/toolbar/events/session.rs @@ -11,7 +11,6 @@ pub(super) fn populate_session_snapshot( ) { let active_path = options.map(|options| options.session_file_path()); snapshot.active_session_name = active_path.as_deref().map(session_display_name); - snapshot.active_session_path = active_path.clone(); // Recents are only read (from the catalog on disk) while the top strip's // Session popover is up. snapshot.recent_sessions = if snapshot.session_popover_open { @@ -19,6 +18,7 @@ pub(super) fn populate_session_snapshot( } else { Vec::new() }; + snapshot.active_session_path = active_path; } fn session_display_name(path: &Path) -> String { diff --git a/src/input/state/core/session_preflight_exact.rs b/src/input/state/core/session_preflight_exact.rs index e17eb19df..c2b36d4f4 100644 --- a/src/input/state/core/session_preflight_exact.rs +++ b/src/input/state/core/session_preflight_exact.rs @@ -189,7 +189,7 @@ fn duplicate_active_board_in_snapshot(input: &InputState, snapshot: &mut Session }; cloned.pages.active = source_board.pages.active_index(); let insert_at = (source_index + 1).min(snapshot.boards.len()); - snapshot.active_board_id = cloned.id.clone(); + snapshot.active_board_id.clone_from(&cloned.id); snapshot.boards.insert(insert_at, cloned); true }