perf: eliminate crosshair input lag by using immediate cursor position and forced view redraws
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled

This commit is contained in:
Your Name
2026-07-24 02:46:30 +03:00
parent fba10b753c
commit 7496f5e208
2 changed files with 30 additions and 8 deletions
@@ -691,6 +691,11 @@ pub enum Message {
x: u32,
y: u32,
},
/// Lightweight no-op message emitted by the canvas overlay on every
/// `CursorMoved` event. Its sole purpose is to trigger a `view()`
/// rebuild so the crosshair overlay redraws at the latest cursor
/// position during idle (non-drawing) pointer movement.
CursorOverlayRedraw,
ModifiersChanged(iced::keyboard::Modifiers),
SpacePanChanged(bool),
@@ -3554,6 +3559,11 @@ impl HcieIcedApp {
self.canvas_cursor_pos = Some((x, y));
}
// No-op — this message exists solely to trigger a view() rebuild
// so the crosshair overlay redraws at the latest cursor position.
// See Strategy 1 in the cursor lag fix.
Message::CursorOverlayRedraw => {}
Message::CanvasZoomSet(z) => {
let doc = &mut self.documents[self.active_doc];
doc.zoom = z.clamp(ZOOM_MIN, ZOOM_MAX);
@@ -1169,7 +1169,7 @@ impl canvas::Program<Message> for OverlayProgram {
renderer: &iced::Renderer,
_theme: &iced::Theme,
bounds: Rectangle,
_cursor: Cursor,
cursor: Cursor,
) -> Vec<canvas::Geometry> {
let (origin_x, origin_y, _display_w, _display_h) = self.canvas_origin(bounds.size());
let mut geometries = Vec::new();
@@ -2010,8 +2010,12 @@ impl canvas::Program<Message> for OverlayProgram {
}
// ── Crosshair cursor ─────────────────────────────────────────────
if state.is_hovered {
if let Some(cursor) = state.cursor_pos {
// Strategy 4: Use the `cursor` parameter from iced's current render
// frame instead of the retained `state.cursor_pos` (which lags by
// one Elm update cycle). This eliminates the 1-frame positional
// delay between the OS pointer and the brush-circle indicator.
if cursor.is_over(bounds) {
if let Some(cursor_point) = cursor.position_in(bounds) {
let mut crosshair_frame = Frame::new(renderer, bounds.size());
let crosshair_size = 10.0;
let crosshair_color = iced::Color::from_rgb(1.0, 1.0, 1.0);
@@ -2023,7 +2027,7 @@ impl canvas::Program<Message> for OverlayProgram {
| hcie_engine_api::Tool::Spray
) {
let footprint =
Path::circle(cursor, (self.brush_size * self.zoom * 0.5).max(1.0));
Path::circle(cursor_point, (self.brush_size * self.zoom * 0.5).max(1.0));
crosshair_frame.stroke(
&footprint,
Stroke {
@@ -2035,8 +2039,8 @@ impl canvas::Program<Message> for OverlayProgram {
}
crosshair_frame.stroke(
&Path::line(
Point::new(cursor.x - crosshair_size, cursor.y),
Point::new(cursor.x + crosshair_size, cursor.y),
Point::new(cursor_point.x - crosshair_size, cursor_point.y),
Point::new(cursor_point.x + crosshair_size, cursor_point.y),
),
Stroke {
style: canvas::stroke::Style::Solid(crosshair_color),
@@ -2046,8 +2050,8 @@ impl canvas::Program<Message> for OverlayProgram {
);
crosshair_frame.stroke(
&Path::line(
Point::new(cursor.x, cursor.y - crosshair_size),
Point::new(cursor.x, cursor.y + crosshair_size),
Point::new(cursor_point.x, cursor_point.y - crosshair_size),
Point::new(cursor_point.x, cursor_point.y + crosshair_size),
),
Stroke {
style: canvas::stroke::Style::Solid(crosshair_color),
@@ -2147,6 +2151,14 @@ impl canvas::Program<Message> for OverlayProgram {
}
}
}
// Strategy 1: Emit a lightweight redraw message on every CursorMoved
// event so iced triggers a view() rebuild and the overlay's draw()
// runs with the latest cursor position. Without this, iced may
// skip repaints during idle cursor movement because no application
// state changed.
if matches!(event, canvas::Event::Mouse(mouse::Event::CursorMoved { .. })) {
return (canvas::event::Status::Ignored, Some(Message::CursorOverlayRedraw));
}
// Always pass events through to the shader widget below
(canvas::event::Status::Ignored, None)
}