From ede15eb3e6b333bddfd4c6e73c0e32e980a50a57 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:37:44 +0200 Subject: [PATCH] Improve input-to-paint latency for active drawing - add env-gated performance logging for input-to-paint timing - default rendering to no-vsync with a 120 FPS cap - reduce active path redraws to dirty tail regions - keep provisional cleanup bounds correct for pressure/size changes - preserve translucent pressure preview alpha semantics - update performance docs and configurator labels --- README.md | 15 +- config.example.toml | 8 +- configurator/src/app/view/performance.rs | 4 +- docs/CONFIG.md | 43 +- .../wayland/backend/event_loop/render.rs | 1 + .../wayland/handlers/pointer/motion.rs | 9 + src/backend/wayland/handlers/tablet/frame.rs | 20 +- src/backend/wayland/handlers/touch.rs | 3 +- src/backend/wayland/state.rs | 3 + src/backend/wayland/state/core/init.rs | 1 + src/backend/wayland/state/perf.rs | 624 ++++++++++++++++++ .../wayland/state/render/canvas/mod.rs | 4 +- src/backend/wayland/state/render/mod.rs | 7 + src/config/core.rs | 3 +- src/config/types/performance.rs | 18 +- src/env_vars.rs | 1 + src/input/state/core/dirty.rs | 78 ++- .../state/core/tool_controls/settings.rs | 39 +- src/input/state/render.rs | 247 +++++++ src/input/state/tests/drawing.rs | 160 +++++ 20 files changed, 1245 insertions(+), 43 deletions(-) create mode 100644 src/backend/wayland/state/perf.rs diff --git a/README.md b/README.md index 1bc0fa7d..79b46744 100644 --- a/README.md +++ b/README.md @@ -782,8 +782,8 @@ size = 3.0 [performance] buffer_count = 3 -enable_vsync = true -max_fps_no_vsync = 60 +enable_vsync = false +max_fps_no_vsync = 120 ui_animation_fps = 30 [ui] @@ -909,13 +909,20 @@ See `docs/CONFIG.md` for the full list. ### Performance tuning +Default behavior prioritizes lower drawing latency by disabling vsync and capping no-vsync rendering: + ```toml [performance] -buffer_count = 2 -enable_vsync = true +buffer_count = 3 +enable_vsync = false +max_fps_no_vsync = 120 ui_animation_fps = 30 ``` +Use `120` as a strong low-latency cap for common systems. Try `144`, `165`, `240`, or higher if it matches your display and the machine handles the extra rendering work. Use `max_fps_no_vsync = 0` only for profiling because uncapped rendering can spin CPU/GPU hard. + +Set `enable_vsync = true` when tear-free presentation, lower power use, or quieter behavior matters more than input latency. Vsync usually adds a frame-cadence floor, especially on 60 Hz displays; disabling it improves input latency but may allow tearing and higher CPU/GPU usage. + --- ## Contributing diff --git a/config.example.toml b/config.example.toml index b324ccbe..8a6231ce 100644 --- a/config.example.toml +++ b/config.example.toml @@ -964,12 +964,12 @@ head_at_end = false buffer_count = 3 # Enable vsync frame synchronization -# Prevents tearing and limits rendering to display refresh rate -enable_vsync = true +# false lowers drawing latency; true prevents tearing and limits rendering to display refresh rate +enable_vsync = false # Max FPS when VSync is disabled (0 = unlimited) -# Prevents CPU spinning at very high FPS; set to match your monitor (60/120/144/240) -max_fps_no_vsync = 60 +# 120 keeps pen latency low without uncapped CPU usage; set to 0 only for profiling +max_fps_no_vsync = 120 # UI animation frame rate (0 = unlimited) # Higher values smooth UI effects at the cost of more redraws diff --git a/configurator/src/app/view/performance.rs b/configurator/src/app/view/performance.rs index 610c76c8..1dab8a9b 100644 --- a/configurator/src/app/view/performance.rs +++ b/configurator/src/app/view/performance.rs @@ -57,10 +57,10 @@ impl ConfiguratorApp { &self.draft.performance_max_fps_no_vsync, &self.defaults.performance_max_fps_no_vsync, TextField::PerformanceMaxFpsNoVsync, - Some("0 = unlimited, or match your monitor (60/120/144/240)"), + Some("Default 120; try 144/240 on high-refresh displays; 0 = unlimited"), validate_u32_range(&self.draft.performance_max_fps_no_vsync, 0, 1000), )) - .push(text("Caps frame rate when VSync is disabled. Prevents CPU spinning at 500+ FPS. Set to your monitor's refresh rate for best results, or 0 for unlimited (requires strong CPU).").size(12)); + .push(text("Caps frame rate when VSync is disabled. 120 FPS keeps drawing latency low without uncapped CPU/GPU usage; use 0 only for profiling.").size(12)); } if show_animation { diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ca773f19..cb39635d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -244,12 +244,12 @@ Controls rendering performance and smoothness. buffer_count = 3 # Enable vsync frame synchronization -# Prevents tearing and limits rendering to display refresh rate -enable_vsync = true +# false lowers drawing latency; true prevents tearing and limits rendering to display refresh rate +enable_vsync = false # Max FPS when VSync is disabled (0 = unlimited) -# Prevents CPU spinning at very high FPS; set to match your monitor -max_fps_no_vsync = 60 +# 120 keeps pen latency low without uncapped CPU usage; set to 0 only for profiling +max_fps_no_vsync = 120 # UI animation frame rate (0 = unlimited) # Higher values smooth UI effects at the cost of more redraws @@ -262,13 +262,14 @@ ui_animation_fps = 30 - **4**: Quad buffering - for high-refresh displays (144Hz+), ultra-smooth **VSync:** -- **true** (default): Synchronizes with display refresh rate, no tearing -- **false**: Capped by `max_fps_no_vsync` (set to 0 for uncapped); may cause tearing but lower latency +- **false** (default): Capped by `max_fps_no_vsync`; lower drawing latency, with possible tearing +- **true**: Synchronizes with display refresh rate, no tearing, but input-to-commit latency is bounded by refresh cadence **Max FPS (VSync off):** -- **60** (default): Suitable for most displays -- **0**: Unlimited (uncapped; higher CPU usage) -- Set to your monitor refresh (60/120/144/240) for best balance +- **120** (default): Low-latency drawing without uncapped redraw loops +- **60**: Lower CPU/GPU use, but latency may feel closer to one 60 Hz frame interval +- **144/165/240+**: Use when it matches your display and the machine handles the extra rendering work +- **0**: Unlimited; mostly for profiling because it can spin CPU/GPU hard **UI Animation FPS:** - **30** (default): Smooth enough for most effects @@ -277,10 +278,24 @@ ui_animation_fps = 30 **Defaults:** - Buffer count: 3 (triple buffering) -- VSync: true -- Max FPS (VSync off): 60 +- VSync: false +- Max FPS (VSync off): 120 - UI animation FPS: 30 +**Tradeoff:** +Disabling vsync improves input latency but may allow tearing and higher CPU/GPU usage. On weaker +PCs, laptops, or battery-sensitive setups, restore `enable_vsync = true` or lower +`max_fps_no_vsync` if you notice heat, fan noise, battery drain, or compositor smoothness issues. + +**Measurement note:** +With `WAYSCRIBER_PERF_LOG=1`, the `perf.input_to_paint_latency proxy=input_to_wayland_commit` +line reports an input-to-Wayland-commit proxy metric. It measures from input sample receipt inside +the app to Wayland surface commit. It is not photons-on-screen display latency; compositor +scheduling, display scanout, and hardware can add more latency outside Wayscriber. + +In local continuous-drawing measurements, 120 FPS low-latency mode held p95 around 8-9 ms and +p99 around 8-9 ms for this proxy metric. Isolated max spikes existed, but p99 stayed under 16 ms. + ### `[ui]` - User Interface Controls visual indicators, overlays, and UI styling. @@ -1392,7 +1407,8 @@ default_font_size = 24.0 [performance] buffer_count = 4 -enable_vsync = true +enable_vsync = false +max_fps_no_vsync = 120 ui_animation_fps = 30 [ui] @@ -1416,7 +1432,8 @@ status_bar_position = "top-right" ```toml [performance] buffer_count = 4 -enable_vsync = true +enable_vsync = false +max_fps_no_vsync = 144 ui_animation_fps = 120 ``` diff --git a/src/backend/wayland/backend/event_loop/render.rs b/src/backend/wayland/backend/event_loop/render.rs index e1d6372a..ae1574c6 100644 --- a/src/backend/wayland/backend/event_loop/render.rs +++ b/src/backend/wayland/backend/event_loop/render.rs @@ -85,6 +85,7 @@ pub(super) fn maybe_render( state.surface.frame_callback_pending() ); let render_start = Instant::now(); + state.begin_perf_render(render_start); match state.render(qh) { Ok(keep_rendering) => { let render_duration = render_start.elapsed(); diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs index 11337dd6..598b666b 100644 --- a/src/backend/wayland/handlers/pointer/motion.rs +++ b/src/backend/wayland/handlers/pointer/motion.rs @@ -2,6 +2,7 @@ use log::debug; use smithay_client_toolkit::seat::pointer::PointerEvent; use wayland_client::Connection; +use crate::backend::wayland::state::PerfInputSource; use crate::backend::wayland::state::drag_log; use crate::backend::wayland::toolbar_intent::intent_to_event; @@ -167,5 +168,13 @@ impl WaylandState { wx, wy, ); + self.record_perf_input_sample( + PerfInputSource::Pointer, + event.position.0.round() as i32, + event.position.1.round() as i32, + wx, + wy, + false, + ); } } diff --git a/src/backend/wayland/handlers/tablet/frame.rs b/src/backend/wayland/handlers/tablet/frame.rs index 6c6f786c..0360241f 100644 --- a/src/backend/wayland/handlers/tablet/frame.rs +++ b/src/backend/wayland/handlers/tablet/frame.rs @@ -1,6 +1,6 @@ use log::{debug, info}; -use crate::backend::wayland::state::WaylandState; +use crate::backend::wayland::state::{PerfInputSource, WaylandState}; use crate::input::MouseButton; /// Linux input event code for the primary stylus barrel button. @@ -49,7 +49,7 @@ impl WaylandState { } if let Some((x, y)) = pending.motion { - self.commit_stylus_motion_sample(x, y); + self.commit_stylus_motion_sample(x, y, pending.pressure.is_some()); } if pending.down { @@ -61,7 +61,7 @@ impl WaylandState { && !pending.down && self.stylus_tip_down { - self.commit_stylus_motion_sample_at_current_position(); + self.commit_stylus_motion_sample_at_current_position(true); } if pending.up { @@ -107,13 +107,21 @@ impl WaylandState { self.record_stylus_peak(self.input_state.current_thickness); } - fn commit_stylus_motion_sample(&mut self, x: f64, y: f64) { + fn commit_stylus_motion_sample(&mut self, x: f64, y: f64, pressure_sample: bool) { let previous_hover_cursor_pos = self.stylus_hover_cursor_position(); self.set_current_mouse(x as i32, y as i32); self.stylus_last_pos = Some((x, y)); let (wx, wy) = self.zoomed_world_coords(x, y); self.input_state .on_mouse_motion_with_canvas(x.round() as i32, y.round() as i32, wx, wy); + self.record_perf_input_sample( + PerfInputSource::Stylus, + x.round() as i32, + y.round() as i32, + wx, + wy, + pressure_sample, + ); let next_hover_cursor_pos = self.stylus_hover_cursor_position(); self.mark_stylus_hover_cursor_dirty(previous_hover_cursor_pos, next_hover_cursor_pos); if self.stylus_tip_down { @@ -121,9 +129,9 @@ impl WaylandState { } } - fn commit_stylus_motion_sample_at_current_position(&mut self) { + fn commit_stylus_motion_sample_at_current_position(&mut self, pressure_sample: bool) { let (x, y) = self.current_stylus_position(); - self.commit_stylus_motion_sample(x, y); + self.commit_stylus_motion_sample(x, y, pressure_sample); } fn commit_stylus_down(&mut self) { diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs index 29bf1ca9..01cb3cf2 100644 --- a/src/backend/wayland/handlers/touch.rs +++ b/src/backend/wayland/handlers/touch.rs @@ -6,7 +6,7 @@ use wayland_client::{ }; use crate::backend::wayland::state::{ - TouchTarget, WaylandState, debug_toolbar_drag_logging_enabled, + PerfInputSource, TouchTarget, WaylandState, debug_toolbar_drag_logging_enabled, }; use crate::backend::wayland::toolbar_intent::intent_to_event; use crate::input::MouseButton; @@ -325,6 +325,7 @@ impl WaylandState { .update_pointer_positions(screen_x, screen_y, wx, wy); self.input_state .on_mouse_motion_with_canvas(screen_x, screen_y, wx, wy); + self.record_perf_input_sample(PerfInputSource::Touch, screen_x, screen_y, wx, wy, false); } fn handle_touch_up( diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index df5e4a09..f229aaf8 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -93,6 +93,7 @@ mod helpers; mod input_actions; mod onboarding; mod pdf_export; +mod perf; mod render; mod toolbar; mod zoom; @@ -108,6 +109,7 @@ pub(super) use helpers::{ scale_damage_regions, surface_id, toolbar_drag_preview_enabled, toolbar_drag_throttle_interval, toolbar_pointer_lock_enabled, }; +pub(in crate::backend::wayland) use perf::PerfInputSource; pub(in crate::backend::wayland) struct WaylandGlobals { pub registry_state: RegistryState, @@ -181,6 +183,7 @@ pub(super) struct WaylandState { pub(super) capture: CaptureState, pub(super) frozen: FrozenState, pub(super) zoom: ZoomState, + perf: perf::PerfMetrics, // Overlay behavior pub(super) exit_after_capture_mode: ExitAfterCaptureMode, diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index 1cb0b1b7..a18b1f9d 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -111,6 +111,7 @@ impl WaylandState { capture: CaptureState::new(capture_manager), frozen: FrozenState::new(screencopy_manager), zoom: ZoomState::new(zoom_manager), + perf: perf::PerfMetrics::from_env(), exit_after_capture_mode, themed_pointer: None, touch: None, diff --git a/src/backend/wayland/state/perf.rs b/src/backend/wayland/state/perf.rs new file mode 100644 index 00000000..3856c52c --- /dev/null +++ b/src/backend/wayland/state/perf.rs @@ -0,0 +1,624 @@ +use std::{ + collections::VecDeque, + fmt, + time::{Duration, Instant}, +}; + +use log::info; + +use crate::{ + env_vars::PERF_LOG_ENV, + input::{DrawingState, Tool}, + util::Rect, +}; + +use super::WaylandState; + +const MAX_PENDING_INPUT_SAMPLES: usize = 4096; +const MAX_RECENT_LATENCIES: usize = 2048; +const SUMMARY_FRAME_INTERVAL: u64 = 120; +const SUMMARY_INTERVAL: Duration = Duration::from_secs(5); +const SLOW_INPUT_TO_COMMIT: Duration = Duration::from_millis(50); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(not(tablet), allow(dead_code))] +pub(in crate::backend::wayland) enum PerfInputSource { + Pointer, + Touch, + Stylus, +} + +impl fmt::Display for PerfInputSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pointer => f.write_str("pointer"), + Self::Touch => f.write_str("touch"), + Self::Stylus => f.write_str("stylus"), + } + } +} + +#[derive(Clone, Debug)] +struct PerfInputSample { + received_at: Instant, + source: PerfInputSource, + tool: Tool, + point_count: usize, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + pressure_sample: bool, +} + +#[derive(Clone, Copy, Debug)] +struct PerfFrameContext { + render_duration: Option, + dirty_area_pct: f64, + full_damage: bool, + damage_rects: usize, +} + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Clone, Debug, PartialEq)] +struct PerfCommitReport { + sample_count: usize, + max_latency_ms: u64, + slow_frame: Option, + summary: Option, +} + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Clone, Debug, PartialEq)] +struct PerfSlowFrame { + latency_ms: u64, + source: PerfInputSource, + tool: Tool, + point_count: usize, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + pressure_sample: bool, + render_ms: Option, + dirty_area_pct: f64, + full_damage: bool, + damage_rects: usize, + dropped_input_samples: u64, +} + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Clone, Debug, PartialEq, Eq)] +struct PerfSummary { + frames: u64, + samples: u64, + window_samples: usize, + p50_ms: u64, + p95_ms: u64, + p99_ms: u64, + max_ms: u64, + dropped_input_samples: u64, +} + +#[derive(Debug)] +pub(super) struct PerfMetrics { + enabled: bool, + pending_input_samples: VecDeque, + recent_latencies_ms: VecDeque, + render_started_at: Option, + frames_since_summary: u64, + samples_since_summary: u64, + dropped_input_samples: u64, + last_summary_at: Option, +} + +impl PerfMetrics { + pub(super) fn from_env() -> Self { + let enabled = perf_log_enabled_from_env(); + if enabled { + info!("Performance logging enabled via {PERF_LOG_ENV}=1"); + } + Self::new(enabled) + } + + fn new(enabled: bool) -> Self { + Self { + enabled, + pending_input_samples: VecDeque::new(), + recent_latencies_ms: VecDeque::new(), + render_started_at: None, + frames_since_summary: 0, + samples_since_summary: 0, + dropped_input_samples: 0, + last_summary_at: None, + } + } + + pub(super) fn enabled(&self) -> bool { + self.enabled + } + + pub(super) fn begin_render(&mut self, now: Instant) { + if !self.enabled { + return; + } + self.render_started_at = Some(now); + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn record_input_sample( + &mut self, + source: PerfInputSource, + tool: Tool, + point_count: usize, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + pressure_sample: bool, + received_at: Instant, + ) { + if !self.enabled { + return; + } + if self.pending_input_samples.len() == MAX_PENDING_INPUT_SAMPLES { + self.pending_input_samples.pop_front(); + self.dropped_input_samples += 1; + } + self.pending_input_samples.push_back(PerfInputSample { + received_at, + source, + tool, + point_count, + screen_x, + screen_y, + canvas_x, + canvas_y, + pressure_sample, + }); + } + + fn commit_frame( + &mut self, + frame: PerfFrameContext, + commit_at: Instant, + ) -> Option { + if !self.enabled { + return None; + } + + let render_duration = frame.render_duration.or_else(|| { + self.render_started_at + .map(|start| commit_at.saturating_duration_since(start)) + }); + self.render_started_at = None; + + let mut sample_count: usize = 0; + let mut max_latency = Duration::ZERO; + let mut slowest_sample = None; + + while let Some(sample) = self.pending_input_samples.pop_front() { + let latency = commit_at.saturating_duration_since(sample.received_at); + self.push_latency_ms(duration_ms(latency)); + sample_count += 1; + if latency >= max_latency { + max_latency = latency; + slowest_sample = Some(sample); + } + } + + self.frames_since_summary += 1; + self.samples_since_summary += sample_count as u64; + if self.last_summary_at.is_none() { + self.last_summary_at = Some(commit_at); + } + + let slow_frame = if max_latency >= SLOW_INPUT_TO_COMMIT { + slowest_sample.map(|sample| PerfSlowFrame { + latency_ms: duration_ms(max_latency), + source: sample.source, + tool: sample.tool, + point_count: sample.point_count, + screen_x: sample.screen_x, + screen_y: sample.screen_y, + canvas_x: sample.canvas_x, + canvas_y: sample.canvas_y, + pressure_sample: sample.pressure_sample, + render_ms: render_duration.map(duration_ms), + dirty_area_pct: frame.dirty_area_pct, + full_damage: frame.full_damage, + damage_rects: frame.damage_rects, + dropped_input_samples: self.dropped_input_samples, + }) + } else { + None + }; + + if let Some(slow) = slow_frame.as_ref() { + info!( + "perf.slow_input_to_paint proxy=input_to_wayland_commit latency_ms={} source={} tool={:?} points={} pressure_sample={} screen=({}, {}) canvas=({}, {}) render_ms={} dirty_area_pct={:.2} full_damage={} damage_rects={} dropped_input_samples={}", + slow.latency_ms, + slow.source, + slow.tool, + slow.point_count, + slow.pressure_sample, + slow.screen_x, + slow.screen_y, + slow.canvas_x, + slow.canvas_y, + format_optional_ms(slow.render_ms), + slow.dirty_area_pct, + slow.full_damage, + slow.damage_rects, + slow.dropped_input_samples + ); + } + + let summary = if self.summary_due(commit_at) && !self.recent_latencies_ms.is_empty() { + let summary = self.build_summary(); + info!( + "perf.input_to_paint_latency proxy=input_to_wayland_commit frames={} samples={} window_samples={} p50_ms={} p95_ms={} p99_ms={} max_ms={} dropped_input_samples={}", + summary.frames, + summary.samples, + summary.window_samples, + summary.p50_ms, + summary.p95_ms, + summary.p99_ms, + summary.max_ms, + summary.dropped_input_samples + ); + self.frames_since_summary = 0; + self.samples_since_summary = 0; + self.last_summary_at = Some(commit_at); + Some(summary) + } else { + None + }; + + Some(PerfCommitReport { + sample_count, + max_latency_ms: duration_ms(max_latency), + slow_frame, + summary, + }) + } + + fn push_latency_ms(&mut self, latency_ms: u64) { + if self.recent_latencies_ms.len() == MAX_RECENT_LATENCIES { + self.recent_latencies_ms.pop_front(); + } + self.recent_latencies_ms.push_back(latency_ms); + } + + fn summary_due(&self, now: Instant) -> bool { + if self.frames_since_summary >= SUMMARY_FRAME_INTERVAL { + return true; + } + self.last_summary_at + .is_some_and(|last| now.saturating_duration_since(last) >= SUMMARY_INTERVAL) + } + + fn build_summary(&self) -> PerfSummary { + let mut sorted = self.recent_latencies_ms.iter().copied().collect::>(); + sorted.sort_unstable(); + + PerfSummary { + frames: self.frames_since_summary, + samples: self.samples_since_summary, + window_samples: sorted.len(), + p50_ms: percentile_nearest_rank(&sorted, 50).unwrap_or(0), + p95_ms: percentile_nearest_rank(&sorted, 95).unwrap_or(0), + p99_ms: percentile_nearest_rank(&sorted, 99).unwrap_or(0), + max_ms: sorted.last().copied().unwrap_or(0), + dropped_input_samples: self.dropped_input_samples, + } + } +} + +impl WaylandState { + pub(in crate::backend::wayland) fn begin_perf_render(&mut self, now: Instant) { + self.perf.begin_render(now); + } + + pub(in crate::backend::wayland) fn record_perf_input_sample( + &mut self, + source: PerfInputSource, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + pressure_sample: bool, + ) { + if !self.perf.enabled() { + return; + } + let Some((tool, point_count)) = self.active_drawing_perf_context() else { + return; + }; + self.perf.record_input_sample( + source, + tool, + point_count, + screen_x, + screen_y, + canvas_x, + canvas_y, + pressure_sample, + Instant::now(), + ); + } + + pub(in crate::backend::wayland) fn commit_perf_frame( + &mut self, + damage_screen: &[Rect], + logical_width: u32, + logical_height: u32, + damage_rects: usize, + commit_at: Instant, + ) { + if !self.perf.enabled() { + return; + } + let frame = PerfFrameContext { + render_duration: None, + dirty_area_pct: damage_area_pct(damage_screen, logical_width, logical_height), + full_damage: damage_covers_surface(damage_screen, logical_width, logical_height), + damage_rects, + }; + let _ = self.perf.commit_frame(frame, commit_at); + } + + fn active_drawing_perf_context(&self) -> Option<(Tool, usize)> { + let DrawingState::Drawing { tool, points, .. } = &self.input_state.state else { + return None; + }; + Some((*tool, points.len())) + } +} + +fn perf_log_enabled_from_env() -> bool { + std::env::var(PERF_LOG_ENV) + .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "on" | "ON")) + .unwrap_or(false) +} + +fn duration_ms(duration: Duration) -> u64 { + duration.as_millis().min(u128::from(u64::MAX)) as u64 +} + +fn format_optional_ms(value: Option) -> String { + value.map_or_else(|| "n/a".to_string(), |ms| ms.to_string()) +} + +fn percentile_nearest_rank(sorted_values: &[u64], percentile: u64) -> Option { + if sorted_values.is_empty() { + return None; + } + let rank = ((percentile as f64 / 100.0) * sorted_values.len() as f64).ceil() as usize; + let index = rank.saturating_sub(1).min(sorted_values.len() - 1); + sorted_values.get(index).copied() +} + +fn damage_area_pct(damage: &[Rect], logical_width: u32, logical_height: u32) -> f64 { + let surface_area = u64::from(logical_width).saturating_mul(u64::from(logical_height)); + if surface_area == 0 { + return 0.0; + } + + let damage_area = damage + .iter() + .map(|rect| clamped_rect_area(*rect, logical_width, logical_height)) + .sum::(); + ((damage_area as f64 / surface_area as f64) * 100.0).min(100.0) +} + +fn damage_covers_surface(damage: &[Rect], logical_width: u32, logical_height: u32) -> bool { + let width = logical_width.min(i32::MAX as u32) as i32; + let height = logical_height.min(i32::MAX as u32) as i32; + damage + .iter() + .any(|rect| rect.x <= 0 && rect.y <= 0 && rect.width >= width && rect.height >= height) +} + +fn clamped_rect_area(rect: Rect, logical_width: u32, logical_height: u32) -> u64 { + let max_x = logical_width.min(i32::MAX as u32) as i32; + let max_y = logical_height.min(i32::MAX as u32) as i32; + let left = rect.x.clamp(0, max_x); + let top = rect.y.clamp(0, max_y); + let right = rect.x.saturating_add(rect.width).clamp(0, max_x); + let bottom = rect.y.saturating_add(rect.height).clamp(0, max_y); + if right <= left || bottom <= top { + return 0; + } + (right - left) as u64 * (bottom - top) as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record_sample(metrics: &mut PerfMetrics, received_at: Instant) { + metrics.record_input_sample( + PerfInputSource::Pointer, + Tool::Pen, + 12, + 10, + 20, + 30, + 40, + false, + received_at, + ); + } + + #[test] + fn percentile_uses_nearest_rank() { + let values = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + assert_eq!(percentile_nearest_rank(&values, 50), Some(5)); + assert_eq!(percentile_nearest_rank(&values, 95), Some(10)); + assert_eq!(percentile_nearest_rank(&values, 99), Some(10)); + assert_eq!(percentile_nearest_rank(&[], 95), None); + } + + #[test] + fn disabled_metrics_do_not_store_or_report_samples() { + let base = Instant::now(); + let mut metrics = PerfMetrics::new(false); + + record_sample(&mut metrics, base); + let report = metrics.commit_frame( + PerfFrameContext { + render_duration: Some(Duration::from_millis(2)), + dirty_area_pct: 1.0, + full_damage: false, + damage_rects: 1, + }, + base + Duration::from_millis(16), + ); + + assert!(report.is_none()); + assert!(metrics.pending_input_samples.is_empty()); + assert!(metrics.recent_latencies_ms.is_empty()); + } + + #[test] + fn fake_input_to_commit_flow_records_latency_and_slow_frame_context() { + let base = Instant::now(); + let mut metrics = PerfMetrics::new(true); + metrics.begin_render(base + Duration::from_millis(40)); + record_sample(&mut metrics, base); + metrics.record_input_sample( + PerfInputSource::Stylus, + Tool::Marker, + 27, + 100, + 110, + 120, + 130, + true, + base + Duration::from_millis(10), + ); + + let report = metrics + .commit_frame( + PerfFrameContext { + render_duration: None, + dirty_area_pct: 2.5, + full_damage: false, + damage_rects: 3, + }, + base + Duration::from_millis(70), + ) + .expect("enabled metrics should report commits"); + + assert_eq!(report.sample_count, 2); + assert_eq!(report.max_latency_ms, 70); + let slow = report.slow_frame.expect("slow sample should be reported"); + assert_eq!(slow.source, PerfInputSource::Pointer); + assert_eq!(slow.tool, Tool::Pen); + assert_eq!(slow.point_count, 12); + assert_eq!(slow.render_ms, Some(30)); + assert_eq!( + metrics + .recent_latencies_ms + .iter() + .copied() + .collect::>(), + vec![70, 60] + ); + } + + #[test] + fn summary_reports_p95_and_p99_after_frame_interval() { + let base = Instant::now(); + let mut metrics = PerfMetrics::new(true); + + for frame in 0..SUMMARY_FRAME_INTERVAL { + let commit_at = base + Duration::from_millis(frame); + metrics.record_input_sample( + PerfInputSource::Touch, + Tool::Eraser, + frame as usize, + 1, + 2, + 3, + 4, + false, + commit_at - Duration::from_millis(frame + 1), + ); + let report = metrics.commit_frame( + PerfFrameContext { + render_duration: Some(Duration::from_millis(1)), + dirty_area_pct: 0.5, + full_damage: false, + damage_rects: 1, + }, + commit_at, + ); + if frame + 1 < SUMMARY_FRAME_INTERVAL { + assert!(report.and_then(|r| r.summary).is_none()); + } else { + let summary = report + .and_then(|r| r.summary) + .expect("summary at frame interval"); + assert_eq!(summary.frames, SUMMARY_FRAME_INTERVAL); + assert_eq!(summary.samples, SUMMARY_FRAME_INTERVAL); + assert_eq!(summary.window_samples, SUMMARY_FRAME_INTERVAL as usize); + assert_eq!(summary.p95_ms, 114); + assert_eq!(summary.p99_ms, 119); + } + } + } + + #[test] + fn summary_reports_after_time_interval_before_frame_interval() { + let base = Instant::now(); + let mut metrics = PerfMetrics::new(true); + record_sample(&mut metrics, base); + let first_report = metrics.commit_frame( + PerfFrameContext { + render_duration: Some(Duration::from_millis(1)), + dirty_area_pct: 0.5, + full_damage: false, + damage_rects: 1, + }, + base + Duration::from_millis(10), + ); + assert!(first_report.and_then(|report| report.summary).is_none()); + + record_sample(&mut metrics, base + Duration::from_secs(5)); + let second_report = metrics + .commit_frame( + PerfFrameContext { + render_duration: Some(Duration::from_millis(1)), + dirty_area_pct: 0.5, + full_damage: false, + damage_rects: 1, + }, + base + Duration::from_secs(5) + Duration::from_millis(20), + ) + .expect("enabled metrics should report commits"); + + let summary = second_report.summary.expect("summary after time interval"); + assert_eq!(summary.frames, 2); + assert_eq!(summary.samples, 2); + assert_eq!(summary.p95_ms, 20); + assert_eq!(summary.p99_ms, 20); + } + + #[test] + fn damage_percentage_clamps_to_surface_bounds() { + let damage = [ + Rect::new(-10, -10, 20, 20).unwrap(), + Rect::new(50, 50, 100, 100).unwrap(), + ]; + + assert_eq!(damage_area_pct(&damage, 100, 100), 26.0); + assert!(damage_covers_surface( + &[Rect::new(0, 0, 100, 100).unwrap()], + 100, + 100 + )); + } +} diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index c0e7dc81..caab610b 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -173,7 +173,9 @@ impl WaylandState { crate::draw::render_blur_rect(ctx, params, &replay_ctx); true } - _ => self.input_state.render_provisional_shape(ctx, mx, my), + _ => self + .input_state + .render_provisional_shape_for_damage(ctx, mx, my, damage_world), }; if rendered_provisional { debug!("Rendered provisional shape"); diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index a7c51a2d..c0493029 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -301,6 +301,13 @@ impl WaylandState { debug!("Skipping frame callback (vsync disabled - allows back-to-back renders)"); } + self.commit_perf_frame( + &damage_screen, + width, + height, + scaled_damage.len(), + Instant::now(), + ); wl_surface.commit(); debug!("=== RENDER COMPLETE ==="); diff --git a/src/config/core.rs b/src/config/core.rs index 4b6ed6f6..7c384838 100644 --- a/src/config/core.rs +++ b/src/config/core.rs @@ -27,7 +27,8 @@ use serde::{Deserialize, Serialize}; /// /// [performance] /// buffer_count = 3 -/// enable_vsync = true +/// enable_vsync = false +/// max_fps_no_vsync = 120 /// ui_animation_fps = 30 /// /// [ui] diff --git a/src/config/types/performance.rs b/src/config/types/performance.rs index 25a3e849..69a56ed5 100644 --- a/src/config/types/performance.rs +++ b/src/config/types/performance.rs @@ -2,8 +2,8 @@ use serde::{Deserialize, Serialize}; /// Performance tuning options. /// -/// These settings control rendering performance and smoothness. Most users -/// won't need to change these from their defaults. +/// These settings control rendering performance and smoothness. Defaults favor +/// low drawing latency over strict display-synchronized rendering. #[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PerformanceConfig { @@ -14,14 +14,14 @@ pub struct PerformanceConfig { #[serde(default = "default_buffer_count")] pub buffer_count: u32, - /// Enable vsync frame synchronization to prevent tearing - /// Set to false for lower latency at the cost of potential screen tearing + /// Enable vsync frame synchronization to prevent tearing. + /// The default is false for lower drawing latency. #[serde(default = "default_enable_vsync")] pub enable_vsync: bool, - /// Maximum frame rate when vsync is disabled (0 = unlimited) - /// Prevents CPU spinning at very high FPS. Set to match your monitor's - /// refresh rate (e.g., 60, 120, 144, 240) or 0 for no limit. + /// Maximum frame rate when vsync is disabled (0 = unlimited). + /// Prevents CPU spinning at very high FPS. 120 FPS keeps input latency low + /// while avoiding uncapped redraw loops. #[serde(default = "default_max_fps_no_vsync")] pub max_fps_no_vsync: u32, @@ -47,11 +47,11 @@ fn default_buffer_count() -> u32 { } fn default_enable_vsync() -> bool { - true + false } fn default_max_fps_no_vsync() -> u32 { - 60 + 120 } fn default_ui_animation_fps() -> u32 { diff --git a/src/env_vars.rs b/src/env_vars.rs index 6cd37950..b99ab881 100644 --- a/src/env_vars.rs +++ b/src/env_vars.rs @@ -15,6 +15,7 @@ pub const FORCE_INLINE_TOOLBARS_ENV: &str = "WAYSCRIBER_FORCE_INLINE_TOOLBARS"; pub const DEBUG_DAMAGE_ENV: &str = "WAYSCRIBER_DEBUG_DAMAGE"; pub const DEBUG_TOOLBAR_COLOR_ENV: &str = "WAYSCRIBER_DEBUG_TOOLBAR_COLOR"; pub const DEBUG_TOOLBAR_DRAG_ENV: &str = "WAYSCRIBER_DEBUG_TOOLBAR_DRAG"; +pub const PERF_LOG_ENV: &str = "WAYSCRIBER_PERF_LOG"; pub const ICON_THEME_PATH_ENV: &str = "WAYSCRIBER_ICON_THEME_PATH"; pub const LOG_FILE_ENV: &str = "WAYSCRIBER_LOG_FILE"; pub const LOG_MAX_SIZE_ENV: &str = "WAYSCRIBER_LOG_MAX_SIZE_MB"; diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs index 4d329d51..d2146c75 100644 --- a/src/input/state/core/dirty.rs +++ b/src/input/state/core/dirty.rs @@ -2,7 +2,9 @@ use super::base::{DrawingState, InputState, TextInputMode}; use crate::draw::shape::{ bounding_box_for_points, bounding_box_for_sticky_note, bounding_box_for_text, }; -use crate::input::tool::PROVISIONAL_POLYGON_DAMAGE_PADDING; +use crate::input::tool::{ + PROVISIONAL_POLYGON_DAMAGE_PADDING, ToolMotionBehavior, ToolMotionSizeSource, +}; use crate::util::Rect; impl InputState { @@ -15,6 +17,13 @@ impl InputState { /// Updates tracked provisional shape bounds for dirty-region purposes. pub(crate) fn update_provisional_dirty(&mut self, current_x: i32, current_y: i32) { + if let Some(append_bounds) = self.compute_append_only_provisional_bounds() { + self.dirty_tracker.mark_rect(append_bounds); + self.last_provisional_bounds = + union_optional_rect(self.last_provisional_bounds, append_bounds); + return; + } + let new_bounds = self.compute_provisional_bounds(current_x, current_y); let previous = self.last_provisional_bounds; @@ -32,6 +41,19 @@ impl InputState { } } + /// Marks the full current provisional shape dirty. + /// + /// This is needed when existing provisional geometry changes in place, for + /// example when the first tablet pressure sample backfills previous widths. + pub(crate) fn mark_current_provisional_dirty_full(&mut self) { + let (current_x, current_y) = self.last_canvas_pointer_position; + if let Some(bounds) = self.compute_provisional_bounds(current_x, current_y) { + self.dirty_tracker.mark_rect(bounds); + self.last_provisional_bounds = + union_optional_rect(self.last_provisional_bounds, bounds); + } + } + fn compute_provisional_bounds(&self, current_x: i32, current_y: i32) -> Option { match &self.state { DrawingState::Drawing { .. } => { @@ -58,6 +80,43 @@ impl InputState { } } + fn compute_append_only_provisional_bounds(&self) -> Option { + let DrawingState::Drawing { + tool, + points, + point_thicknesses, + .. + } = &self.state + else { + return None; + }; + + let stroke_width = match tool.motion_behavior() { + ToolMotionBehavior::NoPathAccumulation => return None, + ToolMotionBehavior::AccumulatePath { + size_source: ToolMotionSizeSource::ToolSize, + } => { + if *tool == crate::input::Tool::Marker { + let size = self.thickness_for_tool(*tool); + (size * 1.35).max(size + 1.0) + } else if point_thicknesses.len() == points.len() && !point_thicknesses.is_empty() { + let start = point_thicknesses.len().saturating_sub(2); + point_thicknesses[start..] + .iter() + .fold(1.0f64, |max, &thickness| max.max(thickness as f64)) + } else { + self.thickness_for_tool(*tool) + } + } + ToolMotionBehavior::AccumulatePath { + size_source: ToolMotionSizeSource::EraserSize, + } => self.eraser_size, + }; + + let start = points.len().saturating_sub(2); + bounding_box_for_points(&points[start..], stroke_width) + } + /// Updates dirty tracking for the live text preview/caret overlay. pub(crate) fn update_text_preview_dirty(&mut self) { let new_bounds = self.compute_text_preview_bounds(); @@ -112,3 +171,20 @@ impl InputState { } } } + +fn union_optional_rect(current: Option, next: Rect) -> Option { + match current { + Some(current) => union_rect(current, next), + None => Some(next), + } +} + +fn union_rect(a: Rect, b: Rect) -> Option { + let min_x = a.x.min(b.x); + let min_y = a.y.min(b.y); + let max_x = a.x.saturating_add(a.width).max(b.x.saturating_add(b.width)); + let max_y = + a.y.saturating_add(a.height) + .max(b.y.saturating_add(b.height)); + Rect::from_min_max(min_x, min_y, max_x, max_y) +} diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index 7bda1589..ac2f5585 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -89,7 +89,15 @@ impl InputState { self.tool_settings.get_mut(tool).thickness = clamped; } self.current_thickness = clamped; + let initial_pressure_sample_changes = + self.active_initial_pressure_sample_changes(clamped as f32); + if initial_pressure_sample_changes { + self.mark_current_provisional_dirty_full(); + } self.update_initial_pressure_sample(clamped); + if initial_pressure_sample_changes { + self.mark_current_provisional_dirty_full(); + } self.needs_redraw = true; clamped } @@ -99,7 +107,7 @@ impl InputState { let clamped = thickness.clamp(MIN_STROKE_THICKNESS, MAX_STROKE_THICKNESS) as f32; let DrawingState::Drawing { point_thicknesses, .. - } = &mut self.state + } = &self.state else { return false; }; @@ -107,7 +115,17 @@ impl InputState { return false; } + self.mark_current_provisional_dirty_full(); + + let DrawingState::Drawing { + point_thicknesses, .. + } = &mut self.state + else { + return false; + }; point_thicknesses.fill(clamped); + + self.mark_current_provisional_dirty_full(); self.needs_redraw = true; true } @@ -126,6 +144,21 @@ impl InputState { } } + fn active_initial_pressure_sample_changes(&self, thickness: f32) -> bool { + let DrawingState::Drawing { + points, + point_thicknesses, + .. + } = &self.state + else { + return false; + }; + + points.len() == 1 + && point_thicknesses.len() == 1 + && (point_thicknesses[0] - thickness).abs() > f32::EPSILON + } + /// Sets or clears an explicit tool override. Returns true if the tool changed. pub fn set_tool_override(&mut self, tool: Option) -> bool { if self.presenter_mode @@ -274,8 +307,10 @@ impl InputState { return false; } + self.mark_current_provisional_dirty_full(); self.tool_settings.get_mut(tool).thickness = clamped; self.current_thickness = clamped; + self.mark_current_provisional_dirty_full(); self.active_preset_slot = None; self.dirty_tracker.mark_full(); self.needs_redraw = true; @@ -289,7 +324,9 @@ impl InputState { if (clamped - self.eraser_size).abs() < f64::EPSILON { return false; } + self.mark_current_provisional_dirty_full(); self.eraser_size = clamped; + self.mark_current_provisional_dirty_full(); self.active_preset_slot = None; self.dirty_tracker.mark_full(); self.needs_redraw = true; diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 202edcfd..fb091724 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -1,4 +1,5 @@ use crate::draw::render::{render_freehand_pressure_preview_borrowed, render_polygon_preview}; +use crate::draw::shape::bounding_box_for_points; use crate::draw::{ Color, Shape, render_freehand_borrowed, render_marker_stroke_borrowed, render_shape, }; @@ -6,6 +7,8 @@ use crate::input::Tool; use crate::input::tool::{ PolygonProvisionalSnapshot, ProvisionalToolSnapshot, ProvisionalToolStroke, }; +use crate::util::Rect; +use std::ops::Range; use super::{DrawingState, InputState}; @@ -124,6 +127,101 @@ impl InputState { } } + #[allow(dead_code)] // Used by the binary Wayland backend; the lib target has no backend modules. + pub(crate) fn render_provisional_tool_stroke_for_damage( + &self, + ctx: &cairo::Context, + stroke: ProvisionalToolStroke<'_>, + damage_regions: &[Rect], + ) -> bool { + match stroke { + ProvisionalToolStroke::BorrowedFreehand { + points, + color, + size, + } => { + let ranges = path_damage_ranges(points, damage_regions, size); + if ranges.is_empty() { + return false; + } + for range in ranges { + render_freehand_borrowed(ctx, &points[range], color, size); + } + true + } + ProvisionalToolStroke::BorrowedPressureFreehand { + points, + point_thicknesses, + color, + } => { + let max_thick = point_thicknesses + .iter() + .fold(1.0f64, |max, &thickness| max.max(thickness as f64)); + let ranges = path_damage_ranges(points, damage_regions, max_thick); + if ranges.is_empty() { + return false; + } + if pressure_preview_needs_full_mask_render(color, &ranges) { + render_freehand_pressure_preview_borrowed( + ctx, + points, + point_thicknesses, + color, + ); + return true; + } + let mut rendered = false; + for range in ranges { + let thickness_start = range.start.min(point_thicknesses.len()); + let thickness_end = range.end.min(point_thicknesses.len()); + if thickness_start >= thickness_end { + continue; + } + render_freehand_pressure_preview_borrowed( + ctx, + &points[range], + &point_thicknesses[thickness_start..thickness_end], + color, + ); + rendered = true; + } + rendered + } + ProvisionalToolStroke::BorrowedMarker { + points, + color, + size, + } => { + let inflated = (size * 1.35).max(size + 1.0); + let ranges = path_damage_ranges(points, damage_regions, inflated); + if ranges.is_empty() { + return false; + } + for range in ranges { + render_marker_stroke_borrowed(ctx, &points[range], color, size); + } + true + } + ProvisionalToolStroke::EraserPreview { points, size } => { + let ranges = path_damage_ranges(points, damage_regions, size); + if ranges.is_empty() { + return false; + } + let preview_color = Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 0.35, + }; + for range in ranges { + render_freehand_borrowed(ctx, &points[range], preview_color, size); + } + true + } + other => self.render_provisional_tool_stroke(ctx, other), + } + } + /// Renders the provisional shape directly to a Cairo context without cloning. /// /// This is an optimized version for freehand drawing that avoids cloning @@ -194,4 +292,153 @@ impl InputState { _ => false, } } + + #[allow(dead_code)] // Used by the binary Wayland backend; the lib target has no backend modules. + pub(crate) fn render_provisional_shape_for_damage( + &self, + ctx: &cairo::Context, + current_x: i32, + current_y: i32, + damage_regions: &[Rect], + ) -> bool { + if matches!(self.state, DrawingState::Drawing { .. }) { + let stroke = self.provisional_tool_stroke(current_x, current_y); + return self.render_provisional_tool_stroke_for_damage(ctx, stroke, damage_regions); + } + + self.render_provisional_shape(ctx, current_x, current_y) + } +} + +#[allow(dead_code)] // Used by the binary Wayland backend; the lib target has no backend modules. +fn path_damage_ranges( + points: &[(i32, i32)], + damage_regions: &[Rect], + stroke_width: f64, +) -> Vec> { + if points.is_empty() { + return Vec::new(); + } + + if damage_regions.is_empty() { + return single_range(0..points.len()); + } + + if points.len() == 1 { + return segment_bounds(points[0], points[0], stroke_width) + .filter(|bounds| { + damage_regions + .iter() + .any(|damage| rects_intersect(*bounds, *damage)) + }) + .map(|_| single_range(0..1)) + .unwrap_or_default(); + } + + let mut ranges = Vec::new(); + for index in 1..points.len() { + let Some(bounds) = segment_bounds(points[index - 1], points[index], stroke_width) else { + continue; + }; + if damage_regions + .iter() + .any(|damage| rects_intersect(bounds, *damage)) + { + let start = index.saturating_sub(2); + let end = (index + 2).min(points.len()); + push_merged_range(&mut ranges, start..end); + } + } + ranges +} + +#[allow(dead_code)] // Used by the binary Wayland backend; the lib target has no backend modules. +fn single_range(range: Range) -> Vec> { + std::iter::once(range).collect() +} + +#[allow(dead_code)] // Used by the binary Wayland backend; the lib target has no backend modules. +fn push_merged_range(ranges: &mut Vec>, next: Range) { + if next.start >= next.end { + return; + } + + if let Some(last) = ranges.last_mut() + && last.end >= next.start + { + last.end = last.end.max(next.end); + return; + } + + ranges.push(next); +} + +#[allow(dead_code)] // Used by the binary Wayland backend; the lib target has no backend modules. +fn segment_bounds(a: (i32, i32), b: (i32, i32), stroke_width: f64) -> Option { + bounding_box_for_points(&[a, b], stroke_width) +} + +#[allow(dead_code)] // Used by the binary Wayland backend; the lib target has no backend modules. +fn rects_intersect(a: Rect, b: 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) +} + +#[allow(dead_code)] // Used by the binary Wayland backend; the lib target has no backend modules. +fn pressure_preview_needs_full_mask_render(color: Color, ranges: &[Range]) -> bool { + color.a < 1.0 && ranges.len() > 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_damage_ranges_limits_long_path_to_intersecting_tail() { + let points: Vec<_> = (0..100).map(|index| (index * 10, 0)).collect(); + let damage = Rect::new(975, -5, 10, 10).unwrap(); + + let ranges = path_damage_ranges(&points, &[damage], 2.0); + + assert_eq!(ranges, single_range(96..100)); + } + + #[test] + fn path_damage_ranges_returns_full_path_without_damage_context() { + let points: Vec<_> = (0..8).map(|index| (index * 10, 0)).collect(); + + let ranges = path_damage_ranges(&points, &[], 2.0); + + assert_eq!(ranges, single_range(0..points.len())); + } + + #[test] + fn translucent_pressure_preview_uses_full_mask_for_multiple_dirty_ranges() { + let color = Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 0.35, + }; + let opaque = Color { a: 1.0, ..color }; + let points: Vec<_> = (0..10).map(|index| (index * 20, 0)).collect(); + let damage = [ + Rect::new(18, -4, 4, 8).unwrap(), + Rect::new(158, -4, 4, 8).unwrap(), + ]; + + let ranges = path_damage_ranges(&points, &damage, 2.0); + + assert_eq!(ranges, vec![0..4, 6..10]); + assert!(pressure_preview_needs_full_mask_render(color, &ranges)); + assert!(!pressure_preview_needs_full_mask_render(opaque, &ranges)); + assert!(!pressure_preview_needs_full_mask_render( + color, + &single_range(0..points.len()) + )); + } } diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index edb5bd51..8f393624 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -213,6 +213,157 @@ fn freeform_polygon_preview_dirty_has_antialias_padding() { ); } +#[test] +fn append_path_motion_dirties_only_new_tail_segment() { + let mut state = create_test_input_state(); + assert!(state.set_tool_override(Some(Tool::Pen))); + let _ = state.take_dirty_regions(); + + state.on_mouse_press(MouseButton::Left, 10, 10); + let _ = state.take_dirty_regions(); + state.on_mouse_motion(20, 10); + let _ = state.take_dirty_regions(); + + state.on_mouse_motion(30, 10); + let dirty = state.take_dirty_regions(); + let thick = state.thickness_for_tool(Tool::Pen); + let tail_bounds = crate::draw::shape::bounding_box_for_points(&[(20, 10), (30, 10)], thick) + .expect("tail segment should have bounds"); + let full_bounds = crate::draw::shape::bounding_box_for_points(&[(10, 10), (30, 10)], thick) + .expect("full provisional stroke should have bounds"); + let head_probe = crate::util::Rect::new(10, 10, 1, 1).unwrap(); + + assert!( + dirty + .iter() + .any(|rect| test_rects_intersect(*rect, tail_bounds)), + "dirty regions should include the new tail segment; dirty={dirty:?}, tail={tail_bounds:?}" + ); + assert!( + !dirty + .iter() + .any(|rect| test_rects_intersect(*rect, head_probe)), + "append motion should not redraw the start of the accumulated stroke; dirty={dirty:?}" + ); + assert_eq!( + state.last_provisional_bounds, + Some(full_bounds), + "cleanup bounds should still cover the whole active stroke" + ); +} + +#[test] +fn pressure_sample_shrink_dirties_previous_full_provisional_bounds() { + let mut state = create_test_input_state(); + assert!(state.set_tool_override(Some(Tool::Pen))); + assert!(state.set_thickness(32.0)); + let _ = state.take_dirty_regions(); + + state.on_mouse_press(MouseButton::Left, 10, 10); + let old_full_bounds = state + .last_provisional_bounds + .expect("initial pressure preview should have bounds"); + let old_only_probe = crate::util::Rect::new(10, old_full_bounds.y, 1, 1).unwrap(); + let _ = state.take_dirty_regions(); + + state.set_pressure_thickness_for_active_tool(2.0); + state.on_mouse_motion(30, 10); + let dirty = state.take_dirty_regions(); + + assert!( + dirty + .iter() + .any(|rect| test_rects_intersect(*rect, old_only_probe)), + "shrinking the first pressure sample should dirty old wide preview pixels; dirty={dirty:?}, old_full={old_full_bounds:?}, probe={old_only_probe:?}" + ); +} + +#[test] +fn marker_size_increase_updates_accumulated_cleanup_bounds() { + let mut state = create_test_input_state(); + assert!(state.set_tool_override(Some(Tool::Marker))); + assert!(state.set_thickness(2.0)); + let _ = state.take_dirty_regions(); + + state.on_mouse_press(MouseButton::Left, 10, 10); + state.on_mouse_motion(20, 10); + let _ = state.take_dirty_regions(); + + assert!(state.set_thickness(32.0)); + let _ = state.take_dirty_regions(); + + state.cancel_active_interaction(); + let dirty = state.take_dirty_regions(); + let marker_width = (32.0f64 * 1.35).max(32.0 + 1.0); + let expanded_bounds = + crate::draw::shape::bounding_box_for_points(&[(10, 10), (20, 10)], marker_width) + .expect("expanded marker preview should have bounds"); + let expanded_only_probe = crate::util::Rect::new(10, expanded_bounds.y, 1, 1).unwrap(); + + assert!( + dirty + .iter() + .any(|rect| test_rects_intersect(*rect, expanded_only_probe)), + "cancel should dirty marker pixels exposed by a mid-stroke size increase; dirty={dirty:?}, expanded={expanded_bounds:?}, probe={expanded_only_probe:?}" + ); + assert_eq!(state.last_provisional_bounds, None); +} + +#[test] +fn eraser_size_increase_updates_accumulated_cleanup_bounds() { + let mut state = create_test_input_state(); + assert!(state.set_tool_override(Some(Tool::Eraser))); + assert!(state.set_eraser_size(2.0)); + let _ = state.take_dirty_regions(); + + state.on_mouse_press(MouseButton::Left, 10, 10); + state.on_mouse_motion(20, 10); + let _ = state.take_dirty_regions(); + + assert!(state.set_eraser_size(32.0)); + let _ = state.take_dirty_regions(); + + state.cancel_active_interaction(); + let dirty = state.take_dirty_regions(); + let expanded_bounds = crate::draw::shape::bounding_box_for_eraser(&[(10, 10), (20, 10)], 32.0) + .expect("expanded eraser preview should have bounds"); + let expanded_only_probe = crate::util::Rect::new(10, expanded_bounds.y, 1, 1).unwrap(); + + assert!( + dirty + .iter() + .any(|rect| test_rects_intersect(*rect, expanded_only_probe)), + "cancel should dirty eraser pixels exposed by a mid-stroke size increase; dirty={dirty:?}, expanded={expanded_bounds:?}, probe={expanded_only_probe:?}" + ); + assert_eq!(state.last_provisional_bounds, None); +} + +#[test] +fn cancel_active_path_dirties_full_accumulated_provisional_bounds() { + let mut state = create_test_input_state(); + assert!(state.set_tool_override(Some(Tool::Pen))); + let _ = state.take_dirty_regions(); + + state.on_mouse_press(MouseButton::Left, 10, 10); + state.on_mouse_motion(20, 10); + state.on_mouse_motion(30, 10); + let _ = state.take_dirty_regions(); + + state.cancel_active_interaction(); + let dirty = state.take_dirty_regions(); + let thick = state.thickness_for_tool(Tool::Pen); + let full_bounds = crate::draw::shape::bounding_box_for_points(&[(10, 10), (30, 10)], thick) + .expect("full provisional stroke should have bounds"); + + assert!( + dirty + .iter() + .any(|rect| test_rects_intersect(*rect, full_bounds)), + "cancel should dirty the full active stroke bounds; dirty={dirty:?}, full={full_bounds:?}" + ); + assert_eq!(state.last_provisional_bounds, None); +} + #[test] fn freeform_polygon_freezes_style_on_first_click() { let mut state = create_test_input_state(); @@ -459,3 +610,12 @@ fn sync_highlight_color_marks_dirty_when_pen_color_changes() { state.sync_highlight_color(); assert!(state.needs_redraw); } + +fn test_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) +}