Keep fractional pen ink and add direct zero-level meter dots

Pen, Marker and Shape Pen strokes now keep fractional pointer positions
from press to commit instead of rounding every sample to whole canvas
units, which left visible stairs on fractional-scale outputs.

- Freehand, pressure and marker points are stored as f64. Motion samples
  closer than 0.75 canvas units to the last kept sample are skipped, the
  release position is always kept, and a skipped tablet sample still
  raises the last kept width to its pressure peak.
- Smoothing runs in floating point with pinned endpoints. Smoothed
  interior points are committed on a 1/1000 canvas-unit grid to keep
  sessions compact.
- Session format 8 stores fractional points and validates them on save
  and load. Integer points from older sessions load exactly.
- Shape recognition, laser and eraser geometry keep integer paths
  through one rounding adapter.
- Level meters and steppers gain an accent dot that sets Off (or Precise
  for shape detection) in one click, and each bar now selects its exact
  level. Built-in and GTK toolbars share the dot painter, and meter rows
  widen by the dot slot so bar widths are unchanged.

Validated with ./tools/lint-and-test.sh and tools/test-gtk-widgets.sh.
This commit is contained in:
devmobasa committed 2026-10-10 21:22:06 +02:00
1 parent 91559a5ce9
commit 7ac885f9d2
95 files changed
+1698 -460

No files matched your search

+23 -9
View File
@@ -569,6 +569,14 @@ on the pointer and pays for the smoothing once, on a finished path.
Both endpoints are pinned. A stroke starts and stops where you started and
stopped it, at every level.
Pen, Marker and Shape Pen keep fractional pointer positions. During a drag,
samples less than 0.75 canvas units from the last kept sample are skipped (3 screen
pixels at 4× zoom). The release position is always retained. This removes repeated
samples from slow drags. Level 0 keeps those sampled positions unchanged.
Levels 1–6 apply that many passes of the same smoothing filter. Smoothed interior points are
committed to 0.001 canvas-unit precision to keep session files compact; endpoints
retain their exact fractional positions.
| Level | Result |
|-------|--------|
| 0 | The exact path you drew |
@@ -579,13 +587,18 @@ The level applies to the Pen and the Marker. The Eraser is not smoothed: its pat
decides what gets erased, so moving it would change the result rather than the
look.
A tablet stroke is smoothed too, because its path shakes like any other. Its
**pressure values** are not touched — each smoothed point keeps the thickness
that was sampled with it, so pen dynamics survive.
A tablet stroke is smoothed too, because its path shakes like any other.
Smoothing changes positions and preserves each kept point's thickness. Motion
skipped by the spacing threshold retains the highest pressure at the last kept
point, including pressure changes during a pause.
A stroke is stored as the points it ended up with, so nothing about smoothing
changes how a shape is written. The level itself is remembered with the rest of
the tool settings, so a session restores at the level it was saved at. A session
A stroke is stored as the points it ended up with. Session format 8 stores
freehand, pressure and marker positions as floating-point coordinates so
fractional-scale outputs retain the smooth result. It loads integer positions
from older sessions exactly. Loading alone does not rewrite a session; the
next save writes format 8, which older Wayscriber versions cannot open.
The level itself is remembered with the rest of the tool settings, so a session
restores at the level it was saved at. A session
written before this existed has no level recorded and restores at whatever
`pen_smoothing` your config says.
@@ -603,9 +616,10 @@ The level is also on the toolbar whenever the Pen, Marker, or Shape Pen is up.
current one.
- `"stepper"`: a **Smooth** `− value +` stepper that reads `Off` at zero.
On a meter, in the pill or in the panel, click a bar to set that level
(clicking the highest filled bar steps one below it, down to `Off`), or scroll
over it to step one level; the tooltip names the level. The chip, meter, or
On a meter, in the pill or in the panel, click the accent dot for `Off` (or
`Precise` for Shape detection), or click a bar to set that exact level.
Clicking the current level keeps it. Steppers also have a dot for direct zero.
Scroll over it to step one level; the tooltip names the level. The chip, meter, or
stepper is one of the first things the pill drops on a narrow output; the
actions below still reach the level there.
+8 -1
View File
@@ -582,7 +582,14 @@ data remains in the backup or recovery file.
Offline tool-state clears report preserved corruption rather than silently rewriting
restored data.
Format 7 now writes an optional `save_generation` immediately after `version`.
Format 8 stores freehand, pressure-stroke and marker positions as `f64`, including
undo history. Older integer coordinates load exactly; opening a session does not
rewrite it. The next save writes format 8, which older releases cannot open.
Pen input keeps fractional canvas positions, thins motion samples at 0.75 logical
pixels and retains the exact release endpoint. The existing 0–6 smoothing passes
keep endpoints pinned and pressure paired with each retained position.
Since format 7, saves write an optional `save_generation` immediately after `version`.
Normal saves allocate a counter under the session lock using bounded payload and
marker probes. Save As prepares before locking, then quarantines target sidecars.
Marker format 1 records kind, generation, and a diagnostic timestamp. Load precedence
@@ -519,6 +519,7 @@ mod tests {
DrawingState::PendingTextClick {
x: 0,
y: 0,
position: (0.0, 0.0),
tool: Tool::Select,
shape_id: 1,
},
@@ -128,6 +128,7 @@ impl WaylandState {
let (wx, wy) = self.zoomed_world_coords(sx, sy);
self.input_state
.update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy);
let (canvas_x, canvas_y) = self.canvas_world_coords_precise(sx, sy);
self.input_state.on_mouse_motion_with_canvas_and_resources(
crate::input::state::InputTextResources {
measurer: self.render.text_measurer(),
@@ -135,8 +136,8 @@ impl WaylandState {
},
sx.round() as i32,
sy.round() as i32,
wx,
wy,
canvas_x,
canvas_y,
);
self.update_pointer_cursor(false, conn);
true
@@ -263,6 +264,7 @@ impl WaylandState {
}
self.input_state
.update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy);
let (canvas_x, canvas_y) = self.canvas_world_coords_precise(sx, sy);
self.input_state.on_mouse_motion_with_canvas_and_resources(
crate::input::state::InputTextResources {
measurer: self.render.text_measurer(),
@@ -270,8 +272,8 @@ impl WaylandState {
},
sx.round() as i32,
sy.round() as i32,
wx,
wy,
canvas_x,
canvas_y,
);
self.update_pointer_cursor(false, conn);
self.mark_mouse_tool_preview_dirty(previous, next);
@@ -115,7 +115,7 @@ impl WaylandState {
let screen_x = event.position.0.round() as i32;
let screen_y = event.position.1.round() as i32;
let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1);
let (wx, wy) = self.canvas_world_coords_precise(event.position.0, event.position.1);
self.pointer.route_canvas_press(
&mut self.input_state,
crate::input::state::InputTextResources {
@@ -124,7 +124,8 @@ impl WaylandState {
},
button,
mb,
[screen_x, screen_y, wx, wy],
(screen_x, screen_y),
(wx, wy),
);
self.pointer.reconcile_contacts(
&self.input_state,
@@ -159,7 +159,8 @@ impl WaylandState {
let screen_x = event.position.0.round() as i32;
let screen_y = event.position.1.round() as i32;
let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1);
let (canvas_x, canvas_y) =
self.canvas_world_coords_precise(event.position.0, event.position.1);
self.input_state.on_mouse_release_with_canvas_and_resources(
crate::input::state::InputTextResources {
measurer: self.render.text_measurer(),
@@ -168,8 +169,8 @@ impl WaylandState {
mb,
screen_x,
screen_y,
wx,
wy,
canvas_x,
canvas_y,
);
self.input_state.needs_redraw = true;
}
@@ -278,7 +279,7 @@ impl WaylandState {
let (Some(mb), Some((sx, sy))) = (mb, routed.screen) else {
return false;
};
let (wx, wy) = self.zoomed_world_coords(sx, sy);
let (canvas_x, canvas_y) = self.canvas_world_coords_precise(sx, sy);
self.input_state.on_mouse_release_with_canvas_and_resources(
crate::input::state::InputTextResources {
measurer: self.render.text_measurer(),
@@ -287,8 +288,8 @@ impl WaylandState {
mb,
sx.round() as i32,
sy.round() as i32,
wx,
wy,
canvas_x,
canvas_y,
);
self.input_state.needs_redraw = true;
true
+9 -8
View File
@@ -134,6 +134,7 @@ impl WaylandState {
self.pointer.set_position((x as i32, y as i32));
self.tablet.last_pos = Some((x, y));
let (wx, wy) = self.zoomed_world_coords(x, y);
let (canvas_x, canvas_y) = self.canvas_world_coords_precise(x, y);
self.input_state.on_mouse_motion_with_canvas_and_resources(
crate::input::state::InputTextResources {
measurer: self.render.text_measurer(),
@@ -141,8 +142,8 @@ impl WaylandState {
},
x.round() as i32,
y.round() as i32,
wx,
wy,
canvas_x,
canvas_y,
);
self.record_perf_input_sample(
PerfInputSource::Stylus,
@@ -243,7 +244,7 @@ impl WaylandState {
);
let screen_x = self.pointer.position().0;
let screen_y = self.pointer.position().1;
let (wx, wy) = self.zoomed_world_coords(x, y);
let (canvas_x, canvas_y) = self.canvas_world_coords_precise(x, y);
self.input_state.on_mouse_press_with_canvas_and_resources(
crate::input::state::InputTextResources {
measurer: self.render.text_measurer(),
@@ -252,8 +253,8 @@ impl WaylandState {
MouseButton::Left,
screen_x,
screen_y,
wx,
wy,
canvas_x,
canvas_y,
);
self.record_stylus_motion_thickness();
self.input_state.needs_redraw = true;
@@ -326,7 +327,7 @@ impl WaylandState {
self.input_state.needs_redraw = true;
return;
}
let (wx, wy) = self.zoomed_world_coords(x, y);
let (canvas_x, canvas_y) = self.canvas_world_coords_precise(x, y);
self.input_state.on_mouse_release_with_canvas_and_resources(
crate::input::state::InputTextResources {
measurer: self.render.text_measurer(),
@@ -335,8 +336,8 @@ impl WaylandState {
MouseButton::Left,
screen_x,
screen_y,
wx,
wy,
canvas_x,
canvas_y,
);
let hover_cursor_pos = self.stylus_hover_cursor_position();
self.mark_stylus_hover_cursor_dirty(None, hover_cursor_pos);
+12 -8
View File
@@ -323,7 +323,8 @@ impl WaylandState {
self.input_state.needs_redraw = true;
return target;
}
let (wx, wy) = self.zoomed_world_coords(screen_position.0, screen_position.1);
let (canvas_x, canvas_y) =
self.canvas_world_coords_precise(screen_position.0, screen_position.1);
self.input_state.on_mouse_press_with_canvas_and_resources(
crate::input::state::InputTextResources {
measurer: self.render.text_measurer(),
@@ -332,8 +333,8 @@ impl WaylandState {
MouseButton::Left,
screen_x,
screen_y,
wx,
wy,
canvas_x,
canvas_y,
);
self.input_state.needs_redraw = true;
target
@@ -433,6 +434,8 @@ impl WaylandState {
let (wx, wy) = self.zoomed_world_coords(screen_position.0, screen_position.1);
self.input_state
.update_pointer_positions(screen_x, screen_y, wx, wy);
let (canvas_x, canvas_y) =
self.canvas_world_coords_precise(screen_position.0, screen_position.1);
self.input_state.on_mouse_motion_with_canvas_and_resources(
crate::input::state::InputTextResources {
measurer: self.render.text_measurer(),
@@ -440,8 +443,8 @@ impl WaylandState {
},
screen_x,
screen_y,
wx,
wy,
canvas_x,
canvas_y,
);
self.record_perf_input_sample(PerfInputSource::Touch, screen_x, screen_y, wx, wy, false);
}
@@ -567,7 +570,8 @@ impl WaylandState {
self.input_state.needs_redraw = true;
return;
}
let (wx, wy) = self.zoomed_world_coords(screen_position.0, screen_position.1);
let (canvas_x, canvas_y) =
self.canvas_world_coords_precise(screen_position.0, screen_position.1);
self.input_state.on_mouse_release_with_canvas_and_resources(
crate::input::state::InputTextResources {
measurer: self.render.text_measurer(),
@@ -576,8 +580,8 @@ impl WaylandState {
MouseButton::Left,
screen_x,
screen_y,
wx,
wy,
canvas_x,
canvas_y,
);
self.input_state.needs_redraw = true;
}
+1 -1
View File
@@ -519,7 +519,7 @@ mod load {
let mut snapshot = sample_snapshot();
snapshot.boards[0].pages.pages[0].add_shape(crate::draw::Shape::Freehand {
points: (0..40_000)
.map(|index| (index % 1000, (index * 17) % 800))
.map(|index| (f64::from(index % 1000), f64::from((index * 17) % 800)))
.collect(),
color: crate::draw::WHITE,
thick: 2.0,
+2 -2
View File
@@ -1141,7 +1141,7 @@ fn runtime_open_cancels_active_interaction_from_previous_session() {
tool: Tool::Pen,
start_x: 10,
start_y: 20,
points: vec![(10, 20), (30, 40)],
points: vec![(10.0, 20.0), (30.0, 40.0)],
point_thicknesses: vec![2.0, 2.0],
};
assert!(input.has_active_pointer_interaction());
@@ -2423,7 +2423,7 @@ fn pending_session_handoffs_preserve_new_unfinished_pointer_and_text_drafts() {
assert_eq!(input.boards.active_frame().shapes.len(), 1);
if draft_kind == "pointer" {
assert!(
matches!(&input.state, DrawingState::Drawing { points, .. } if points.last() == Some(&(90,90)))
matches!(&input.state, DrawingState::Drawing { points, .. } if points.last() == Some(&(90.0,90.0)))
);
} else {
assert!(
+11 -8
View File
@@ -36,18 +36,21 @@ impl WaylandState {
screen_x: f64,
screen_y: f64,
) -> (i32, i32) {
let (x, y) = self.canvas_world_coords_precise(screen_x, screen_y);
(x.round() as i32, y.round() as i32)
}
pub(in crate::backend::wayland) fn canvas_world_coords_precise(
&self,
screen_x: f64,
screen_y: f64,
) -> (f64, f64) {
let (board_x, board_y) = self.board_view_offset();
if self.zoom.active {
let (zoom_x, zoom_y) = self.zoom.screen_to_world(screen_x, screen_y);
(
(board_x + zoom_x).round() as i32,
(board_y + zoom_y).round() as i32,
)
(board_x + zoom_x, board_y + zoom_y)
} else {
(
(board_x + screen_x).round() as i32,
(board_y + screen_y).round() as i32,
)
(board_x + screen_x, board_y + screen_y)
}
}
+2 -1
View File
@@ -142,7 +142,8 @@ impl PointerRuntime {
resources: crate::input::state::InputTextResources<'_>,
button: u32,
mouse_button: crate::input::MouseButton,
[sx, sy, wx, wy]: [i32; 4],
(sx, sy): (i32, i32),
(wx, wy): (f64, f64),
) {
let outcome =
input.on_mouse_press_with_canvas_and_resources(resources, mouse_button, sx, sy, wx, wy);
@@ -141,7 +141,8 @@ mod dispatch_regressions {
resources(),
1,
mouse_button,
[300, 300, 300, 300],
(300, 300),
(300.0, 300.0),
);
assert_eq!(pointer.contact_motion(), ContactMotion::Hover);
input.close_context_menu();
@@ -179,7 +180,8 @@ mod dispatch_regressions {
},
1,
MouseButton::Left,
[600, 400, 600, 400],
(600, 400),
(600.0, 400.0),
);
assert_eq!(pointer.contact_motion(), ContactMotion::Canvas);
input.cancel_active_interaction_with(&measurer);
@@ -245,7 +247,7 @@ mod popup_dispatch_regressions {
} else {
(layout.ok_btn_x + 2.0, layout.ok_btn_y + 2.0)
};
let coords = [x as i32, y as i32, x as i32, y as i32];
let screen = (x as i32, y as i32);
pointer.route_canvas_press(
&mut input,
InputTextResources {
@@ -254,7 +256,8 @@ mod popup_dispatch_regressions {
},
1,
MouseButton::Left,
coords,
screen,
(x, y),
);
assert_eq!(
pointer.contact_motion(),
@@ -295,10 +298,10 @@ mod popup_dispatch_regressions {
ui_engine: &engine,
},
MouseButton::Left,
coords[0],
coords[1],
coords[2],
coords[3],
screen.0,
screen.1,
x,
y,
);
assert!(!input.color_picker_popup_is_dragging());
assert_eq!(input.is_color_picker_popup_open(), slider);
@@ -141,7 +141,9 @@ impl WaylandState {
..
} = &self.input_state.state
{
let sampled = self.input_state.sample_eraser_path_points(points);
// Preview the integer path the committed stroke erases with.
let rounded = crate::draw::shape::quantize_path(points);
let sampled = self.input_state.sample_eraser_path_points(&rounded);
self.input_state.hit_test_all_for_points_cached_with(
self.render.text_measurer(),
&sampled,
@@ -83,9 +83,9 @@ impl ToolbarLayoutSpec {
/// "Detect"). Holds one short word at the caption size plus the gap to
/// the first bar or the − half.
pub(in crate::backend::wayland::toolbar) const TOP_STYLE_CAPTION_W: f64 = 56.0;
/// Bar row of a level meter. Fixed whatever the level count, so both
/// meters line up; each bar takes an equal share of it.
pub(in crate::backend::wayland::toolbar) const TOP_STYLE_METER_W: f64 = 84.0;
/// Zero dot plus the original 84px bar row, shared by both level meters.
pub(in crate::backend::wayland::toolbar) const TOP_STYLE_METER_W: f64 =
84.0 + crate::ui::toolbar::model::METER_ZERO_SLOT_W;
/// The Pen feel chip ("Pen feel ▾"): a fixed slot like the font button's,
/// so the planner budgets it whatever the levels behind it.
pub(in crate::backend::wayland::toolbar) const TOP_STYLE_PEN_FEEL_W: f64 = 92.0;
@@ -332,6 +332,9 @@ fn paint_node(
WidgetKind::MeterBar { filled, enabled } => {
draw_meter_bar(ctx, node.rect, *filled, is_hover, *enabled);
}
WidgetKind::MeterDot { active, enabled } => {
crate::toolbar_icons::draw_meter_dot(ctx, node.rect, *active, is_hover, *enabled);
}
WidgetKind::SmoothingPreview { level } => {
crate::toolbar_icons::draw_smoothing_preview(ctx, node.rect, *level);
}
+2
View File
@@ -202,6 +202,8 @@ pub enum WidgetKind {
/// painter draws a slimmer rounded bar centered in it, accent-filled at
/// or below the current level and track-colored above it.
MeterBar { filled: bool, enabled: bool },
/// Accent zero-level dot with a hover ring while another level is active.
MeterDot { active: bool, enabled: bool },
/// The Pen feel panel's live smoothing preview at `level`, drawn by the
/// painter both frontends share (`toolbar_icons::draw_smoothing_preview`).
SmoothingPreview { level: u8 },
+17 -8
View File
@@ -60,21 +60,30 @@ pub(super) fn meter_bar_nodes(
row: (f64, f64, f64, f64),
) -> impl Iterator<Item = WidgetNode> {
let (x, y, w, h) = row;
let bar_w = w / meter.segments.len().max(1) as f64;
let dot_w = model::METER_ZERO_SLOT_W.min(w);
let bar_w = (w - dot_w) / meter.segments.len().max(1) as f64;
let zero = meter.zero;
let dot = WidgetNode::new(
zero.id,
(x, y, dot_w, h),
WidgetKind::MeterDot {
active: meter.level == 0,
enabled,
},
enabled.then(|| Interaction::click(zero.event, Some(zero.tooltip))),
);
meter
.segments
.into_iter()
.enumerate()
.map(move |(index, segment)| {
std::iter::once(dot).chain(meter.segments.into_iter().enumerate().map(
move |(index, segment)| {
WidgetNode::new(
segment.id,
(x + index as f64 * bar_w, y, bar_w, h),
(x + dot_w + index as f64 * bar_w, y, bar_w, h),
WidgetKind::MeterBar {
filled: segment.filled,
enabled,
},
enabled.then(|| Interaction::click(segment.event, Some(segment.tooltip))),
)
})
},
))
}
@@ -126,6 +126,7 @@ fn the_open_panel_shows_the_sections_the_tool_uses() {
"top.feel.title",
"top.feel.smoothing.label",
"top.feel.smoothing.value",
"top.feel.smoothing.level-0",
"top.feel.smoothing.level-1",
"top.feel.smoothing.level-2",
"top.feel.smoothing.level-3",
@@ -146,6 +147,7 @@ fn the_open_panel_shows_the_sections_the_tool_uses() {
[
"top.feel.detection.label",
"top.feel.detection.value",
"top.feel.detection.level-0",
"top.feel.detection.level-1",
"top.feel.detection.level-2",
"top.feel.detection.level-3",
@@ -367,3 +369,107 @@ fn panel_text_fits_its_column() {
}
}
}
#[test]
fn zero_is_one_click_and_bars_keep_their_level_in_every_presentation() {
for style in [
ToolbarStrokeControls::Panel,
ToolbarStrokeControls::Meter,
ToolbarStrokeControls::Stepper,
] {
for level in 0..=6 {
let mut snapshot = snapshot_for(Tool::LiveShape, style, true);
snapshot.pen_smoothing = level;
snapshot.shape_recognition_sensitivity = level.min(4);
let tree = tree_for(&snapshot);
for (setting, prefix) in [
(model::StrokeSetting::Smoothing, "pen-smoothing"),
(model::StrokeSetting::ShapeDetection, "shape-sensitivity"),
] {
let prefix = if style == ToolbarStrokeControls::Panel {
format!("top.feel.{}", setting.panel_key())
} else {
format!("top.style.{prefix}")
};
let targets = if style == ToolbarStrokeControls::Stepper {
0..=0
} else {
0..=setting.max()
};
for target in targets {
let node = tree
.node_by_id(&format!("{prefix}.level-{target}").into())
.unwrap();
let (x, y, w, h) = node.rect;
let hit = tree
.hit(x + w / 2.0, y + h / 2.0)
.expect("direct level hit");
assert_eq!(
hit.interact.as_ref().unwrap().event,
setting.event(target),
"{style:?} current={level} target={target}"
);
}
}
}
}
}
#[test]
fn pen_feel_renders_the_zero_hover_ring_at_integer_and_fractional_scale() {
use crate::backend::wayland::toolbar::render::render_top_strip;
for scale in [1.0, 5.0 / 3.0] {
let engine = UiTextEngine::default();
let mut snapshot = snapshot_for(Tool::LiveShape, ToolbarStrokeControls::Panel, true);
snapshot.top_fade = 1.0;
let (w, h) = top_size(&engine, &snapshot);
let tree = tree_for(&snapshot);
let dot = tree
.node_by_id(&"top.feel.smoothing.level-0".into())
.unwrap();
let (x, y, _, row_h) = dot.rect;
let hover = Some((x + 6.0, y + row_h / 2.0));
let paint = |snapshot: &ToolbarSnapshot, hover| {
let surface = cairo::ImageSurface::create(
cairo::Format::ARgb32,
(f64::from(w) * scale).ceil() as i32,
(f64::from(h) * scale).ceil() as i32,
)
.unwrap();
let ctx = cairo::Context::new(&surface).unwrap();
ctx.scale(scale, scale);
render_top_strip(
&engine,
&ctx,
f64::from(w),
f64::from(h),
snapshot,
&mut Vec::new(),
hover,
None,
)
.unwrap();
drop(ctx);
surface
};
let mut resting = paint(&snapshot, None);
let mut hovered = paint(&snapshot, hover);
let px = ((x + 13.5) * scale).floor() as usize;
let py = ((y + row_h / 2.0) * scale).floor() as usize;
let stride = hovered.stride() as usize;
let offset = py * stride + px * 4;
assert_ne!(
&resting.data().unwrap()[offset..offset + 4],
&hovered.data().unwrap()[offset..offset + 4],
"hover ring at scale {scale}"
);
snapshot.pen_smoothing = 0;
let mut off = paint(&snapshot, None);
let mut off_hover = paint(&snapshot, hover);
assert_eq!(
&off.data().unwrap()[offset..offset + 4],
&off_hover.data().unwrap()[offset..offset + 4],
"active zero has no hover ring"
);
}
}
@@ -17,7 +17,15 @@ pub(super) fn style_stepper_width(control: model::StylePillControl) -> f64 {
} else {
0.0
};
caption + ToolbarLayoutSpec::TOP_STYLE_STEP_W * 2.0 + ToolbarLayoutSpec::TOP_STYLE_SEL_VALUE_W
let zero = if control.stroke_setting().is_some() {
model::METER_ZERO_SLOT_W
} else {
0.0
};
caption
+ zero
+ ToolbarLayoutSpec::TOP_STYLE_STEP_W * 2.0
+ ToolbarLayoutSpec::TOP_STYLE_SEL_VALUE_W
}
/// Push one stepper's nodes starting at `x` on the row whose top is `y`,
@@ -52,6 +60,20 @@ pub(super) fn push_style_stepper(
left += caption_w;
}
if let Some(setting) = control.stroke_setting() {
let zero = setting.zero_segment(snapshot, &id);
nodes.push(WidgetNode::new(
zero.id,
(left, y, model::METER_ZERO_SLOT_W, row_h),
WidgetKind::MeterDot {
active: setting.level(snapshot) == 0,
enabled,
},
enabled.then(|| Interaction::click(zero.event, Some(zero.tooltip))),
));
left += model::METER_ZERO_SLOT_W;
}
nodes.push(WidgetNode::new(
steps[0].id,
(left, y, step_w, row_h),
+14 -5
View File
@@ -1303,8 +1303,12 @@ fn a_press_on_a_meter_bar_activates_that_bar_not_its_neighbour() {
let first = tree
.node_by_id(&"top.style.pen-smoothing.level-1".into())
.expect("first smoothing bar");
// 11px into the row: inside bar 1, and inside bar 2's inflated target.
let (x, y) = (first.rect.0 + 11.0, first.rect.1 + first.rect.3 / 2.0);
assert_eq!(first.rect.2, 14.0, "the zero dot does not shrink the bars");
// Inside bar 1, even where neighbouring minimum-target inflation overlaps.
let (x, y) = (
first.rect.0 + first.rect.2 / 2.0,
first.rect.1 + first.rect.3 / 2.0,
);
let pressed = crate::backend::wayland::toolbar::hit::find_hit(&hits, x, y, |hit| {
crate::backend::wayland::toolbar::hit::intent_for_hit(hit, x, y)
})
@@ -1363,7 +1367,11 @@ fn shape_pen_meters_carry_captions_and_level_bars_inside_the_planned_width() {
// One interactive bar per level above zero, abutting the caption and
// each other, filling the fixed bar row, filled up to the level.
let mut left = caption.rect.0 + caption.rect.2;
let zero = tree
.node_by_id(&format!("{meter_id}.level-0").into())
.expect("zero dot");
assert_eq!(zero.rect.2, model::METER_ZERO_SLOT_W);
let mut left = caption.rect.0 + caption.rect.2 + model::METER_ZERO_SLOT_W;
for bar in 1..=bars {
let node = tree
.node_by_id(&format!("{meter_id}.level-{bar}").into())
@@ -1443,8 +1451,9 @@ fn shape_pen_steppers_carry_visible_captions_inside_the_planned_width() {
.node_by_id(&format!("{stepper}.minus").into())
.unwrap_or_else(|| panic!("{stepper} minus half"));
assert!(
(minus.rect.0 - (caption.rect.0 + caption.rect.2)).abs() < 1e-9,
"{stepper} − half abuts its caption"
(minus.rect.0 - (caption.rect.0 + caption.rect.2 + model::METER_ZERO_SLOT_W)).abs()
< 1e-9,
"{stepper} − half follows the zero dot"
);
// The readout is the value being changed: primary tone, bold,
+3 -1
View File
@@ -131,7 +131,9 @@ fn limited_history_copy_cost() {
use std::{hint::black_box, time::Instant};
let mut frame = Frame::new();
let shape = Shape::Freehand {
points: (0..4096).map(|i| (i, i % 100)).collect(),
points: (0..4096)
.map(|i| (f64::from(i), f64::from(i % 100)))
.collect(),
color: BLACK,
thick: 2.0,
};
+7 -7
View File
@@ -41,7 +41,7 @@ impl LaserStyle {
}
/// Bounds of everything [`render_laser_stroke`] paints for `points`.
pub(crate) fn bounds(self, points: &[(i32, i32)]) -> Option<Rect> {
pub(crate) fn bounds<T: Copy + Into<f64>>(self, points: &[(T, T)]) -> Option<Rect> {
bounding_box_for_points(points, self.glow_width())
}
@@ -72,9 +72,9 @@ impl LaserStyle {
/// near-white centre line, all scaled by `opacity`.
///
/// A single point paints a round dot, so a tap still shows where it landed.
pub(crate) fn render_laser_stroke(
pub(crate) fn render_laser_stroke<T: Copy + Into<f64>>(
ctx: &cairo::Context,
points: &[(i32, i32)],
points: &[(T, T)],
style: LaserStyle,
opacity: f64,
) {
@@ -115,17 +115,17 @@ pub(crate) fn render_laser_stroke(
let _ = ctx.restore();
}
fn trace_path(ctx: &cairo::Context, points: &[(i32, i32)]) {
fn trace_path<T: Copy + Into<f64>>(ctx: &cairo::Context, points: &[(T, T)]) {
let (x0, y0) = points[0];
ctx.new_path();
ctx.move_to(f64::from(x0), f64::from(y0));
ctx.move_to(x0.into(), y0.into());
if points.len() == 1 {
// A zero-length segment with round caps is how Cairo paints a dot.
ctx.line_to(f64::from(x0), f64::from(y0));
ctx.line_to(x0.into(), y0.into());
return;
}
for &(x, y) in &points[1..] {
ctx.line_to(f64::from(x), f64::from(y));
ctx.line_to(x.into(), y.into());
}
}
+24 -24
View File
@@ -63,7 +63,7 @@ impl PressureStrokeSegment {
}
fn pressure_stroke_samples(
points: &[(i32, i32)],
points: &[(f64, f64)],
thicknesses: &[f32],
) -> Vec<PressureStrokeSample> {
let len = points.len().min(thicknesses.len());
@@ -77,7 +77,7 @@ fn pressure_stroke_samples(
}
fn packed_pressure_stroke_samples(
points: &[(i32, i32, f32)],
points: &[(f64, f64, f32)],
thickness_delta: f32,
) -> Vec<PressureStrokeSample> {
pressure_stroke_samples_from_iter(
@@ -89,7 +89,7 @@ fn packed_pressure_stroke_samples(
}
fn pressure_stroke_samples_from_iter(
points: impl Iterator<Item = (i32, i32, f32)>,
points: impl Iterator<Item = (f64, f64, f32)>,
capacity: usize,
) -> Vec<PressureStrokeSample> {
let mut samples = Vec::with_capacity(capacity);
@@ -97,8 +97,8 @@ fn pressure_stroke_samples_from_iter(
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,
x,
y,
radius: width / 2.0,
};
@@ -133,7 +133,7 @@ fn pressure_stroke_samples_from_iter(
#[cfg(test)]
fn pressure_stroke_segments(
points: &[(i32, i32)],
points: &[(f64, f64)],
thicknesses: &[f32],
) -> Vec<PressureStrokeSegment> {
let samples = pressure_stroke_samples(points, thicknesses);
@@ -185,7 +185,7 @@ fn fill_pressure_geometry(ctx: &cairo::Context, samples: &[PressureStrokeSample]
}
fn pressure_stroke_preview_samples(
points: &[(i32, i32)],
points: &[(f64, f64)],
thicknesses: &[f32],
) -> Vec<PressureStrokeSample> {
let len = points.len().min(thicknesses.len());
@@ -195,8 +195,8 @@ fn pressure_stroke_preview_samples(
let (x, y) = points[i];
let width = (thicknesses[i] as f64).max(PRESSURE_STROKE_MIN_WIDTH);
samples.push(PressureStrokeSample {
x: x as f64,
y: y as f64,
x,
y,
radius: width / 2.0,
});
}
@@ -210,7 +210,7 @@ fn pressure_stroke_preview_samples(
/// the stylus is still down. Committed pressure strokes use the mask renderer.
pub(crate) fn render_freehand_pressure_preview_borrowed(
ctx: &cairo::Context,
points: &[(i32, i32)],
points: &[(f64, f64)],
thicknesses: &[f32],
color: Color,
) {
@@ -241,7 +241,7 @@ pub(crate) fn render_freehand_pressure_preview_borrowed(
/// Render a variable-thickness freehand stroke (pressure sensitive).
pub fn render_freehand_pressure_borrowed(
ctx: &cairo::Context,
points: &[(i32, i32)],
points: &[(f64, f64)],
thicknesses: &[f32],
color: Color,
) {
@@ -257,7 +257,7 @@ pub fn render_freehand_pressure_borrowed(
/// the stored stroke into parallel coordinate and thickness buffers.
pub(crate) fn render_packed_freehand_pressure_borrowed(
ctx: &cairo::Context,
points: &[(i32, i32, f32)],
points: &[(f64, f64, f32)],
thickness_delta: f32,
color: Color,
) {
@@ -331,8 +331,8 @@ mod tests {
#[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 packed = [(10.0, 20.0, 4.0), (30.0, 40.0, 8.0), (50.0, 25.0, 2.0)];
let points = [(10.0, 20.0), (30.0, 40.0), (50.0, 25.0)];
let thicknesses = [4.0, 8.0, 2.0];
assert_eq!(
@@ -343,8 +343,8 @@ mod tests {
#[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 packed = [(10.0, 20.0, 4.0), (30.0, 40.0, 8.0)];
let points = [(10.0, 20.0), (30.0, 40.0)];
let thicknesses = [8.0, 12.0];
assert_eq!(
@@ -355,7 +355,7 @@ mod tests {
#[test]
fn pressure_stroke_preview_renders_nonblank_output() {
let points = [(20, 140), (100, 50), (220, 50), (320, 110)];
let points = [(20.0, 140.0), (100.0, 50.0), (220.0, 50.0), (320.0, 110.0)];
let thicknesses = [32.0, 18.0, 5.0, 1.0];
let (mut surface, ctx) = surface_with_context(360, 180);
@@ -377,7 +377,7 @@ mod tests {
#[test]
fn pressure_stroke_preview_covers_backtracking_turnaround() {
let points = [(40, 80), (160, 80), (100, 80)];
let points = [(40.0, 80.0), (160.0, 80.0), (100.0, 80.0)];
let thicknesses = [30.0, 30.0, 30.0];
let (mut surface, ctx) = surface_with_context(200, 140);
@@ -402,7 +402,7 @@ mod tests {
#[test]
fn pressure_stroke_preview_translucent_overlap_applies_alpha_once() {
let points = [(40, 80), (160, 80), (100, 80)];
let points = [(40.0, 80.0), (160.0, 80.0), (100.0, 80.0)];
let thicknesses = [30.0, 30.0, 30.0];
let expected_alpha = (0.35_f64 * 255.0).round() as i32;
let (mut surface, ctx) = surface_with_context(200, 140);
@@ -429,7 +429,7 @@ mod tests {
#[test]
fn pressure_stroke_sparse_drop_geometry_is_valid_and_centerline_covered() {
let points = [(20, 140), (100, 50), (220, 50), (320, 110)];
let points = [(20.0, 140.0), (100.0, 50.0), (220.0, 50.0), (320.0, 110.0)];
let thicknesses = [32.0, 18.0, 5.0, 1.0];
let segments = pressure_stroke_segments(&points, &thicknesses);
assert!(segments.len() > points.len() - 1);
@@ -454,10 +454,10 @@ mod tests {
let (x1, y1) = window[1];
for step in 1..20 {
let t = step as f64 / 20.0;
let x = (x0 as f64 + (x1 - x0) as f64 * t).round() as i32;
let y = (y0 as f64 + (y1 - y0) as f64 * t).round() as i32;
let x = x0 + (x1 - x0) * t;
let y = y0 + (y1 - y0) * t;
assert!(
alpha_at(&mut surface, x, y) > 160,
alpha_at(&mut surface, x.round() as i32, y.round() as i32) > 160,
"expected pressure stroke to cover centerline at ({x}, {y})"
);
}
@@ -466,7 +466,7 @@ mod tests {
#[test]
fn pressure_stroke_translucent_overlap_applies_alpha_once() {
let points = [(40, 80), (160, 80), (40, 80)];
let points = [(40.0, 80.0), (160.0, 80.0), (40.0, 80.0)];
let thicknesses = [30.0, 30.0, 30.0];
let expected_alpha = (0.35_f64 * 255.0).round() as i32;
+8 -8
View File
@@ -6,9 +6,9 @@ use crate::draw::shape::{EraserBrush, EraserKind};
///
/// This function accepts a borrowed slice, avoiding clones for better performance.
/// Use this for rendering provisional shapes during drawing to prevent quadratic behavior.
pub fn render_freehand_borrowed(
pub fn render_freehand_borrowed<T: Copy + Into<f64>>(
ctx: &cairo::Context,
points: &[(i32, i32)],
points: &[(T, T)],
color: Color,
thick: f64,
) {
@@ -23,11 +23,11 @@ pub fn render_freehand_borrowed(
// Start at first point
let (x0, y0) = points[0];
ctx.move_to(x0 as f64, y0 as f64);
ctx.move_to(x0.into(), y0.into());
// Draw lines through all points
for &(x, y) in &points[1..] {
ctx.line_to(x as f64, y as f64);
ctx.line_to(x.into(), y.into());
}
let _ = ctx.stroke();
@@ -94,9 +94,9 @@ pub(crate) fn render_eraser_stroke(
}
/// Render a marker stroke with soft edges and screen blending to mimic a physical highlighter.
pub fn render_marker_stroke_borrowed(
pub fn render_marker_stroke_borrowed<T: Copy + Into<f64>>(
ctx: &cairo::Context,
points: &[(i32, i32)],
points: &[(T, T)],
color: Color,
thick: f64,
) {
@@ -114,9 +114,9 @@ pub fn render_marker_stroke_borrowed(
ctx.set_line_cap(cairo::LineCap::Round);
ctx.set_line_join(cairo::LineJoin::Round);
let (x0, y0) = points[0];
ctx.move_to(x0 as f64, y0 as f64);
ctx.move_to(x0.into(), y0.into());
for &(x, y) in &points[1..] {
ctx.line_to(x as f64, y as f64);
ctx.line_to(x.into(), y.into());
}
let _ = ctx.stroke();
};
+43 -47
View File
@@ -7,43 +7,52 @@ use super::types::{ArrowLabel, ArrowStyle};
const MIN_COORDINATE: i64 = i32::MIN as i64;
const MAX_COORDINATE_EXCLUSIVE: i64 = i32::MAX as i64 + 1;
pub(crate) fn bounding_box_for_points(points: &[(i32, i32)], thick: f64) -> Option<Rect> {
if points.is_empty() {
return None;
}
let mut min_x = points[0].0;
let mut max_x = points[0].0;
let mut min_y = points[0].1;
let mut max_y = points[0].1;
for &(x, y) in &points[1..] {
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
}
padded_extrema_rect(min_x, min_y, max_x, max_y, stroke_padding(thick))
pub(crate) fn bounding_box_for_points<T: Copy + Into<f64>>(
points: &[(T, T)],
thick: f64,
) -> Option<Rect> {
bounding_box_for_positions(points.iter().map(|&(x, y)| (x.into(), y.into())), thick)
}
pub(super) fn bounding_box_for_pressure_points(points: &[(i32, i32, f32)]) -> Option<Rect> {
let &(first_x, first_y, _) = points.first()?;
let mut min_x = first_x;
let mut max_x = first_x;
let mut min_y = first_y;
let mut max_y = first_y;
let mut max_thick = 0.0f32;
fn bounding_box_for_positions(
mut points: impl Iterator<Item = (f64, f64)>,
thick: f64,
) -> Option<Rect> {
let (x, y) = points.next()?;
if !x.is_finite() || !y.is_finite() {
return None;
}
for &(x, y, thickness) in points {
let (mut min_x, mut max_x) = (x, x);
let (mut min_y, mut max_y) = (y, y);
for (x, y) in points {
if !x.is_finite() || !y.is_finite() {
return None;
}
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
max_thick = max_thick.max(thickness);
}
let padding = i64::from((max_thick as i32 / 2).max(1));
padded_extrema_rect(min_x, min_y, max_x, max_y, padding)
let pad = stroke_padding(thick) as f64;
let extrema = [
(min_x - pad).floor(),
(min_y - pad).floor(),
(max_x + pad).ceil(),
(max_y + pad).ceil(),
];
ensure_positive_rect_i64(
extrema[0] as i64,
extrema[1] as i64,
extrema[2] as i64,
extrema[3] as i64,
)
}
pub(super) fn bounding_box_for_pressure_points(points: &[(f64, f64, f32)]) -> Option<Rect> {
let max_thick = points.iter().map(|p| p.2).fold(0.0_f32, f32::max);
bounding_box_for_positions(points.iter().map(|&(x, y, _)| (x, y)), f64::from(max_thick))
}
pub(crate) fn bounding_box_for_line(
@@ -210,24 +219,11 @@ pub(crate) fn bounding_box_for_arrow_with(
)
}
pub(crate) fn bounding_box_for_eraser(points: &[(i32, i32)], diameter: f64) -> Option<Rect> {
if points.is_empty() {
return None;
}
let padding = stroke_padding(diameter.max(1.0));
let mut min_x = points[0].0;
let mut max_x = points[0].0;
let mut min_y = points[0].1;
let mut max_y = points[0].1;
for &(x, y) in &points[1..] {
min_x = min_x.min(x);
max_x = max_x.max(x);
min_y = min_y.min(y);
max_y = max_y.max(y);
}
padded_extrema_rect(min_x, min_y, max_x, max_y, padding)
pub(crate) fn bounding_box_for_eraser<T: Copy + Into<f64>>(
points: &[(T, T)],
diameter: f64,
) -> Option<Rect> {
bounding_box_for_points(points, diameter.max(1.0))
}
fn stroke_padding(thick: f64) -> i64 {
@@ -320,7 +316,7 @@ mod tests {
#[test]
fn bounding_box_for_points_returns_none_for_empty_input() {
assert_eq!(bounding_box_for_points(&[], 2.0), None);
assert_eq!(bounding_box_for_points::<f64>(&[], 2.0), None);
}
#[test]
+10
View File
@@ -5,6 +5,7 @@ mod bounds;
mod polygon;
mod smoothing;
mod step_marker;
mod stroke_points;
mod text;
mod text_cache;
mod transform;
@@ -15,6 +16,7 @@ pub use polygon::{
REGULAR_POLYGON_MIN_SIDES, clamp_regular_sides,
};
pub use smoothing::{MAX_PEN_SMOOTHING, clamp_pen_smoothing, smooth_path, smooth_pressure_path};
pub(crate) use smoothing::{keep_stroke_sample, smooth_committed_path};
pub use text_cache::TextMeasurer;
pub use types::{
ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, Shape,
@@ -34,5 +36,13 @@ pub(crate) use text_cache::{
VisualLineEdge, configured_layout,
};
/// Integer adapter for shape recognition and tools whose geometry stays integral.
pub(crate) fn quantize_path(points: &[(f64, f64)]) -> Vec<(i32, i32)> {
points
.iter()
.map(|&(x, y)| (x.round() as i32, y.round() as i32))
.collect()
}
#[cfg(test)]
mod tests;
+54 -27
View File
@@ -28,6 +28,22 @@
/// following its own corners and reads as a different line from the one drawn.
pub const MAX_PEN_SMOOTHING: u8 = 6;
/// Keep real pointer samples at least this far apart in logical canvas units.
pub(crate) const MIN_STROKE_SAMPLE_DISTANCE: f64 = 0.75;
pub(crate) fn keep_stroke_sample(points: &[(f64, f64)], point: (f64, f64)) -> bool {
if !point.0.is_finite() || !point.1.is_finite() {
return false;
}
points
.last()
.is_none_or(|last| (point.0 - last.0).hypot(point.1 - last.1) >= MIN_STROKE_SAMPLE_DISTANCE)
}
/// Smoothed interior points are committed on a 1/1000 canvas-unit grid: far
/// below any visible difference, and short enough to keep sessions compact.
const COMMITTED_POINTS_PER_UNIT: f64 = 1000.0;
/// Fewest points a path needs before smoothing can do anything. With two points
/// there is no interior to smooth, and both are pinned.
const MIN_SMOOTHABLE_POINTS: usize = 3;
@@ -41,15 +57,27 @@ pub fn clamp_pen_smoothing(level: u8) -> u8 {
}
/// Smooth an `(x, y)` path. Level 0 returns the path unchanged.
pub fn smooth_path(points: &[(i32, i32)], level: u8) -> Vec<(i32, i32)> {
let Some(smoothed) = smooth_points(points, level, |&(x, y)| (f64::from(x), f64::from(y)))
else {
pub fn smooth_path(points: &[(f64, f64)], level: u8) -> Vec<(f64, f64)> {
let Some(smoothed) = smooth_points(points, level, |&(x, y)| (x, y)) else {
return points.to_vec();
};
smoothed
.into_iter()
.map(|(x, y)| (round_to_i32(x), round_to_i32(y)))
.collect()
}
/// Smooth a finished stroke for commit. Level 0 and the pinned endpoints keep
/// their exact positions; smoothed interior points are snapped to
/// [`COMMITTED_POINTS_PER_UNIT`].
pub(crate) fn smooth_committed_path(points: &[(f64, f64)], level: u8) -> Vec<(f64, f64)> {
let Some(mut smoothed) = smooth_points(points, level, |&(x, y)| (x, y)) else {
return points.to_vec();
};
let end = smoothed.len() - 1;
for (x, y) in &mut smoothed[1..end] {
*x = (*x * COMMITTED_POINTS_PER_UNIT).round() / COMMITTED_POINTS_PER_UNIT;
*y = (*y * COMMITTED_POINTS_PER_UNIT).round() / COMMITTED_POINTS_PER_UNIT;
}
smoothed
}
/// Smooth the positions of a pressure path, leaving every thickness alone.
@@ -59,15 +87,14 @@ pub fn smooth_path(points: &[(i32, i32)], level: u8) -> Vec<(i32, i32)> {
/// tablet rather than from the hand's aim, and averaging them would erase real
/// detail while fixing nothing. Each smoothed position keeps the thickness that
/// was sampled with it.
pub fn smooth_pressure_path(points: &[(i32, i32, f32)], level: u8) -> Vec<(i32, i32, f32)> {
let Some(smoothed) = smooth_points(points, level, |&(x, y, _)| (f64::from(x), f64::from(y)))
else {
pub fn smooth_pressure_path(points: &[(f64, f64, f32)], level: u8) -> Vec<(f64, f64, f32)> {
let Some(smoothed) = smooth_points(points, level, |&(x, y, _)| (x, y)) else {
return points.to_vec();
};
points
.iter()
.zip(smoothed)
.map(|(&(_, _, thickness), (x, y))| (round_to_i32(x), round_to_i32(y), thickness))
.map(|(&(_, _, thickness), (x, y))| (x, y, thickness))
.collect()
}
@@ -113,19 +140,19 @@ fn smooth_points<T>(
Some(current)
}
fn round_to_i32(value: f64) -> i32 {
value
.round()
.clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32
}
#[cfg(test)]
mod tests {
use super::*;
/// A straight run with one sample knocked sideways, as a shaky hand makes.
fn spike() -> Vec<(i32, i32)> {
vec![(0, 0), (10, 0), (20, 12), (30, 0), (40, 0)]
fn spike() -> Vec<(f64, f64)> {
vec![
(0.0, 0.0),
(10.0, 0.0),
(20.0, 12.0),
(30.0, 0.0),
(40.0, 0.0),
]
}
#[test]
@@ -146,7 +173,7 @@ mod tests {
smoothed[2].1 < points[2].1,
"the spike at index 2 must come down, not stay at 12"
);
assert!(smoothed[2].1 > 0, "and not be flattened away entirely");
assert!(smoothed[2].1 > 0.0, "and not be flattened away entirely");
}
#[test]
@@ -183,7 +210,7 @@ mod tests {
#[test]
fn a_straight_line_survives_every_level_unchanged() {
let points = vec![(0, 0), (10, 0), (20, 0), (30, 0)];
let points = vec![(0.0, 0.0), (10.0, 0.0), (20.0, 0.0), (30.0, 0.0)];
for level in 0..=MAX_PEN_SMOOTHING {
assert_eq!(smooth_path(&points, level), points, "level {level}");
@@ -192,7 +219,7 @@ mod tests {
#[test]
fn paths_too_short_to_have_an_interior_are_returned_as_they_are() {
for points in [vec![], vec![(5, 5)], vec![(5, 5), (9, 9)]] {
for points in [vec![], vec![(5.0, 5.0)], vec![(5.0, 5.0), (9.0, 9.0)]] {
assert_eq!(smooth_path(&points, 6), points);
}
}
@@ -211,11 +238,11 @@ mod tests {
#[test]
fn a_pressure_path_keeps_every_thickness_while_its_positions_move() {
let points = vec![
(0, 0, 1.0f32),
(10, 0, 4.0),
(20, 12, 9.0),
(30, 0, 4.0),
(40, 0, 1.0),
(0.0, 0.0, 1.0f32),
(10.0, 0.0, 4.0),
(20.0, 12.0, 9.0),
(30.0, 0.0, 4.0),
(40.0, 0.0, 1.0),
];
let smoothed = smooth_pressure_path(&points, 3);
@@ -226,7 +253,7 @@ mod tests {
"tablet pressure is real detail and is not the hand's shake"
);
assert!(smoothed[2].1 < points[2].1);
assert_eq!(smoothed.first().map(|&(x, y, _)| (x, y)), Some((0, 0)));
assert_eq!(smoothed.first().map(|&(x, y, _)| (x, y)), Some((0.0, 0.0)));
}
#[test]
+138
View File
@@ -0,0 +1,138 @@
//! Floating stroke coordinates accept legacy JSON integers and validate both directions.
use serde::{Deserialize, Deserializer, Serialize, Serializer};
fn valid(x: f64, y: f64) -> bool {
[x, y]
.into_iter()
.all(|v| v.is_finite() && v >= f64::from(i32::MIN) && v <= f64::from(i32::MAX))
}
pub(super) fn serialize<S: Serializer>(
points: &[(f64, f64)],
serializer: S,
) -> Result<S::Ok, S::Error> {
if points.iter().any(|&(x, y)| !valid(x, y)) {
return Err(serde::ser::Error::custom("invalid stroke coordinate"));
}
points.serialize(serializer)
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<(f64, f64)>, D::Error> {
let points = Vec::<(f64, f64)>::deserialize(deserializer)?;
if points.iter().any(|&(x, y)| !valid(x, y)) {
return Err(serde::de::Error::custom("invalid stroke coordinate"));
}
Ok(points)
}
pub(super) fn serialize_pressure<S: Serializer>(
points: &[(f64, f64, f32)],
serializer: S,
) -> Result<S::Ok, S::Error> {
if points
.iter()
.any(|&(x, y, width)| !valid(x, y) || !width.is_finite())
{
return Err(serde::ser::Error::custom("invalid pressure stroke sample"));
}
points.serialize(serializer)
}
pub(super) fn deserialize_pressure<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<Vec<(f64, f64, f32)>, D::Error> {
let points = Vec::<(f64, f64, f32)>::deserialize(deserializer)?;
if points
.iter()
.any(|&(x, y, width)| !valid(x, y) || !width.is_finite())
{
return Err(serde::de::Error::custom("invalid pressure stroke sample"));
}
Ok(points)
}
#[cfg(test)]
mod tests {
use crate::draw::{Shape, color::BLACK};
#[test]
fn fractional_ink_survives_selection_transforms_and_serialization() {
for shape in [
Shape::Freehand {
points: vec![(10.25, 20.5), (13.75, 22.125)],
color: BLACK,
thick: 2.0,
},
Shape::FreehandPressure {
points: vec![(10.25, 20.5, 2.0), (13.75, 22.125, 4.0)],
color: BLACK,
},
Shape::MarkerStroke {
points: vec![(10.25, 20.5), (13.75, 22.125)],
color: BLACK,
thick: 2.0,
},
] {
let mut transformed = shape.scaled(1.5, 0.5, 0.0, 0.0);
transformed.translate(3, -2);
let expected = [(18.375, 8.25), (23.625, 9.0625)];
match &transformed {
Shape::Freehand { points, .. } | Shape::MarkerStroke { points, .. } => {
assert_eq!(points, &expected)
}
Shape::FreehandPressure { points, .. } => {
assert_eq!(points, &[(18.375, 8.25, 2.0), (23.625, 9.0625, 4.0)])
}
_ => unreachable!(),
}
let encoded = serde_json::to_vec(&transformed).unwrap();
assert_eq!(
serde_json::from_slice::<Shape>(&encoded).unwrap(),
transformed
);
let bounds = transformed.bounding_box().unwrap();
assert!(bounds.contains(18, 8));
assert!(bounds.contains(24, 10));
}
}
#[test]
fn invalid_stroke_coordinates_and_pressure_cannot_be_saved_or_loaded() {
for x in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e30] {
for shape in [
Shape::Freehand {
points: vec![(x, 1.0)],
color: BLACK,
thick: 2.0,
},
Shape::MarkerStroke {
points: vec![(x, 1.0)],
color: BLACK,
thick: 2.0,
},
Shape::FreehandPressure {
points: vec![(x, 1.0, 2.0)],
color: BLACK,
},
] {
assert!(serde_json::to_vec(&shape).is_err());
}
}
let shape = Shape::FreehandPressure {
points: vec![(1.0, 2.0, f32::NAN)],
color: BLACK,
};
assert!(serde_json::to_vec(&shape).is_err());
let shape = Shape::Freehand {
points: vec![(1.0, 2.0)],
color: BLACK,
thick: 2.0,
};
let mut value = serde_json::to_value(shape).unwrap();
value["Freehand"]["points"][0][0] = serde_json::json!(1e30);
assert!(serde_json::from_value::<Shape>(value).is_err());
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ use crate::util;
#[test]
fn freehand_bounding_box_expands_with_thickness() {
let shape = Shape::Freehand {
points: vec![(10, 20), (30, 40)],
points: vec![(10.0, 20.0), (30.0, 40.0)],
color: WHITE,
thick: 6.0,
};
@@ -422,7 +422,7 @@ fn step_marker_bounding_box_is_square_and_contains_center() {
#[test]
fn marker_bounding_box_uses_inflated_thickness() {
let shape = Shape::MarkerStroke {
points: vec![(0, 0), (10, 0)],
points: vec![(0.0, 0.0), (10.0, 0.0)],
color: WHITE,
thick: 4.0,
};
@@ -474,7 +474,7 @@ fn image_bounding_box_and_kind_name_use_display_bounds() {
#[test]
fn pressure_and_image_bounds_handle_extreme_coordinates() {
let pressure = Shape::FreehandPressure {
points: vec![(i32::MAX, i32::MAX, 2.0)],
points: vec![(f64::from(i32::MAX), f64::from(i32::MAX), 2.0)],
color: WHITE,
};
let pressure_bounds = pressure
+26 -11
View File
@@ -7,14 +7,14 @@ impl Shape {
match self {
Shape::Freehand { points, .. } => {
for point in points {
point.0 += dx;
point.1 += dy;
point.0 += f64::from(dx);
point.1 += f64::from(dy);
}
}
Shape::FreehandPressure { points, .. } => {
for point in points {
point.0 += dx;
point.1 += dy;
point.0 += f64::from(dx);
point.1 += f64::from(dy);
}
}
Shape::Line { x1, y1, x2, y2, .. } => {
@@ -69,8 +69,8 @@ impl Shape {
}
Shape::MarkerStroke { points, .. } => {
for point in points {
point.0 += dx;
point.1 += dy;
point.0 += f64::from(dx);
point.1 += f64::from(dy);
}
}
Shape::EraserStroke { points, .. } => {
@@ -265,7 +265,8 @@ impl Shape {
color,
thick,
} => {
let scaled_points = scale_points(points, anchor_x, anchor_y, scale_x, scale_y);
let scaled_points =
scale_stroke_points(points, anchor_x, anchor_y, scale_x, scale_y);
Shape::Freehand {
points: scaled_points,
color: *color,
@@ -285,7 +286,8 @@ impl Shape {
color,
thick,
} => {
let scaled_points = scale_points(points, anchor_x, anchor_y, scale_x, scale_y);
let scaled_points =
scale_stroke_points(points, anchor_x, anchor_y, scale_x, scale_y);
Shape::MarkerStroke {
points: scaled_points,
color: *color,
@@ -398,20 +400,33 @@ fn scale_points(
}
fn scale_points_with_pressure(
points: &[(i32, i32, f32)],
points: &[(f64, f64, f32)],
anchor_x: f64,
anchor_y: f64,
scale_x: f64,
scale_y: f64,
) -> Vec<(i32, i32, f32)> {
) -> Vec<(f64, f64, f32)> {
points
.iter()
.map(|(x, y, pressure)| {
let (x, y) = scale_point_i32(*x, *y, anchor_x, anchor_y, scale_x, scale_y);
let (x, y) = scale_point(*x, *y, anchor_x, anchor_y, scale_x, scale_y);
(x, y, *pressure)
})
.collect()
}
fn scale_stroke_points(
points: &[(f64, f64)],
anchor_x: f64,
anchor_y: f64,
scale_x: f64,
scale_y: f64,
) -> Vec<(f64, f64)> {
points
.iter()
.map(|&(x, y)| scale_point(x, y, anchor_x, anchor_y, scale_x, scale_y))
.collect()
}
#[cfg(test)]
mod tests;
+3 -3
View File
@@ -24,12 +24,12 @@ fn every_shape_variant_translates_its_bounds() {
};
let mut shapes = vec![
Shape::Freehand {
points: vec![(10, 20), (30, 40)],
points: vec![(10.0, 20.0), (30.0, 40.0)],
color: WHITE,
thick: 2.0,
},
Shape::FreehandPressure {
points: vec![(10, 20, 1.5), (30, 40, 2.5)],
points: vec![(10.0, 20.0, 1.5), (30.0, 40.0, 2.5)],
color: WHITE,
},
Shape::Line {
@@ -127,7 +127,7 @@ fn every_shape_variant_translates_its_bounds() {
wrap_width: Some(80),
},
Shape::MarkerStroke {
points: vec![(10, 20), (30, 40)],
points: vec![(10.0, 20.0), (30.0, 40.0)],
color: WHITE,
thick: 8.0,
},
+9 -3
View File
@@ -198,7 +198,8 @@ pub enum Shape {
/// Freehand drawing - polyline connecting mouse drag points
Freehand {
/// Sequence of (x, y) coordinates traced by the mouse
points: Vec<(i32, i32)>,
#[serde(with = "super::stroke_points")]
points: Vec<(f64, f64)>,
/// Stroke color
color: Color,
/// Line thickness in pixels
@@ -207,7 +208,11 @@ pub enum Shape {
/// Freehand drawing with variable thickness (pressure sensitivity)
FreehandPressure {
/// Sequence of (x, y, thickness) coordinates
points: Vec<(i32, i32, f32)>,
#[serde(
serialize_with = "super::stroke_points::serialize_pressure",
deserialize_with = "super::stroke_points::deserialize_pressure"
)]
points: Vec<(f64, f64, f32)>,
/// Stroke color
color: Color,
},
@@ -418,7 +423,8 @@ pub enum Shape {
/// Highlighter-style stroke with translucent ink
MarkerStroke {
/// Sequence of (x, y) coordinates traced by the marker
points: Vec<(i32, i32)>,
#[serde(with = "super::stroke_points")]
points: Vec<(f64, f64)>,
/// Stroke color (alpha controls ink intensity)
color: Color,
/// Stroke thickness in pixels
+8 -8
View File
@@ -1,13 +1,13 @@
pub(super) const EPS: f64 = 1e-6;
pub(super) fn distance_point_to_segment(
pub(super) fn distance_point_to_segment<T: Copy + Into<f64>>(
point: (i32, i32),
start: (i32, i32),
end: (i32, i32),
start: (T, T),
end: (T, T),
) -> f64 {
let (px, py) = (point.0 as f64, point.1 as f64);
let (x1, y1) = (start.0 as f64, start.1 as f64);
let (x2, y2) = (end.0 as f64, end.1 as f64);
let (x1, y1) = (start.0.into(), start.1.into());
let (x2, y2) = (end.0.into(), end.1.into());
let vx = x2 - x1;
let vy = y2 - y1;
let len_sq = vx * vx + vy * vy;
@@ -21,9 +21,9 @@ pub(super) fn distance_point_to_segment(
((px - proj_x).powi(2) + (py - proj_y).powi(2)).sqrt()
}
pub(super) fn distance_point_to_point(a: (i32, i32), b: (i32, i32)) -> f64 {
let dx = (a.0 - b.0) as f64;
let dy = (a.1 - b.1) as f64;
pub(super) fn distance_point_to_point<T: Copy + Into<f64>>(a: (T, T), b: (i32, i32)) -> f64 {
let dx = a.0.into() - f64::from(b.0);
let dy = a.1.into() - f64::from(b.1);
(dx * dx + dy * dy).sqrt()
}
+6 -4
View File
@@ -5,8 +5,8 @@ use super::geometry::{
point_in_triangle, to_i32_pair,
};
pub(super) fn freehand_hit(
points: &[(i32, i32)],
pub(super) fn freehand_hit<T: Copy + Into<f64>>(
points: &[(T, T)],
point: (i32, i32),
thickness: f64,
tolerance: f64,
@@ -29,7 +29,7 @@ pub(super) fn freehand_hit(
}
pub(super) fn freehand_pressure_hit(
points: &[(i32, i32, f32)],
points: &[(f64, f64, f32)],
point: (i32, i32),
tolerance: f64,
) -> bool {
@@ -47,7 +47,9 @@ pub(super) fn freehand_pressure_hit(
for window in points.windows(2) {
let (x1, y1, t1) = window[0];
let (x2, y2, t2) = window[1];
if segment_hit(x1, y1, x2, y2, t1.max(t2) as f64, point, tolerance) {
if distance_point_to_segment(point, (x1, y1), (x2, y2))
<= tolerance.max(f64::from(t1.max(t2)) / 2.0)
{
return true;
}
}
+1 -1
View File
@@ -519,7 +519,7 @@ fn pressure_stroke_hit_includes_one_point_stylus_dots() {
let dot = DrawnShape::with_metadata(
1,
Shape::FreehandPressure {
points: vec![(50, 50, 20.0)],
points: vec![(50.0, 50.0, 20.0)],
color: BLACK,
},
0,
+3 -1
View File
@@ -76,7 +76,7 @@ pub enum DrawingState {
/// Starting Y coordinate (where mouse was pressed)
start_y: i32,
/// Accumulated points for freehand drawing
points: Vec<(i32, i32)>,
points: Vec<(f64, f64)>,
/// Accumulated thickness values for freehand drawing (pressure sensitivity)
point_thicknesses: Vec<f32>,
},
@@ -112,6 +112,8 @@ pub enum DrawingState {
},
/// Pending click on text/note to detect double-click editing
PendingTextClick {
/// Exact canvas position for a click that becomes an ink stroke.
position: (f64, f64),
/// Starting X coordinate
x: i32,
/// Starting Y coordinate
+5 -11
View File
@@ -405,7 +405,7 @@ fn text_edit_block_moved(current: (i32, i32), original: &Shape) -> bool {
}
fn append_only_damage_regions(
points: &[(i32, i32)],
points: &[(f64, f64)],
stroke_width: f64,
fallback: Rect,
) -> Vec<Rect> {
@@ -416,8 +416,8 @@ fn append_only_damage_regions(
return vec![fallback];
}
let dx = f64::from(end.0 - start.0);
let dy = f64::from(end.1 - start.1);
let dx = end.0 - start.0;
let dy = end.1 - start.1;
let steps = (dx.abs().max(dy.abs()) / APPEND_ONLY_DAMAGE_MAX_SPAN).ceil() as usize;
let steps = steps.max(1);
if steps == 1 {
@@ -428,14 +428,8 @@ fn append_only_damage_regions(
for step in 0..steps {
let t0 = step as f64 / steps as f64;
let t1 = (step + 1) as f64 / steps as f64;
let p0 = (
(start.0 as f64 + dx * t0).round() as i32,
(start.1 as f64 + dy * t0).round() as i32,
);
let p1 = (
(start.0 as f64 + dx * t1).round() as i32,
(start.1 as f64 + dy * t1).round() as i32,
);
let p0 = (start.0 + dx * t0, start.1 + dy * t0);
let p1 = (start.0 + dx * t1, start.1 + dy * t1);
if let Some(region) = bounding_box_for_points(&[p0, p1], stroke_width) {
regions.push(region);
}
@@ -252,7 +252,7 @@ mod tests {
.boards
.active_frame_mut()
.add_shape(Shape::MarkerStroke {
points: vec![(0, 0), (10, 10)],
points: vec![(0.0, 0.0), (10.0, 10.0)],
color: Color {
r: 0.0,
g: 0.0,
+1 -1
View File
@@ -445,7 +445,7 @@ mod tests {
.boards
.active_frame_mut()
.add_shape(Shape::MarkerStroke {
points: vec![(0, 0), (10, 10)],
points: vec![(0.0, 0.0), (10.0, 10.0)],
color: Color {
a: 0.2,
..PALETTE_RED
+1 -1
View File
@@ -411,7 +411,7 @@ mod tests {
wrap_width: None,
};
let marker = Shape::MarkerStroke {
points: vec![(0, 0), (5, 5)],
points: vec![(0.0, 0.0), (5.0, 5.0)],
color: Color {
r: 1.0,
g: 0.5,
+3 -2
View File
@@ -226,12 +226,13 @@ mod tests {
tool: Tool::Pen,
start_x: 10,
start_y: 20,
points: vec![(10, 20)],
points: vec![(10.0, 20.0)],
point_thicknesses: vec![2.0],
},
DrawingState::PendingTextClick {
x: 10,
y: 20,
position: (10.0, 20.0),
tool: Tool::Pen,
shape_id: 1,
},
@@ -422,7 +423,7 @@ mod tests {
fn test_shape_snapshot() -> ShapeSnapshot {
ShapeSnapshot {
shape: Shape::Freehand {
points: vec![(0, 0), (1, 1)],
points: vec![(0.0, 0.0), (1.0, 1.0)],
color: BLACK,
thick: 1.0,
},
+13 -11
View File
@@ -50,6 +50,13 @@ impl CloneStorageEstimate {
self.non_image_shape_count = self.non_image_shape_count.saturating_add(1);
}
fn add_point_shape(&mut self, count: usize, bytes_per_point: u64) {
self.add_non_image_shape(
NON_IMAGE_SHAPE_RAW_OVERHEAD_BYTES
.saturating_add(usize_to_u64(count).saturating_mul(bytes_per_point)),
);
}
fn saturating_add(self, other: Self) -> Self {
Self {
raw_bytes: self.raw_bytes.saturating_add(other.raw_bytes),
@@ -429,19 +436,14 @@ fn estimate_frame_page_storage(page: &Frame) -> CloneStorageEstimate {
fn estimate_shape_storage(shape: &Shape) -> CloneStorageEstimate {
let mut estimate = CloneStorageEstimate::default();
match shape {
Shape::Freehand { points, .. }
| Shape::MarkerStroke { points, .. }
| Shape::EraserStroke { points, .. }
| Shape::Polygon { points, .. } => {
estimate.add_non_image_shape(
NON_IMAGE_SHAPE_RAW_OVERHEAD_BYTES
.saturating_add(usize_to_u64(points.len()).saturating_mul(POINT_JSON_BYTES)),
);
Shape::Freehand { points, .. } | Shape::MarkerStroke { points, .. } => {
estimate.add_point_shape(points.len(), POINT_JSON_BYTES);
}
Shape::EraserStroke { points, .. } | Shape::Polygon { points, .. } => {
estimate.add_point_shape(points.len(), POINT_JSON_BYTES);
}
Shape::FreehandPressure { points, .. } => {
estimate.add_non_image_shape(NON_IMAGE_SHAPE_RAW_OVERHEAD_BYTES.saturating_add(
usize_to_u64(points.len()).saturating_mul(PRESSURE_POINT_JSON_BYTES),
));
estimate.add_point_shape(points.len(), PRESSURE_POINT_JSON_BYTES);
}
Shape::Image { data, .. } => estimate.add_image(data.bytes.len(), &data.mime_type),
Shape::Text {
+1 -1
View File
@@ -388,7 +388,7 @@ mod tests {
tool: Tool::Pen,
start_x: 10,
start_y: 20,
points: vec![(10, 20), (30, 40)],
points: vec![(10.0, 20.0), (30.0, 40.0)],
point_thicknesses: vec![1.0, 1.0],
};
state.begin_pointer_drag(MouseButton::Left, None);
@@ -57,6 +57,7 @@ pub(crate) fn handle_active_motion(
if let DrawingState::PendingTextClick {
x: start_x,
y: start_y,
position,
tool,
..
} = &state.state
@@ -70,10 +71,10 @@ pub(crate) fn handle_active_motion(
ToolPressBehavior::StartDrawing { .. }
) {
let drawing_thickness = state.thickness_for_tool(tool);
let mut points = vec![(*start_x, *start_y)];
let mut points = vec![*position];
let mut point_thicknesses = vec![drawing_thickness as f32];
if let Some(sample_size) = motion_sample_size(state, tool) {
points.push((canvas.x(), canvas.y()));
points.push(canvas.position());
point_thicknesses.push(sample_size as f32);
}
state.state = DrawingState::Drawing {
@@ -186,14 +187,23 @@ pub(crate) fn handle_drawing_or_idle_motion(
None
};
if let DrawingState::Drawing {
tool,
points,
point_thicknesses,
..
} = &mut state.state
{
if let Some(thickness) = sample_size {
points.push((canvas.x(), canvas.y()));
point_thicknesses.push(thickness as f32);
if !matches!(tool, Tool::Pen | Tool::Marker | Tool::LiveShape)
|| crate::draw::shape::keep_stroke_sample(points, canvas.position())
{
points.push(canvas.position());
point_thicknesses.push(thickness as f32);
} else if let Some(last) = point_thicknesses.last_mut() {
// Pressure-only tablet frames still contribute their peak,
// without adding coincident positions to the smoothing filter.
*last = last.max(thickness as f32);
}
}
drawing = true;
}
@@ -5,9 +5,9 @@ use super::super::outcome::{
PointerSideEffect, RoutingOutcome,
};
use crate::draw::Shape;
use crate::input::MouseButton;
use crate::input::state::core::MenuCommand;
use crate::input::state::{ContextMenuKind, DrawingState, InputState};
use crate::input::{MouseButton, Tool};
pub(crate) fn update_pointer_positions(state: &mut InputState, points: PointerPoints) {
let screen = points.screen();
@@ -197,7 +197,7 @@ pub(crate) fn handle_tool_button_press(
tool,
binding.color,
(screen.x(), screen.y()),
(canvas.x(), canvas.y()),
canvas,
);
let after = active_interaction_kind(state);
match (before, after) {
@@ -535,5 +535,17 @@ pub(crate) fn finish_pointer_interaction(
points: PointerPoints,
) {
let canvas = points.canvas();
if let DrawingState::Drawing {
tool,
points,
point_thicknesses,
..
} = &mut state.state
&& matches!(tool, Tool::Pen | Tool::Marker | Tool::LiveShape)
&& points.last().copied() != Some(canvas.position())
{
points.push(canvas.position());
point_thicknesses.push(point_thicknesses.last().copied().unwrap_or(1.0));
}
state.finish_pointer_interaction_at_with_measurer(measurer, canvas.x(), canvas.y());
}
+24 -11
View File
@@ -20,27 +20,40 @@ impl ScreenPoint {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct CanvasPoint {
x: i32,
y: i32,
x: f64,
y: f64,
}
impl CanvasPoint {
pub(crate) fn new(x: i32, y: i32) -> Self {
Self { x, y }
pub(crate) fn new(x: impl Into<f64>, y: impl Into<f64>) -> Self {
Self {
x: x.into(),
y: y.into(),
}
}
pub(crate) fn x(self) -> i32 {
self.x
self.x.round() as i32
}
pub(crate) fn y(self) -> i32 {
self.y
self.y.round() as i32
}
pub(crate) fn position(self) -> (f64, f64) {
(self.x, self.y)
}
pub(crate) fn is_valid(self) -> bool {
[self.x, self.y]
.into_iter()
.all(|v| v.is_finite() && v >= f64::from(i32::MIN) && v <= f64::from(i32::MAX))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct PointerPoints {
screen: ScreenPoint,
canvas: CanvasPoint,
@@ -60,7 +73,7 @@ impl PointerPoints {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct PointerPress {
button: MouseButton,
points: PointerPoints,
@@ -80,7 +93,7 @@ impl PointerPress {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct PointerMotion {
points: PointerPoints,
}
@@ -95,7 +108,7 @@ impl PointerMotion {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct PointerRelease {
button: MouseButton,
points: PointerPoints,
+2 -2
View File
@@ -69,7 +69,7 @@ mod tests {
tool: Tool::Pen,
start_x: 1,
start_y: 2,
points: vec![(1, 2)],
points: vec![(1.0, 2.0)],
point_thicknesses: vec![1.0],
};
assert_eq!(
@@ -212,7 +212,7 @@ mod tests {
tool: Tool::Pen,
start_x: 1,
start_y: 2,
points: vec![(1, 2)],
points: vec![(1.0, 2.0)],
point_thicknesses: vec![1.0],
};
state.begin_pointer_drag(MouseButton::Left, None);
+1
View File
@@ -75,6 +75,7 @@ pub(crate) enum KeyboardSideEffect {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NoRouteReason {
InvalidPointerPosition,
NoPointerBinding,
NoActiveInteraction,
NonLeftReleaseWithoutActiveDrag,
+9
View File
@@ -11,6 +11,9 @@ pub(crate) fn route_pointer_press(
event: PointerPress,
) -> RoutingOutcome {
let points = event.points();
if !points.canvas().is_valid() {
return RoutingOutcome::NoRoute(NoRouteReason::InvalidPointerPosition);
}
// A new press invalidates any HUD or zoom-chip press still awaiting its
// release, so a stale flag can never swallow the release of an unrelated
// interaction.
@@ -89,6 +92,9 @@ pub(crate) fn route_pointer_motion(
event: PointerMotion,
) -> RoutingOutcome {
let points = event.points();
if !points.canvas().is_valid() {
return RoutingOutcome::NoRoute(NoRouteReason::InvalidPointerPosition);
}
adapters::update_pointer_positions(state, points);
// Chrome hover affordances update on every motion, before any modal
// early-return: the setters gate themselves (idle pointer + the same
@@ -129,6 +135,9 @@ pub(crate) fn route_pointer_release(
event: PointerRelease,
) -> RoutingOutcome {
let points = event.points();
if !points.canvas().is_valid() {
return RoutingOutcome::NoRoute(NoRouteReason::InvalidPointerPosition);
}
adapters::update_pointer_positions(state, points);
// The menu has already closed on press. Consume its release before any
+7 -9
View File
@@ -23,8 +23,8 @@ impl InputState {
&mut self,
screen_x: i32,
screen_y: i32,
canvas_x: i32,
canvas_y: i32,
canvas_x: impl Into<f64>,
canvas_y: impl Into<f64>,
) {
let measurer = crate::draw::TextMeasurer::default();
let ui_engine = crate::ui_text::UiTextEngine::default();
@@ -45,17 +45,15 @@ impl InputState {
resources: crate::input::state::InputTextResources<'_>,
screen_x: i32,
screen_y: i32,
canvas_x: i32,
canvas_y: i32,
canvas_x: impl Into<f64>,
canvas_y: impl Into<f64>,
) {
// Moving off the loupe ends a wheel adjustment of it. Nothing else runs
// between two wheel bursts over one shape, so without this a visit
// minutes later would merge into the same undo entry.
self.end_spotlight_magnification_gesture_if_pointer_left(canvas_x, canvas_y);
let points = PointerPoints::new(
ScreenPoint::new(screen_x, screen_y),
CanvasPoint::new(canvas_x, canvas_y),
);
let canvas = CanvasPoint::new(canvas_x, canvas_y);
self.end_spotlight_magnification_gesture_if_pointer_left(canvas.x(), canvas.y());
let points = PointerPoints::new(ScreenPoint::new(screen_x, screen_y), canvas);
self.note_session_interaction_activity();
let _ = route_pointer_motion(self, resources.measurer, PointerMotion::new(points));
self.note_session_interaction_activity();
+19 -21
View File
@@ -17,8 +17,7 @@ mod polygon;
struct PressCoords {
screen_x: i32,
screen_y: i32,
canvas_x: i32,
canvas_y: i32,
canvas: CanvasPoint,
}
impl InputState {
@@ -126,8 +125,8 @@ impl InputState {
button: MouseButton,
screen_x: i32,
screen_y: i32,
canvas_x: i32,
canvas_y: i32,
canvas_x: impl Into<f64>,
canvas_y: impl Into<f64>,
) {
let measurer = crate::draw::TextMeasurer::default();
let ui_engine = crate::ui_text::UiTextEngine::default();
@@ -150,8 +149,8 @@ impl InputState {
button: MouseButton,
screen_x: i32,
screen_y: i32,
canvas_x: i32,
canvas_y: i32,
canvas_x: impl Into<f64>,
canvas_y: impl Into<f64>,
) -> super::super::interaction::RoutingOutcome {
// Any press ends a wheel adjustment of a loupe, so the burst lands in
// history as its own entry rather than merging with what follows.
@@ -214,10 +213,10 @@ impl InputState {
self.update_pointer_positions(
coords.screen_x,
coords.screen_y,
coords.canvas_x,
coords.canvas_y,
coords.canvas.x(),
coords.canvas.y(),
);
self.trigger_click_highlight(coords.canvas_x, coords.canvas_y);
self.trigger_click_highlight(coords.canvas.x(), coords.canvas.y());
if self.handle_context_menu_press(coords.screen_x, coords.screen_y) {
return;
@@ -230,8 +229,8 @@ impl InputState {
if button == MouseButton::Left
&& self.handle_text_input_left_press_with(
measurer,
coords.canvas_x,
coords.canvas_y,
coords.canvas.x(),
coords.canvas.y(),
color,
)
{
@@ -244,14 +243,13 @@ impl InputState {
button,
tool,
color,
coords.canvas_x,
coords.canvas_y,
coords.canvas,
),
DrawingState::BuildingPolygon { .. } if button == MouseButton::Left => {
self.handle_building_polygon_left_click_with_measurer(
measurer,
coords.canvas_x,
coords.canvas_y,
coords.canvas.x(),
coords.canvas.y(),
);
}
DrawingState::TextInput { .. }
@@ -421,7 +419,7 @@ impl InputState {
tool: Tool,
color: Option<crate::draw::Color>,
screen: (i32, i32),
canvas: (i32, i32),
canvas: CanvasPoint,
) {
self.handle_tool_button_press_with_measurer(
measurer,
@@ -431,8 +429,7 @@ impl InputState {
PressCoords {
screen_x: screen.0,
screen_y: screen.1,
canvas_x: canvas.0,
canvas_y: canvas.1,
canvas,
},
);
}
@@ -443,9 +440,9 @@ impl InputState {
button: MouseButton,
tool: Tool,
color: Option<crate::draw::Color>,
x: i32,
y: i32,
canvas: CanvasPoint,
) {
let (x, y) = (canvas.x(), canvas.y());
let selection_click =
self.modifiers.alt || matches!(tool.press_behavior(), ToolPressBehavior::Selection);
let hit_id = self.hit_test_at_with(measurer, x, y);
@@ -530,6 +527,7 @@ impl InputState {
self.state = DrawingState::PendingTextClick {
x,
y,
position: canvas.position(),
tool,
shape_id: hit_id,
};
@@ -601,7 +599,7 @@ impl InputState {
tool,
start_x: x,
start_y: y,
points: vec![(x, y)],
points: vec![canvas.position()],
point_thicknesses: vec![drawing_thickness as f32],
};
self.pointer.replace_provisional_bounds(None);
+35 -24
View File
@@ -14,7 +14,7 @@ const FINISHED_PATH_DAMAGE_MAX_SPAN: f64 = 128.0;
pub(super) struct DrawingRelease {
pub(super) start: (i32, i32),
pub(super) end: (i32, i32),
pub(super) points: Vec<(i32, i32)>,
pub(super) points: Vec<(f64, f64)>,
pub(super) point_thicknesses: Vec<f32>,
}
@@ -254,8 +254,8 @@ fn follow_committed_stroke(
///
/// The result is inflated the way the marker's own damage is, so one
/// calculation covers the widest preview any path tool draws.
fn raw_preview_damage_regions(
points: &[(i32, i32)],
fn raw_preview_damage_regions<T: Copy + Into<f64>>(
points: &[(T, T)],
thickness: f64,
point_thicknesses: &[f32],
) -> Option<Vec<Rect>> {
@@ -306,8 +306,8 @@ fn finished_path_damage_regions(shape: &Shape, fallback: Option<Rect>) -> Option
}
}
fn split_path_damage_regions(
points: &[(i32, i32)],
fn split_path_damage_regions<T: Copy + Into<f64>>(
points: &[(T, T)],
stroke_width: f64,
fallback: Rect,
) -> Vec<Rect> {
@@ -317,7 +317,12 @@ fn split_path_damage_regions(
let mut regions = Vec::new();
for segment in points.windows(2) {
append_segment_damage_regions(segment[0], segment[1], stroke_width, &mut regions);
append_segment_damage_regions(
(segment[0].0.into(), segment[0].1.into()),
(segment[1].0.into(), segment[1].1.into()),
stroke_width,
&mut regions,
);
}
if regions.is_empty() {
@@ -328,8 +333,8 @@ fn split_path_damage_regions(
}
fn append_segment_damage_regions(
start: (i32, i32),
end: (i32, i32),
start: (f64, f64),
end: (f64, f64),
stroke_width: f64,
regions: &mut Vec<Rect>,
) {
@@ -340,22 +345,16 @@ fn append_segment_damage_regions(
return;
}
let dx = f64::from(end.0) - f64::from(start.0);
let dy = f64::from(end.1) - f64::from(start.1);
let dx = end.0 - start.0;
let dy = end.1 - start.1;
let steps = (dx.abs().max(dy.abs()) / FINISHED_PATH_DAMAGE_MAX_SPAN).ceil() as usize;
let steps = steps.max(1);
for step in 0..steps {
let t0 = step as f64 / steps as f64;
let t1 = (step + 1) as f64 / steps as f64;
let p0 = (
(f64::from(start.0) + dx * t0).round() as i32,
(f64::from(start.1) + dy * t0).round() as i32,
);
let p1 = (
(f64::from(start.0) + dx * t1).round() as i32,
(f64::from(start.1) + dy * t1).round() as i32,
);
let p0 = (start.0 + dx * t0, start.1 + dy * t0);
let p1 = (start.0 + dx * t1, start.1 + dy * t1);
if let Some(region) = bounding_box_for_points(&[p0, p1], stroke_width) {
regions.push(region);
}
@@ -400,7 +399,7 @@ mod tests {
/// Draw `path` with `tool` at the state's current smoothing level and return
/// the points the committed shape ended up with.
fn drawn_points(level: u8, tool: Tool) -> Vec<(i32, i32)> {
fn drawn_points(level: u8, tool: Tool) -> Vec<(f64, f64)> {
let mut state = make_test_input_state();
state.set_pen_smoothing(level);
state.set_tool_override(Some(tool));
@@ -433,8 +432,8 @@ mod tests {
let raw = drawn_points(0, Tool::Pen);
let smoothed = drawn_points(4, Tool::Pen);
let raw_spike = raw.iter().map(|&(_, y)| y).max().unwrap();
let smoothed_spike = smoothed.iter().map(|&(_, y)| y).max().unwrap();
let raw_spike = raw.iter().map(|&(_, y)| y).reduce(f64::max).unwrap();
let smoothed_spike = smoothed.iter().map(|&(_, y)| y).reduce(f64::max).unwrap();
assert!(
smoothed_spike < raw_spike,
"level 4 must pull the spike down from {raw_spike}, got {smoothed_spike}"
@@ -443,7 +442,13 @@ mod tests {
#[test]
fn level_zero_commits_the_exact_path_the_pointer_drew() {
assert_eq!(drawn_points(0, Tool::Pen), shaky_path());
assert_eq!(
drawn_points(0, Tool::Pen),
shaky_path()
.iter()
.map(|&(x, y)| (f64::from(x), f64::from(y)))
.collect::<Vec<_>>()
);
}
#[test]
@@ -451,8 +456,14 @@ mod tests {
let path = shaky_path();
let smoothed = drawn_points(6, Tool::Pen);
assert_eq!(smoothed.first(), path.first());
assert_eq!(smoothed.last(), path.last());
assert_eq!(
smoothed.first().copied(),
path.first().map(|&(x, y)| (f64::from(x), f64::from(y)))
);
assert_eq!(
smoothed.last().copied(),
path.last().map(|&(x, y)| (f64::from(x), f64::from(y)))
);
}
/// Draw the shaky path at `level` and return the damage the release left.
+4 -4
View File
@@ -42,8 +42,8 @@ impl InputState {
button: MouseButton,
screen_x: i32,
screen_y: i32,
canvas_x: i32,
canvas_y: i32,
canvas_x: impl Into<f64>,
canvas_y: impl Into<f64>,
) {
let measurer = crate::draw::TextMeasurer::default();
let ui_engine = crate::ui_text::UiTextEngine::default();
@@ -66,8 +66,8 @@ impl InputState {
button: MouseButton,
screen_x: i32,
screen_y: i32,
canvas_x: i32,
canvas_y: i32,
canvas_x: impl Into<f64>,
canvas_y: impl Into<f64>,
) {
let points = PointerPoints::new(
ScreenPoint::new(screen_x, screen_y),
+3 -3
View File
@@ -437,8 +437,8 @@ impl InputState {
}
}
fn path_damage_ranges(
points: &[(i32, i32)],
fn path_damage_ranges<T: Copy + Into<f64>>(
points: &[(T, T)],
damage_regions: &[Rect],
stroke_width: f64,
) -> Vec<Range<usize>> {
@@ -497,7 +497,7 @@ fn push_merged_range(ranges: &mut Vec<Range<usize>>, next: Range<usize>) {
ranges.push(next);
}
fn segment_bounds(a: (i32, i32), b: (i32, i32), stroke_width: f64) -> Option<Rect> {
fn segment_bounds<T: Copy + Into<f64>>(a: (T, T), b: (T, T), stroke_width: f64) -> Option<Rect> {
bounding_box_for_points(&[a, b], stroke_width)
}
+2 -2
View File
@@ -105,7 +105,7 @@ fn switch_board_cancels_active_drawing_through_lifecycle_transition() {
tool: Tool::Pen,
start_x: 10,
start_y: 20,
points: vec![(10, 20), (30, 40)],
points: vec![(10.0, 20.0), (30.0, 40.0)],
point_thicknesses: vec![1.0, 1.0],
};
state.begin_pointer_drag(MouseButton::Left, None);
@@ -127,7 +127,7 @@ fn failed_switch_board_preserves_active_interaction() {
tool: Tool::Pen,
start_x: 10,
start_y: 20,
points: vec![(10, 20), (30, 40)],
points: vec![(10.0, 20.0), (30.0, 40.0)],
point_thicknesses: vec![1.0, 1.0],
};
state.begin_pointer_drag(MouseButton::Left, None);
+3 -3
View File
@@ -386,7 +386,7 @@ fn pending_page_delete_survives_active_board_drift_and_deletes_original_page() {
tool: Tool::Pen,
start_x: 10,
start_y: 20,
points: vec![(10, 20), (30, 40)],
points: vec![(10.0, 20.0), (30.0, 40.0)],
point_thicknesses: vec![1.0, 1.0],
};
state.begin_pointer_drag(MouseButton::Left, None);
@@ -426,7 +426,7 @@ fn stale_active_page_delete_confirmation_does_not_cancel_active_interaction() {
tool: Tool::Pen,
start_x: 10,
start_y: 20,
points: vec![(10, 20), (30, 40)],
points: vec![(10.0, 20.0), (30.0, 40.0)],
point_thicknesses: vec![1.0, 1.0],
};
state.begin_pointer_drag(MouseButton::Left, None);
@@ -467,7 +467,7 @@ fn stale_board_panel_page_delete_confirmation_does_not_cancel_active_interaction
tool: Tool::Pen,
start_x: 10,
start_y: 20,
points: vec![(10, 20), (30, 40)],
points: vec![(10.0, 20.0), (30.0, 40.0)],
point_thicknesses: vec![1.0, 1.0],
};
state.begin_pointer_drag(MouseButton::Left, None);
+1 -1
View File
@@ -1252,7 +1252,7 @@ fn provisional_shape_readout_excludes_freehand_and_non_size_badge_tools() {
tool,
start_x: 10,
start_y: 20,
points: vec![(10, 20)],
points: vec![(10.0, 20.0)],
point_thicknesses: vec![2.0],
};
+1 -1
View File
@@ -142,7 +142,7 @@ fn the_live_laser_preview_uses_the_laser_style_and_glow_damage() {
match state.provisional_tool_stroke(140, 100) {
ProvisionalToolStroke::Laser { points, style } => {
assert_eq!(points.last(), Some(&(140, 100)));
assert_eq!(points.last(), Some(&(140.0, 100.0)));
assert_eq!(style.width, 10.0);
assert_eq!(style.color.g, 0.9);
}
+1
View File
@@ -26,6 +26,7 @@ mod light_mode;
mod menus;
mod modal;
mod pages;
mod pen_sampling;
mod presenter_mode;
mod pressure_modes;
mod properties_panel;
+192
View File
@@ -0,0 +1,192 @@
//! Input-to-committed-ink precision, sampling, and fractional-scale proof.
use super::*;
fn curve(x: f64) -> f64 {
300.2 + 0.10 * x + 4.0 * (x / 30.0).sin()
}
fn samples(step: f64) -> Vec<(f64, f64)> {
let mut points: Vec<_> = (0..=(180.0 / step).floor() as usize)
.map(|i| {
let x = i as f64 * step;
(300.2 + x, curve(x))
})
.collect();
let end = (480.2, curve(180.0));
if points.last() != Some(&end) {
points.push(end);
}
points
}
fn draw(points: &[(f64, f64)], level: u8, tool: Tool) -> Shape {
let mut state = create_test_input_state();
state.set_tool_override(Some(tool));
state.set_pen_smoothing(level);
let first = points[0];
state.on_mouse_press_with_canvas(MouseButton::Left, 300, 300, first.0, first.1);
for &(x, y) in &points[1..] {
state.on_mouse_motion_with_canvas(300, 300, x, y);
}
let last = *points.last().unwrap();
state.on_mouse_release_with_canvas(MouseButton::Left, 300, 300, last.0, last.1);
state
.boards
.active_frame()
.shapes
.last()
.unwrap()
.shape
.clone()
}
fn ink(shape: &Shape) -> &[(f64, f64)] {
match shape {
Shape::Freehand { points, .. } | Shape::MarkerStroke { points, .. } => points,
_ => panic!("expected ink"),
}
}
fn rms(points: &[(f64, f64)]) -> f64 {
(points
.iter()
.map(|&(x, y)| (y - curve(x - 300.2)).powi(2))
.sum::<f64>()
/ points.len() as f64)
.sqrt()
* 5.0
/ 3.0
}
fn interpolated_y(points: &[(f64, f64)], x: f64) -> f64 {
let i = points
.partition_point(|p| p.0 < x)
.clamp(1, points.len() - 1);
let (a, b) = (points[i - 1], points[i]);
a.1 + (b.1 - a.1) * (x - a.0) / (b.0 - a.0)
}
#[test]
fn slow_and_fast_subpixel_strokes_keep_similar_spatial_smoothing() {
// Unequal, non-aligned event intervals exercise spacing without assuming
// that a fast event coincides with each retained slow event.
let slow = samples(0.047);
let fast = samples(0.83);
for tool in [Tool::Pen, Tool::Marker] {
for level in 0..=6 {
let slow_shape = draw(&slow, level, tool);
let fast_shape = draw(&fast, level, tool);
let (a, b) = (ink(&slow_shape), ink(&fast_shape));
for path in [a, b] {
assert_eq!(path.first(), slow.first(), "start is pinned");
assert_eq!(path.last(), slow.last(), "end is pinned");
assert!(rms(path) < 0.02, "{tool:?} level {level}: {}", rms(path));
for w in path.windows(2) {
assert_ne!(w[0], w[1], "no duplicate positions");
if w[0].1 == w[1].1 {
assert!(
(curve(w[1].0 - 300.2) - curve(w[0].0 - 300.2)).abs() < 0.002,
"flat segments only near the curve's natural turning points"
);
}
}
}
let max_difference = (0..=720)
.map(|i| {
let x = 300.2 + i as f64 / 4.0;
(interpolated_y(a, x) - interpolated_y(b, x)).abs() * 5.0 / 3.0
})
.fold(0.0, f64::max);
assert!(
max_difference < 0.02,
"{tool:?} level {level}: {max_difference}"
);
}
}
}
#[test]
fn smoothed_commits_compact_interior_precision_but_preserve_endpoints_and_off() {
let raw = [
(300.123456, 300.234567),
(301.345678, 301.876543),
(302.456789, 300.654321),
(303.765432, 301.987654),
];
for tool in [Tool::Pen, Tool::Marker] {
assert_eq!(ink(&draw(&raw, 0, tool)), raw);
let shape = draw(&raw, 1, tool);
let points = ink(&shape);
assert_eq!(points[0], raw[0]);
assert_eq!(points[3], raw[3]);
assert_eq!(points[1], (301.318, 301.16));
assert_eq!(points[2], (302.506, 301.293));
let saved = serde_json::to_string(&shape).unwrap();
let restored: Shape = serde_json::from_str(&saved).unwrap();
assert_eq!(ink(&restored), points, "saved and in-memory ink agree");
}
}
#[test]
fn thinning_keeps_pressure_with_its_sample_and_level_zero_keeps_positions() {
let mut state = create_test_input_state();
state.set_pen_smoothing(0);
state.style.pressure_variation_threshold = 0.0;
state.on_mouse_press_with_canvas(MouseButton::Left, 300, 300, 300.25, 300.5);
for (x, width) in [
(300.5, 9.0),
(301.0, 4.0),
(301.0, 12.0),
(301.2, 6.0),
(301.75, 8.0),
] {
state.style.tool_settings.get_mut(Tool::Pen).thickness = width;
state.on_mouse_motion_with_canvas(300, 300, x, 300.5);
}
let DrawingState::Drawing {
points,
point_thicknesses,
..
} = &state.state
else {
panic!("drawing");
};
assert_eq!(points, &[(300.25, 300.5), (301.0, 300.5), (301.75, 300.5)]);
assert_eq!(point_thicknesses, &[9.0, 12.0, 8.0]);
let initial_width = point_thicknesses[0];
state.on_mouse_release_with_canvas(MouseButton::Left, 300, 300, 301.9, 300.6);
let Shape::FreehandPressure { points, .. } =
&state.boards.active_frame().shapes.last().unwrap().shape
else {
panic!("pressure stroke");
};
assert_eq!(
points,
&[
(300.25, 300.5, initial_width),
(301.0, 300.5, 12.0),
(301.75, 300.5, 8.0),
(301.9, 300.6, 8.0)
]
);
}
#[test]
fn invalid_fractional_pointer_samples_do_not_enter_a_stroke() {
for x in [f64::NAN, f64::INFINITY, 1e30] {
let mut state = create_test_input_state();
state.on_mouse_press_with_canvas(MouseButton::Left, 300, 300, x, 300.5);
assert!(matches!(state.state, DrawingState::Idle));
state.set_pen_smoothing(0);
state.on_mouse_press_with_canvas(MouseButton::Left, 300, 300, 300.25, 300.5);
state.on_mouse_motion_with_canvas(300, 300, x, 300.5);
state.on_mouse_release_with_canvas(MouseButton::Left, 300, 300, x, 300.5);
assert!(matches!(state.state, DrawingState::Drawing { .. }));
state.on_mouse_release_with_canvas(MouseButton::Left, 300, 300, 301.25, 300.75);
assert_eq!(
ink(&state.boards.active_frame().shapes.last().unwrap().shape),
&[(300.25, 300.5), (301.25, 300.75)]
);
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ fn add_pressure_shape(state: &mut InputState, locked: bool) -> ShapeId {
.boards
.active_frame_mut()
.add_shape(Shape::FreehandPressure {
points: vec![(0, 0, 2.0), (10, 10, 4.0)],
points: vec![(0.0, 0.0, 2.0), (10.0, 10.0, 4.0)],
color: state.style.current_color,
});
if locked {
@@ -740,7 +740,7 @@ fn a_marker_keeps_its_opacity_so_its_own_hue_is_a_quiet_no_op() {
.boards
.active_frame_mut()
.add_shape(Shape::MarkerStroke {
points: vec![(100, 100), (200, 120)],
points: vec![(100.0, 100.0), (200.0, 120.0)],
color: Color {
a: 0.3,
..PALETTE_RED
@@ -845,7 +845,7 @@ fn stepping_a_marker_skips_the_opacity_variant_it_cannot_take() {
.boards
.active_frame_mut()
.add_shape(Shape::MarkerStroke {
points: vec![(100, 100), (200, 120)],
points: vec![(100.0, 100.0), (200.0, 120.0)],
color: Color {
a: 0.3,
..PALETTE_RED
@@ -971,7 +971,7 @@ fn the_opacity_row_steps_by_five_percent_and_a_marker_stays_translucent() {
.boards
.active_frame_mut()
.add_shape(Shape::MarkerStroke {
points: vec![(100, 100), (200, 120)],
points: vec![(100.0, 100.0), (200.0, 120.0)],
color: Color {
a: 0.85,
..PALETTE_RED
+12 -3
View File
@@ -78,7 +78,10 @@ fn add_active_point_shape(state: &mut InputState, kind: PointShapeKind, points:
let thick = state.style.current_thickness;
match kind {
PointShapeKind::Freehand => state.boards.active_frame_mut().add_shape(Shape::Freehand {
points: point_path(points),
points: point_path(points)
.iter()
.map(|&(x, y)| (f64::from(x), f64::from(y)))
.collect(),
color,
thick,
}),
@@ -86,7 +89,10 @@ fn add_active_point_shape(state: &mut InputState, kind: PointShapeKind, points:
.boards
.active_frame_mut()
.add_shape(Shape::MarkerStroke {
points: point_path(points),
points: point_path(points)
.iter()
.map(|&(x, y)| (f64::from(x), f64::from(y)))
.collect(),
color,
thick,
}),
@@ -105,7 +111,10 @@ fn add_active_point_shape(state: &mut InputState, kind: PointShapeKind, points:
.boards
.active_frame_mut()
.add_shape(Shape::FreehandPressure {
points: pressure_point_path(points),
points: pressure_point_path(points)
.iter()
.map(|&(x, y, t)| (f64::from(x), f64::from(y), t))
.collect(),
color,
})
}
+4 -1
View File
@@ -271,7 +271,10 @@ fn first_undo_turns_a_recognized_shape_back_into_its_ink() {
let Shape::Freehand { points, .. } = &state.boards.active_frame().shapes[0].shape else {
panic!("the ink comes back as a freehand stroke");
};
assert_eq!(points.first(), Some(&RECTANGLE[0]));
assert_eq!(
points.first(),
Some(&(f64::from(RECTANGLE[0].0), f64::from(RECTANGLE[0].1)))
);
run_action(&mut state, Action::Undo);
assert_eq!(kind(&state), None);
@@ -147,3 +147,44 @@ fn enter_key_starts_edit_for_selected_text() {
let edit_id = state.text_editing.edit_target().map(|(id, _)| *id);
assert_eq!(edit_id, Some(shape_id));
}
#[test]
fn a_pen_drag_starting_over_text_preserves_fractional_endpoints() {
let mut state = create_test_input_state();
state.set_pen_smoothing(3);
let id = state.boards.active_frame_mut().add_shape(Shape::Text {
x: 300,
y: 300,
text: "Hello".to_string(),
color: state.style.current_color,
size: state.style.current_font_size,
font_descriptor: state.style.font_descriptor.clone(),
background_enabled: state.style.text_background_enabled,
wrap_width: None,
});
let bounds = state
.boards
.active_frame()
.shape(id)
.unwrap()
.shape
.bounding_box()
.unwrap();
let start = (
f64::from(bounds.x + 2) + 0.123456,
f64::from(bounds.y + 2) + 0.234567,
);
let end = (start.0 + 50.0, start.1 + 20.0);
state.on_mouse_press_with_canvas(MouseButton::Left, 300, 300, start.0, start.1);
assert!(matches!(state.state, DrawingState::PendingTextClick { .. }));
state.on_mouse_motion_with_canvas(350, 320, start.0 + 25.0, start.1 + 10.0);
state.on_mouse_release_with_canvas(MouseButton::Left, 350, 320, end.0, end.1);
let Shape::Freehand { points, .. } = &state.boards.active_frame().shapes.last().unwrap().shape
else {
panic!("expected a pen stroke");
};
assert_eq!(points.first(), Some(&start));
assert_eq!(points.last(), Some(&end));
}
+1 -1
View File
@@ -7,7 +7,7 @@ fn test_escape_cancels_active_drawing_only() {
tool: Tool::Pen,
start_x: 0,
start_y: 0,
points: vec![(0, 0), (5, 5)],
points: vec![(0.0, 0.0), (5.0, 5.0)],
point_thicknesses: vec![2.0, 2.0],
};
+1 -1
View File
@@ -27,7 +27,7 @@ fn set_tool_override_clears_active_preset_and_resets_drawing_state() {
tool: Tool::Pen,
start_x: 10,
start_y: 20,
points: vec![(10, 20), (12, 24)],
points: vec![(10.0, 20.0), (12.0, 24.0)],
point_thicknesses: vec![3.0, 3.5],
};
+1 -1
View File
@@ -552,7 +552,7 @@ fn passive_chip_release_does_not_finish_in_flight_interaction() {
tool: Tool::Pen,
start_x: 100,
start_y: 100,
points: vec![(100, 100), (150, 150)],
points: vec![(100.0, 100.0), (150.0, 150.0)],
point_thicknesses: vec![1.0, 1.0],
};
input.begin_pointer_drag(MouseButton::Left, None);
+22 -21
View File
@@ -1,6 +1,5 @@
use crate::draw::shape::{
bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points, smooth_path,
smooth_pressure_path,
bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points, smooth_committed_path,
};
use crate::draw::{
ArrowLabel, ArrowStyle, BlurRectParams, BlurStyle, Color, EraserBrush, EraserKind, LaserStyle,
@@ -40,7 +39,7 @@ pub(crate) struct ToolStrokeSnapshot {
pub(crate) tool: Tool,
pub(crate) start: (i32, i32),
pub(crate) end: (i32, i32),
pub(crate) points: Vec<(i32, i32)>,
pub(crate) points: Vec<(f64, f64)>,
pub(crate) point_thicknesses: Vec<f32>,
pub(crate) color: Color,
pub(crate) size: f64,
@@ -108,7 +107,7 @@ pub(crate) struct ProvisionalToolSnapshot<'a> {
pub(crate) tool: Tool,
pub(crate) start: (i32, i32),
pub(crate) current: (i32, i32),
pub(crate) points: &'a [(i32, i32)],
pub(crate) points: &'a [(f64, f64)],
pub(crate) point_thicknesses: &'a [f32],
pub(crate) color: Color,
pub(crate) size: f64,
@@ -144,22 +143,22 @@ pub(crate) struct PolygonProvisionalSnapshot {
pub(crate) enum ProvisionalToolStroke<'a> {
BorrowedFreehand {
points: &'a [(i32, i32)],
points: &'a [(f64, f64)],
color: Color,
size: f64,
},
BorrowedPressureFreehand {
points: &'a [(i32, i32)],
points: &'a [(f64, f64)],
point_thicknesses: &'a [f32],
color: Color,
},
BorrowedMarker {
points: &'a [(i32, i32)],
points: &'a [(f64, f64)],
color: Color,
size: f64,
},
EraserPreview {
points: &'a [(i32, i32)],
points: &'a [(f64, f64)],
size: f64,
},
Shape(Shape),
@@ -167,14 +166,14 @@ pub(crate) enum ProvisionalToolStroke<'a> {
/// it came from, so the swap from ink to shape is never silent.
Recognized {
shape: Shape,
ink: &'a [(i32, i32)],
ink: &'a [(f64, f64)],
ink_color: Color,
ink_size: f64,
},
BlurReplayPreview(BlurRectParams),
/// The laser stroke under the pointer, drawn exactly as its finished ink.
Laser {
points: &'a [(i32, i32)],
points: &'a [(f64, f64)],
style: LaserStyle,
},
None,
@@ -190,9 +189,10 @@ impl Tool {
finish_path_stroke(snapshot, kind, pressure, usage)
}
ToolDrawingBehavior::LiveShape => {
let recognized = if snapshot.points.last().copied() == Some(snapshot.end) {
let rounded = crate::draw::shape::quantize_path(&snapshot.points);
let recognized = if rounded.last().copied() == Some(snapshot.end) {
super::live_shape::recognize(
&snapshot.points,
&rounded,
snapshot.color,
snapshot.size,
snapshot.fill_enabled,
@@ -200,7 +200,7 @@ impl Tool {
snapshot.shape_recognition_sensitivity,
)
} else {
let mut path = snapshot.points.clone();
let mut path = rounded;
path.push(snapshot.end);
super::live_shape::recognize(
&path,
@@ -335,7 +335,7 @@ impl Tool {
}
ToolDrawingBehavior::Eraser => finish_eraser(snapshot),
ToolDrawingBehavior::Laser => {
let mut points = snapshot.points;
let mut points = crate::draw::shape::quantize_path(&snapshot.points);
if points.last().copied() != Some(snapshot.end) {
points.push(snapshot.end);
}
@@ -592,6 +592,8 @@ fn finish_path_stroke(
pressure: ToolPressureBehavior,
usage: ToolUsage,
) -> FinishedToolStroke {
let points = smooth_committed_path(&snapshot.points, snapshot.pen_smoothing);
match kind {
ToolPathKind::Freehand => {
if matches!(pressure, ToolPressureBehavior::OptionalPressureStroke)
@@ -601,15 +603,14 @@ fn finish_path_stroke(
snapshot.pressure_variation_threshold,
)
{
let points: Vec<_> = snapshot
.points
let points: Vec<_> = points
.into_iter()
.zip(snapshot.point_thicknesses)
.map(|((x, y), t)| (x, y, t))
.collect();
return FinishedToolStroke::Shape {
shape: Shape::FreehandPressure {
points: smooth_pressure_path(&points, snapshot.pen_smoothing),
points,
color: snapshot.color,
},
usage,
@@ -618,7 +619,7 @@ fn finish_path_stroke(
FinishedToolStroke::Shape {
shape: Shape::Freehand {
points: smooth_path(&snapshot.points, snapshot.pen_smoothing),
points,
color: snapshot.color,
thick: snapshot.size,
},
@@ -627,7 +628,7 @@ fn finish_path_stroke(
}
ToolPathKind::Marker => FinishedToolStroke::Shape {
shape: Shape::MarkerStroke {
points: smooth_path(&snapshot.points, snapshot.pen_smoothing),
points,
color: marker_color_with_opacity(snapshot.color, snapshot.marker_opacity),
thick: snapshot.size,
},
@@ -638,7 +639,7 @@ fn finish_path_stroke(
fn finish_eraser(snapshot: ToolStrokeSnapshot) -> FinishedToolStroke {
if snapshot.eraser_mode == EraserMode::Stroke {
let mut path = snapshot.points;
let mut path = crate::draw::shape::quantize_path(&snapshot.points);
if path.last().copied() != Some(snapshot.end) {
path.push(snapshot.end);
}
@@ -647,7 +648,7 @@ fn finish_eraser(snapshot: ToolStrokeSnapshot) -> FinishedToolStroke {
FinishedToolStroke::Shape {
shape: Shape::EraserStroke {
points: snapshot.points,
points: crate::draw::shape::quantize_path(&snapshot.points),
brush: EraserBrush {
size: snapshot.eraser_size,
kind: snapshot.eraser_kind,
+7 -4
View File
@@ -27,8 +27,8 @@ pub(crate) struct LiveShapeMemo {
#[derive(Debug, Clone, Copy, PartialEq)]
struct MemoKey {
len: usize,
first: (i32, i32),
last: (i32, i32),
first: (f64, f64),
last: (f64, f64),
color: Color,
thick: f64,
fill: bool,
@@ -43,7 +43,7 @@ impl LiveShapeMemo {
/// point count and both ends identify it.
pub(crate) fn recognize(
&self,
points: &[(i32, i32)],
points: &[(f64, f64)],
color: Color,
thick: f64,
fill: bool,
@@ -68,7 +68,10 @@ impl LiveShapeMemo {
#[cfg(test)]
self.runs.set(self.runs.get() + 1);
let shape = recognize(points, color, thick, fill, grid, sensitivity);
// Quantize only when recognition runs. Preview, bounds and readout
// reuse the cached result without allocating another path buffer.
let rounded = crate::draw::shape::quantize_path(points);
let shape = recognize(&rounded, color, thick, fill, grid, sensitivity);
*self.last.borrow_mut() = Some((key, shape.clone()));
shape
}
+1 -1
View File
@@ -459,7 +459,7 @@ fn writer_header_restart_output_isolation_and_clear_reset_are_stable() {
assert!(
fs::read(first.session_file_path())
.unwrap()
.starts_with(b"{\n \"version\": 7,\n \"save_generation\": 1,")
.starts_with(b"{\n \"version\": 8,\n \"save_generation\": 1,")
);
// Reconstruct options rather than retaining any writer state across saves.
let mut restarted = options(temp.path());
+87
View File
@@ -2956,3 +2956,90 @@ fn offline_tool_clear_reports_corruption_instead_of_silently_rewriting_restored_
}
}
}
#[test]
fn format_eight_preserves_fractional_ink_and_loads_legacy_integer_history() {
use crate::draw::frame::UndoAction;
let temp = tempdir().unwrap();
let mut options = SessionOptions::new(temp.path().to_path_buf(), "float-ink");
options.persist_transparent = true;
options.compression = CompressionMode::Off;
let mut snapshot = sample_snapshot();
let frame = &mut snapshot.boards[0].pages.pages[0];
let shape = Shape::Freehand {
points: vec![(300.25, 300.5), (301.75, 301.125)],
color: crate::draw::color::BLACK,
thick: 2.0,
};
let id = frame.add_shape(shape.clone());
let index = frame.find_index(id).unwrap();
frame.push_undo_action(
UndoAction::Create {
shapes: vec![(index, frame.shape(id).unwrap().clone())],
},
100,
);
save_snapshot(&snapshot, &options).unwrap();
let encoded = std::fs::read(options.session_file_path()).unwrap();
let mut value: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
assert_eq!(value["version"], 8);
let loaded = load_snapshot_inner(&options.session_file_path(), &options)
.unwrap()
.unwrap();
let mut restored = loaded.snapshot.boards[0].pages.pages[0].clone();
assert_eq!(restored.shapes.last().unwrap().shape, shape);
assert!(restored.undo_last().is_some());
assert!(restored.redo_last().is_some());
assert_eq!(restored.shapes.last().unwrap().shape, shape);
// Format 7's coordinates are integers, including inside undo history.
fn integer_coordinates(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::Array(points)) = map.get_mut("points") {
for point in points {
if let Some(pair) = point.as_array_mut() {
for coordinate in pair.iter_mut().take(2) {
*coordinate =
serde_json::json!(coordinate.as_f64().unwrap().round() as i32);
}
}
}
}
for value in map.values_mut() {
integer_coordinates(value);
}
}
serde_json::Value::Array(values) => {
for value in values {
integer_coordinates(value);
}
}
_ => {}
}
}
value["version"] = serde_json::json!(7);
integer_coordinates(&mut value);
let legacy = serde_json::to_vec(&value).unwrap();
std::fs::write(options.session_file_path(), &legacy).unwrap();
let loaded = load_snapshot_inner(&options.session_file_path(), &options)
.unwrap()
.unwrap();
assert_eq!(loaded.version, 7);
assert_eq!(
std::fs::read(options.session_file_path()).unwrap(),
legacy,
"loading does not migrate on disk"
);
let mut restored = loaded.snapshot.boards[0].pages.pages[0].clone();
let Shape::Freehand { points, .. } = &restored.shapes.last().unwrap().shape else {
panic!("legacy ink");
};
assert_eq!(points, &[(300.0, 301.0), (302.0, 301.0)]);
assert!(restored.undo_last().is_some());
assert!(restored.redo_last().is_some());
save_snapshot(&loaded.snapshot, &options).unwrap();
let value: serde_json::Value =
serde_json::from_slice(&std::fs::read(options.session_file_path()).unwrap()).unwrap();
assert_eq!(value["version"], 8);
}
+1 -1
View File
@@ -5,7 +5,7 @@ use crate::draw::{
use crate::input::{DrawingStyle, EraserMode, InputState, PerToolDrawingSettings, Tool};
use serde::{Deserialize, Serialize};
pub(super) const CURRENT_VERSION: u32 = 7;
pub(super) const CURRENT_VERSION: u32 = 8;
/// Captured state suitable for serialisation or restoration.
#[derive(Debug, Clone)]
+21 -9
View File
@@ -212,7 +212,7 @@ fn moved_multi_selection_with_history_survives_save_restore() {
let mut input = dummy_input_state();
input.update_screen_dimensions(400, 300);
let first = input.boards.active_frame_mut().add_shape(Shape::Freehand {
points: vec![(10, 10), (20, 20)],
points: vec![(10.0, 10.0), (20.0, 20.0)],
color: Color {
r: 1.0,
g: 0.0,
@@ -222,7 +222,7 @@ fn moved_multi_selection_with_history_survives_save_restore() {
thick: 3.0,
});
let second = input.boards.active_frame_mut().add_shape(Shape::Freehand {
points: vec![(40, 40), (50, 50)],
points: vec![(40.0, 40.0), (50.0, 50.0)],
color: Color {
r: 0.0,
g: 0.0,
@@ -237,11 +237,11 @@ fn moved_multi_selection_with_history_survives_save_restore() {
assert_eq!(input.boards.active_frame().undo_stack_len(), 1);
assert_eq!(
freehand_points(input.boards.active_frame(), first),
vec![(35, 20), (45, 30)]
vec![(35.0, 20.0), (45.0, 30.0)]
);
assert_eq!(
freehand_points(input.boards.active_frame(), second),
vec![(65, 50), (75, 60)]
vec![(65.0, 50.0), (75.0, 60.0)]
);
let snapshot = snapshot_from_input(&input, &options).expect("snapshot present");
@@ -259,15 +259,27 @@ fn moved_multi_selection_with_history_survives_save_restore() {
restored.switch_board(BOARD_ID_TRANSPARENT);
let frame = restored.boards.active_frame_mut();
assert_eq!(frame.undo_stack_len(), 1);
assert_eq!(freehand_points(frame, first), vec![(35, 20), (45, 30)]);
assert_eq!(freehand_points(frame, second), vec![(65, 50), (75, 60)]);
assert_eq!(
freehand_points(frame, first),
vec![(35.0, 20.0), (45.0, 30.0)]
);
assert_eq!(
freehand_points(frame, second),
vec![(65.0, 50.0), (75.0, 60.0)]
);
frame.undo_last().expect("compound undo should restore");
assert_eq!(freehand_points(frame, first), vec![(10, 10), (20, 20)]);
assert_eq!(freehand_points(frame, second), vec![(40, 40), (50, 50)]);
assert_eq!(
freehand_points(frame, first),
vec![(10.0, 10.0), (20.0, 20.0)]
);
assert_eq!(
freehand_points(frame, second),
vec![(40.0, 40.0), (50.0, 50.0)]
);
}
fn freehand_points(frame: &crate::draw::Frame, id: crate::draw::ShapeId) -> Vec<(i32, i32)> {
fn freehand_points(frame: &crate::draw::Frame, id: crate::draw::ShapeId) -> Vec<(f64, f64)> {
let shape = frame.shape(id).expect("shape exists");
match &shape.shape {
Shape::Freehand { points, .. } => points.clone(),
+12 -6
View File
@@ -265,7 +265,10 @@ fn assert_restored_limit_page(page: &crate::draw::Frame, page_index: usize, imag
assert_eq!(points.len(), 3);
assert_eq!(
points[0],
(i32::try_from(page_index).expect("page index fits i32"), 20)
(
f64::from(i32::try_from(page_index).expect("page index fits i32")),
20.0
)
);
}
other => panic!("expected annotation stroke on page {page_index}, got {other:?}"),
@@ -431,7 +434,7 @@ fn save_snapshot_drops_history_when_modified_stroke_exceeds_limit() {
match &frame.shapes[0].shape {
Shape::Freehand { points, .. } => {
assert_eq!(points.len(), point_count);
assert_eq!(points[0], (1, 1));
assert_eq!(points[0], (1.0, 1.0));
}
other => panic!("expected freehand stroke, got {other:?}"),
}
@@ -788,9 +791,12 @@ fn add_image_and_annotations(frame: &mut crate::draw::Frame, page_index: usize,
let y = 20 + i32::try_from(page_index).expect("page index fits i32");
frame.add_shape(Shape::Freehand {
points: vec![
(i32::try_from(page_index).expect("page index fits i32"), 20),
(40, y),
(80, y + 8),
(
f64::from(i32::try_from(page_index).expect("page index fits i32")),
20.0,
),
(40.0, f64::from(y)),
(80.0, f64::from(y + 8)),
],
color: Color {
r: 1.0,
@@ -831,7 +837,7 @@ fn large_freehand(point_count: usize, offset: i32) -> Shape {
let points = (0..point_count)
.map(|index| {
let x = i32::try_from(index).expect("test point count fits i32");
(x + offset, (x % 173) + offset)
(f64::from(x + offset), f64::from((x % 173) + offset))
})
.collect();
+1 -1
View File
@@ -98,7 +98,7 @@ const STYLE_STEP_W: f64 = 20.0;
/// meter or tool stepper ("Smooth", "Shapes", "Detect").
const STYLE_CAPTION_W: f64 = 56.0;
/// `ToolbarLayoutSpec::TOP_STYLE_METER_W`: a level meter's bar row.
const STYLE_METER_W: f64 = 84.0;
const STYLE_METER_W: f64 = 84.0 + model::METER_ZERO_SLOT_W;
/// `ToolbarLayoutSpec::TOP_STYLE_PEN_FEEL_W`: the Pen feel chip's slot.
const STYLE_PEN_FEEL_W: f64 = 92.0;
/// Segment tab height (matches the Settings pane's segmented tabs).
+89 -26
View File
@@ -4,15 +4,6 @@
use super::*;
/// Current click target of every bar, refreshed from each snapshot. The
/// handlers read these rather than the build-time meter because clicking
/// the top filled bar lowers the level, so
/// what a bar does changes with the level.
#[derive(Default)]
struct MeterTargets {
bars: Vec<ToolbarEvent>,
}
/// Gap between neighbouring bars; matches the builtin painter's `BAR_GAP`.
const METER_BAR_GAP: f64 = 3.0;
@@ -27,7 +18,7 @@ pub(super) struct MeterBarsSpec {
/// Appends one bar button per level of `spec.setting` to `row` and installs
/// the wheel on it: one level per notch, like the builtin. Returns the updater
/// that keeps fills, tooltips, sensitivity, and targets on the live level.
/// that keeps fills, tooltips and sensitivity on the live level.
pub(super) fn append_meter_bars(
feedback: &FeedbackSender,
row: &gtk4::Box,
@@ -44,8 +35,8 @@ pub(super) fn append_meter_bars(
let px = |value: f64| (value * scale).round() as i32;
let meter = setting.meter(snapshot, &id_prefix);
let targets = Rc::new(RefCell::new(MeterTargets::default()));
let bar_w = row_w / meter.segments.len().max(1) as f64;
let dot_refresh = append_meter_zero(feedback, row, setting, &id_prefix, row_h, snapshot, scale);
let bar_w = (row_w - model::METER_ZERO_SLOT_W) / meter.segments.len().max(1) as f64;
let mut bars = Vec::with_capacity(meter.segments.len());
for (index, segment) in meter.segments.iter().enumerate() {
let bar = sized_button(bar_w * scale, row_h * scale);
@@ -55,18 +46,13 @@ pub(super) fn append_meter_bars(
// builtin painter; a bare box would shrink to a dot.
let fill = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
fill.add_css_class("meter-fill");
fill.set_halign(gtk4::Align::Center);
fill.set_valign(gtk4::Align::Center);
fill.set_size_request(px((bar_w - METER_BAR_GAP).max(1.0)), -1);
bar.set_child(Some(&fill));
let sender = feedback.clone();
let targets = targets.clone();
bar.connect_clicked(move |_| {
let event = targets.borrow().bars.get(index).cloned();
if let Some(event) = event {
send_event(&sender, event);
}
});
bar.connect_clicked(move |_| send_event(&sender, setting.event(index as u8 + 1)));
row.append(&bar);
bars.push(bar);
}
@@ -95,6 +81,7 @@ pub(super) fn append_meter_bars(
let applied: Cell<Option<(u8, bool)>> = Cell::new(None);
let refresh = move |snapshot: &ToolbarSnapshot| {
let enabled = enabled(snapshot);
dot_refresh(snapshot, enabled);
let state = (setting.level(snapshot), enabled);
if applied.replace(Some(state)) == Some(state) {
return;
@@ -111,18 +98,94 @@ pub(super) fn append_meter_bars(
bar.update_property(&[gtk4::accessible::Property::Label(&segment.tooltip)]);
bar.set_sensitive(enabled);
}
*targets.borrow_mut() = MeterTargets {
bars: meter
.segments
.into_iter()
.map(|segment| segment.event)
.collect(),
};
};
refresh(snapshot);
Box::new(refresh)
}
/// Exact shared Cairo dot geometry inside a GTK button, including its hover ring.
pub(super) fn append_meter_zero(
feedback: &FeedbackSender,
row: &gtk4::Box,
setting: model::StrokeSetting,
id_prefix: &str,
row_h: f64,
snapshot: &ToolbarSnapshot,
scale: f64,
) -> impl Fn(&ToolbarSnapshot, bool) + 'static {
let dot = sized_button(model::METER_ZERO_SLOT_W * scale, row_h * scale);
dot.add_css_class("meter-bar");
dot.add_css_class("meter-dot");
let zero = setting.zero_segment(snapshot, id_prefix);
set_semantic_widget_id(&dot, &zero.id);
let area = gtk4::DrawingArea::new();
area.set_content_width((model::METER_ZERO_SLOT_W * scale).round() as i32);
area.set_content_height((row_h * scale).round() as i32);
let paint_state = Rc::new(Cell::new((setting.level(snapshot) == 0, false, true)));
let state = paint_state.clone();
area.set_draw_func(move |_, ctx, _, _| {
let (active, hover, enabled) = state.get();
let _ = ctx.save();
ctx.scale(scale, scale);
crate::toolbar_icons::draw_meter_dot(
ctx,
(3.0, 0.0, model::METER_ZERO_SLOT_W, row_h),
active,
hover,
enabled,
);
let _ = ctx.restore();
});
// The hover ring extends left of the 22px slot. Paint with a 3px
// overhang so GTK does not clip that edge of the shared circle.
let overhang = gtk4::Fixed::new();
overhang.set_size_request(
(model::METER_ZERO_SLOT_W * scale).round() as i32,
(row_h * scale).round() as i32,
);
overhang.set_overflow(gtk4::Overflow::Visible);
overhang.put(&area, -3.0 * scale, 0.0);
dot.set_child(Some(&overhang));
let motion = gtk4::EventControllerMotion::new();
let state = paint_state.clone();
let draw = area.clone();
motion.connect_enter(move |_, _, _| {
let (active, _, enabled) = state.get();
state.set((active, true, enabled));
draw.queue_draw();
});
let state = paint_state.clone();
let draw = area.clone();
motion.connect_leave(move |_| {
let (active, _, enabled) = state.get();
state.set((active, false, enabled));
draw.queue_draw();
});
dot.add_controller(motion);
let sender = feedback.clone();
dot.connect_clicked(move |_| send_event(&sender, setting.event(0)));
row.append(&dot);
// Same rule as the bars: touch the dot only when its level or
// sensitivity changed, never on every toolbar update.
let id_prefix = id_prefix.to_owned();
let applied: Cell<Option<(u8, bool)>> = Cell::new(None);
move |snapshot, enabled| {
let level = setting.level(snapshot);
if applied.replace(Some((level, enabled))) == Some((level, enabled)) {
return;
}
let (_, hover, _) = paint_state.get();
paint_state.set((level == 0, hover, enabled));
area.queue_draw();
let zero = setting.zero_segment(snapshot, &id_prefix);
dot.set_tooltip_text(Some(&zero.tooltip));
dot.update_property(&[gtk4::accessible::Property::Label(&zero.tooltip)]);
dot.set_sensitive(enabled);
}
}
impl TopBar {
/// Appends one level meter to `pill`, followed by `gap_px` of margin, and
/// registers its updater.
+16
View File
@@ -40,6 +40,22 @@ impl TopBar {
row.append(&caption_label);
}
if let Some(setting) = control.stroke_setting() {
let refresh = super::meter::append_meter_zero(
&self.feedback,
&row,
setting,
&control.id(),
STYLE_ROW_H,
snapshot,
scale,
);
refresh(snapshot, control.enabled(snapshot));
self.updaters.borrow_mut().push(Box::new(move |snapshot| {
refresh(snapshot, control.enabled(snapshot))
}));
}
let steps = control.required_steps(snapshot);
let mut handles: Vec<gtk4::Button> = Vec::new();
let minus = pill_button(steps[0].label, sz(STYLE_STEP_W), sz(STYLE_ROW_H));
+1
View File
@@ -9,6 +9,7 @@ mod gtk_style_widgets;
mod interactions;
mod pane_popovers;
mod pen_feel;
mod pen_feel_render;
mod structure;
mod toolbar_popovers;
mod widget_support;
@@ -252,6 +252,7 @@ pub(super) enum StylePillNodeExpectation {
Caption(model::StylePillControl),
/// One interactive bar of a level meter.
MeterBar(model::StylePillControl, usize),
MeterDot(model::StylePillControl),
/// The arrow style chip's drawn glyph and its name (decor laid over the
/// chip's button body).
ArrowChipGlyph,
@@ -272,6 +273,10 @@ fn expected_style_pill_nodes(
format!("{id}.caption"),
StylePillNodeExpectation::Caption(control),
));
nodes.push((
meter.zero.id.clone(),
StylePillNodeExpectation::MeterDot(control),
));
for (index, segment) in meter.segments.iter().enumerate() {
nodes.push((
segment.id.clone(),
@@ -287,6 +292,12 @@ fn expected_style_pill_nodes(
StylePillNodeExpectation::Caption(control),
));
}
if let Some(setting) = control.stroke_setting() {
nodes.push((
setting.zero_segment(snapshot, &id).id,
StylePillNodeExpectation::MeterDot(control),
));
}
nodes.push((
steps[0].id.to_string(),
StylePillNodeExpectation::StepHalf(control, 0),
@@ -60,6 +60,19 @@ pub(super) fn assert_builtin_style_pill_node(
*control,
*index,
),
StylePillNodeExpectation::MeterDot(control) => {
let setting = control.stroke_setting().expect("zero-level setting");
let zero = setting.zero_segment(snapshot, &control.id());
assert_eq!(
kind,
&crate::backend::wayland::TopToolbarWidgetKind::MeterDot {
active: setting.level(snapshot) == 0,
enabled: control.enabled(snapshot),
}
);
assert_eq!(interaction_event, Some(&zero.event));
assert_eq!(interaction_tooltip, Some(zero.tooltip.as_str()));
}
StylePillNodeExpectation::ArrowChipGlyph => {
use crate::backend::wayland::TopToolbarWidgetKind as W;
assert!(!has_interaction, "{name}: {id} is decor");
@@ -38,8 +38,19 @@ pub(super) fn assert_gtk_style_meter(
"{id} caption keeps the planned slot"
);
let mut bar = caption_label.next_sibling();
let mut bar_widths = 0;
let zero = caption_label
.next_sibling()
.expect("zero dot")
.downcast::<gtk4::Button>()
.expect("dot button");
assert_eq!(zero.widget_name().as_str(), meter.zero.id);
assert!(zero.has_css_class("meter-dot"));
assert_eq!(
zero.tooltip_text().as_deref(),
Some(meter.zero.tooltip.as_str())
);
let mut bar = zero.next_sibling();
let mut bar_widths = zero.width_request();
for segment in &meter.segments {
let button = bar
.clone()
@@ -67,6 +78,11 @@ pub(super) fn assert_gtk_style_meter(
"{} enabled",
segment.id
);
assert_eq!(
button.width_request(),
(84.0 / meter.segments.len() as f64).round() as i32,
"{id} retains its original bar widths",
);
bar_widths += button.width_request();
bar = button.next_sibling();
}
@@ -115,6 +131,11 @@ pub(super) fn assert_gtk_style_stepper(
);
first = caption_label.next_sibling().expect("stepper minus half");
}
if let Some(setting) = control.stroke_setting() {
let zero = setting.zero_segment(snapshot, &control.id());
assert_eq!(first.widget_name().as_str(), zero.id);
first = first.next_sibling().expect("minus follows zero dot");
}
let minus = first;
let value = minus.next_sibling().expect("stepper value readout");
let plus = value.next_sibling().expect("stepper plus half");
@@ -186,6 +186,66 @@ pub(super) fn assert_style_pill_interactions(regular: &ToolbarSnapshot) {
assert_eraser_pill_interactions(&mut top, regular, &rx);
assert_pen_pill_interactions(&mut top, regular, &rx);
assert_shape_pill_interaction(&mut top, regular, &rx);
assert_inline_stroke_level_clicks(&mut top, regular, &rx);
}
fn assert_inline_stroke_level_clicks(
top: &mut TopBar,
regular: &ToolbarSnapshot,
rx: &std::sync::mpsc::Receiver<GtkToolbarFeedback>,
) {
use crate::config::ToolbarStrokeControls::{Meter, Stepper};
for (presentation, controls) in [
(
Meter,
[
model::StylePillControl::PenSmoothingMeter,
model::StylePillControl::ShapeSensitivityMeter,
],
),
(
Stepper,
[
model::StylePillControl::PenSmoothingStepper,
model::StylePillControl::ShapeSensitivityStepper,
],
),
] {
let mut snapshot = style_pill_tool_snapshot(regular, Tool::LiveShape);
snapshot.stroke_controls = presentation;
snapshot.pen_smoothing = 3;
snapshot.shape_recognition_sensitivity = 3;
top.build_strip(
&snapshot,
&plan_top_strip(&crate::ui_text::UiTextEngine::default(), &snapshot),
);
for control in controls {
let setting = control.stroke_setting().unwrap();
let levels = if presentation == Meter {
vec![0, 3, setting.max()]
} else {
vec![0]
};
for level in levels {
let id = format!("{}.level-{level}", control.id());
pill_widget(top, &id)
.downcast::<gtk4::Button>()
.unwrap()
.emit_clicked();
assert_eq!(
rx.recv_timeout(Duration::from_secs(1))
.expect("inline level click"),
GtkToolbarFeedback::Event {
event: setting.event(level),
rebind_requested: false,
},
"{presentation:?} {id} selects its exact level"
);
}
}
detach_test_popovers(top);
}
}
fn assert_eraser_pill_interactions(
+12 -1
View File
@@ -220,7 +220,18 @@ fn assert_pen_feel_section(
"{key} hint"
);
let mut widths = 0;
let zero: gtk4::Button = named(content, &section.meter.zero.id);
assert!(zero.has_css_class("meter-dot"));
zero.emit_clicked();
assert_eq!(
rx.recv_timeout(Duration::from_secs(1))
.expect("one-click zero"),
GtkToolbarFeedback::Event {
event: section.setting.event(0),
rebind_requested: false,
}
);
let mut widths = zero.width_request();
for segment in &section.meter.segments {
let bar: gtk4::Button = named(content, &segment.id);
assert!(bar.has_css_class("meter-bar"), "{}", segment.id);
@@ -0,0 +1,179 @@
//! Actual GTK snapshots on the repository's isolated headless compositor.
use super::super::{FeedbackSender, ToolbarSnapshot, TopBar};
use super::gtk_contract::install_gtk_contract_metrics;
use super::widget_support::find_widget_named;
use crate::input::state::test_support::make_test_input_state;
use gtk4::prelude::*;
use std::time::{Duration, Instant};
#[test]
fn gtk_pen_feel_headless_render_at_fractional_scale() {
// A window is allowed only on the dedicated test compositor, never on
// the user's display. Ordinary library runs skip this render check.
if std::env::var_os("WAYSCRIBER_REQUIRE_GTK_TESTS").is_none()
|| std::env::var("WAYLAND_DISPLAY").as_deref() != Ok("wayscriber-widget-tests")
{
return;
}
const CHILD_ENV: &str = "WAYSCRIBER_GTK_PEN_RENDER_CHILD";
const TEST_NAME: &str = "toolbar_gtk::view::top_bar::tests::pen_feel_render::gtk_pen_feel_headless_render_at_fractional_scale";
if std::env::var_os(CHILD_ENV).is_none() {
let status = std::process::Command::new(std::env::current_exe().expect("test binary"))
.args([TEST_NAME, "--exact", "--test-threads=1", "--nocapture"])
.env(CHILD_ENV, "1")
.status()
.expect("run isolated GTK render test");
assert!(status.success(), "isolated GTK render test failed");
return;
}
gtk4::init().expect("headless GTK initialization");
install_gtk_contract_metrics();
let mut state = make_test_input_state();
state.set_pen_smoothing(3);
let snapshot = ToolbarSnapshot::from_input(&state);
let (tx, _) = std::sync::mpsc::channel();
let top = TopBar::new_for_test(FeedbackSender::new(tx));
for scale in [1.0, 5.0 / 3.0] {
let provider = gtk4::CssProvider::new();
provider.load_from_string(&crate::toolbar_gtk::css::stylesheet(scale));
let display = gtk4::gdk::Display::default().unwrap();
gtk4::style_context_add_provider_for_display(
&display,
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
let (content, updaters) = top.build_pen_feel_content(&snapshot, scale);
let window = gtk4::Window::builder().child(&content).build();
window.add_css_class("wayscriber-toolbar");
window.present();
wait_for_frame(&content);
assert!(content.width() > 0 && content.height() > 0);
let dot = find_widget_named(content.upcast_ref(), "top.feel.smoothing.level-0")
.expect("zero dot");
let origin = dot
.compute_point(&content, &gtk4::graphene::Point::new(0.0, 0.0))
.expect("dot in panel");
let controllers = dot.observe_controllers();
let motion = (0..controllers.n_items())
.find_map(|i| {
controllers
.item(i)?
.downcast::<gtk4::EventControllerMotion>()
.ok()
})
.expect("dot hover controller");
let resting = snapshot_pixels(&content);
motion.emit_by_name::<()>("enter", &[&6.0f64, &10.0f64]);
wait_for_frame(&content);
let hovered = snapshot_pixels(&content);
let mut widths = Vec::new();
for level in 1..=6 {
let bar = find_widget_named(
content.upcast_ref(),
&format!("top.feel.smoothing.level-{level}"),
)
.expect("smoothing bar");
let fill = bar.first_child().expect("bar fill");
widths.push(bar.width());
assert!(
f64::from(bar.width() - fill.width()) >= 2.0 * scale,
"GTK bar {level} retains the gap at scale {scale}: slot={} fill={}",
bar.width(),
fill.width()
);
}
assert!(
widths.iter().max().unwrap() - widths.iter().min().unwrap() <= 1,
"GTK bar widths at scale {scale}: {widths:?}"
);
let ring_x = (f64::from(origin.x()) + (6.0 + 7.5) * scale).round() as usize;
let ring_y = (f64::from(origin.y()) + f64::from(dot.height()) / 2.0).round() as usize;
assert_ne!(
pixel(&resting, ring_x, ring_y),
pixel(&hovered, ring_x, ring_y),
"GTK hover ring at scale {scale}"
);
let mut off = snapshot.clone();
off.pen_smoothing = 0;
for updater in &updaters {
updater(&off);
}
wait_for_frame(&content);
let active_hovered = snapshot_pixels(&content);
motion.emit_by_name::<()>("leave", &[]);
wait_for_frame(&content);
let active_resting = snapshot_pixels(&content);
assert_eq!(
pixel(&active_hovered, ring_x, ring_y),
pixel(&active_resting, ring_x, ring_y),
"GTK level zero has no hover ring at scale {scale}"
);
window.close();
gtk4::style_context_remove_provider_for_display(&display, &provider);
}
eprintln!("EXECUTED: GTK Pen feel fractional-scale render assertions");
}
fn wait_for_frame(widget: &impl IsA<gtk4::Widget>) {
let context = gtk4::glib::MainContext::default();
let deadline = Instant::now() + Duration::from_secs(3);
let clock = loop {
while context.pending() {
context.iteration(false);
}
if let Some(clock) = widget.as_ref().frame_clock() {
break clock;
}
assert!(Instant::now() < deadline, "GTK widget has no frame clock");
std::thread::sleep(Duration::from_millis(2));
};
let painted = std::rc::Rc::new(std::cell::Cell::new(false));
let observed = painted.clone();
let handler = clock.connect_after_paint(move |_| observed.set(true));
widget.as_ref().queue_draw();
while !painted.get() {
while context.pending() {
context.iteration(false);
}
assert!(
Instant::now() < deadline,
"GTK did not paint the requested frame"
);
std::thread::sleep(Duration::from_millis(2));
}
clock.disconnect(handler);
}
fn snapshot_pixels(widget: &impl IsA<gtk4::Widget>) -> cairo::ImageSurface {
let widget = widget.as_ref();
let snapshot = gtk4::Snapshot::new();
gtk4::WidgetPaintable::new(Some(widget)).snapshot(
&snapshot,
f64::from(widget.width()),
f64::from(widget.height()),
);
let node = snapshot.to_node().expect("GTK render node");
let surface =
cairo::ImageSurface::create(cairo::Format::ARgb32, widget.width(), widget.height())
.unwrap();
node.draw(&cairo::Context::new(&surface).unwrap());
surface
}
fn pixel(surface: &cairo::ImageSurface, x: usize, y: usize) -> [u8; 4] {
let stride = surface.stride() as usize;
let mut pixel = [0; 4];
surface
.with_data(|bytes| {
pixel.copy_from_slice(&bytes[y * stride + x * 4..y * stride + x * 4 + 4])
})
.unwrap();
pixel
}
+30
View File
@@ -0,0 +1,30 @@
//! Shared zero-level meter dot, used by Cairo and GTK toolbars.
use crate::ui::theme::set_color;
use crate::ui::theme::toolbar::{COLOR_ACCENT, COLOR_METER_TRACK_HOVER};
pub(crate) fn draw_meter_dot(
ctx: &cairo::Context,
rect: (f64, f64, f64, f64),
active: bool,
hover: bool,
enabled: bool,
) {
let (x, y, _, h) = rect;
let alpha = if enabled { 1.0 } else { 0.35 };
let faded = |(r, g, b, a)| (r, g, b, a * alpha);
let _ = ctx.save();
ctx.new_path();
set_color(ctx, faded(COLOR_ACCENT));
ctx.arc(x + 6.0, y + h / 2.0, 5.0, 0.0, std::f64::consts::TAU);
let _ = ctx.fill();
if hover && !active && enabled {
set_color(ctx, COLOR_METER_TRACK_HOVER);
ctx.set_line_width(1.5);
ctx.arc(x + 6.0, y + h / 2.0, 7.5, 0.0, std::f64::consts::TAU);
let _ = ctx.stroke();
}
let _ = ctx.restore();
}
+2
View File
@@ -7,6 +7,7 @@ mod actions;
mod arrow_style_preview;
mod controls;
mod history;
mod meter_dot;
mod opacity_preview;
mod security;
mod slider_ticks;
@@ -23,6 +24,7 @@ pub use tools::*;
pub use zoom::*;
pub(crate) use arrow_style_preview::draw_arrow_style_preview;
pub(crate) use meter_dot::draw_meter_dot;
pub(crate) use opacity_preview::{draw_opacity_swatch, draw_opacity_track};
pub(crate) use slider_ticks::draw_slider_ticks;
pub(crate) use smoothing_preview::draw_smoothing_preview;
+9 -12
View File
@@ -16,8 +16,8 @@ use crate::ui::theme::toolbar::{
};
/// How many sample units make one preview unit. The sample is authored at
/// four times the preview's size so the smoothing's rounding to whole units
/// stays far below a preview pixel.
/// four times the preview's size; its floating positions run the same filter
/// as committed ink without quantization.
const SAMPLE_SCALE: f64 = 4.0;
/// Sample space: the panel's preview (220 x 48 spec units) at `SAMPLE_SCALE`.
const SAMPLE_W: f64 = 880.0;
@@ -34,7 +34,7 @@ const WELL_RADIUS: f64 = 6.0;
/// three frequencies so each smoothing level removes visibly more of it (one
/// pass all but erases the fastest shake; the slowest survives even the
/// maximum). Deterministic, so both frontends and the tests draw one stroke.
pub(crate) fn smoothing_preview_sample() -> Vec<(i32, i32)> {
pub(crate) fn smoothing_preview_sample() -> Vec<(f64, f64)> {
(0..SAMPLE_POINTS)
.map(|index| {
let i = index as f64;
@@ -44,7 +44,7 @@ pub(crate) fn smoothing_preview_sample() -> Vec<(i32, i32)> {
let tremor =
9.0 * (2.4 * i).sin() + 11.0 * (1.3 * i + 0.7).sin() + 8.0 * (0.8 * i + 2.1).sin();
let y = SAMPLE_H / 2.0 + wave + tremor;
(x.round() as i32, y.round() as i32)
(x, y)
})
.collect()
}
@@ -85,13 +85,10 @@ pub(crate) fn draw_smoothing_preview(ctx: &cairo::Context, rect: (f64, f64, f64,
let _ = ctx.restore();
}
fn stroke_polyline(ctx: &cairo::Context, points: &[(i32, i32)], origin: (f64, f64), scale: f64) {
fn stroke_polyline(ctx: &cairo::Context, points: &[(f64, f64)], origin: (f64, f64), scale: f64) {
ctx.new_path();
for &(px, py) in points {
ctx.line_to(
origin.0 + f64::from(px) * scale,
origin.1 + f64::from(py) * scale,
);
ctx.line_to(origin.0 + px * scale, origin.1 + py * scale);
}
}
@@ -157,7 +154,7 @@ mod tests {
let total: f64 = raw
.iter()
.zip(&smoothed)
.map(|(a, b)| f64::from(a.0 - b.0).hypot(f64::from(a.1 - b.1)))
.map(|(a, b)| (a.0 - b.0).hypot(a.1 - b.1))
.sum();
total / raw.len() as f64
}
@@ -184,8 +181,8 @@ mod tests {
#[test]
fn the_sample_fits_its_space() {
for (x, y) in smoothing_preview_sample() {
assert!((0..SAMPLE_W as i32).contains(&x), "x {x}");
assert!((0..SAMPLE_H as i32).contains(&y), "y {y}");
assert!((0.0..SAMPLE_W).contains(&x), "x {x}");
assert!((0.0..SAMPLE_H).contains(&y), "y {y}");
}
}
+7 -6
View File
@@ -49,12 +49,13 @@ pub(crate) use style_pill::{
ARROW_STYLE_CHIP_GLYPH_H, ARROW_STYLE_CHIP_GLYPH_W, ARROW_STYLE_CHIP_W, ARROW_STYLE_MENU_INSET,
ARROW_STYLE_MENU_PAD, ARROW_STYLE_MENU_PREVIEW_H, ARROW_STYLE_MENU_PREVIEW_W,
ARROW_STYLE_MENU_ROW_GAP, ARROW_STYLE_MENU_ROW_H, ARROW_STYLE_MENU_ROW_W, ArrowStyleMenuEntry,
OpacityPaint, PEN_FEEL_BARS_H, PEN_FEEL_CONTENT_W, PEN_FEEL_HEADER_H, PEN_FEEL_HINT_H,
PEN_FEEL_PAD, PEN_FEEL_PREVIEW_H, PEN_FEEL_ROW_GAP, PEN_FEEL_SECTION_GAP, PEN_FEEL_TITLE,
PEN_FEEL_TITLE_H, PenFeelSection, StrokeSetting, StylePillControl, StylePillCounter,
StylePillMeter, StylePillMeterSegment, StylePillRole, StylePillSegment, StylePillSlider,
StylePillSpec, StylePillState, arrow_style_chip_label, arrow_style_menu_entries,
arrow_style_menu_size, pen_feel_id, pen_feel_panel_size, pen_feel_sections, pen_feel_settings,
METER_ZERO_SLOT_W, OpacityPaint, PEN_FEEL_BARS_H, PEN_FEEL_CONTENT_W, PEN_FEEL_HEADER_H,
PEN_FEEL_HINT_H, PEN_FEEL_PAD, PEN_FEEL_PREVIEW_H, PEN_FEEL_ROW_GAP, PEN_FEEL_SECTION_GAP,
PEN_FEEL_TITLE, PEN_FEEL_TITLE_H, PenFeelSection, StrokeSetting, StylePillControl,
StylePillCounter, StylePillMeter, StylePillMeterSegment, StylePillRole, StylePillSegment,
StylePillSlider, StylePillSpec, StylePillState, arrow_style_chip_label,
arrow_style_menu_entries, arrow_style_menu_size, pen_feel_id, pen_feel_panel_size,
pen_feel_sections, pen_feel_settings,
};
#[allow(unused_imports)]
pub(crate) use tools::{
+1 -1
View File
@@ -36,7 +36,7 @@ pub(crate) use arrow_menu::{
ARROW_STYLE_MENU_ROW_GAP, ARROW_STYLE_MENU_ROW_H, ARROW_STYLE_MENU_ROW_W, ArrowStyleMenuEntry,
arrow_style_chip_label, arrow_style_menu_entries, arrow_style_menu_size,
};
pub(crate) use meter::{StrokeSetting, StylePillMeter, StylePillMeterSegment};
pub(crate) use meter::{METER_ZERO_SLOT_W, StrokeSetting, StylePillMeter, StylePillMeterSegment};
pub(crate) use pen_feel::{
PEN_FEEL_BARS_H, PEN_FEEL_CONTENT_W, PEN_FEEL_HEADER_H, PEN_FEEL_HINT_H, PEN_FEEL_PAD,
PEN_FEEL_PREVIEW_H, PEN_FEEL_ROW_GAP, PEN_FEEL_SECTION_GAP, PEN_FEEL_TITLE, PEN_FEEL_TITLE_H,
+53 -18
View File
@@ -3,8 +3,8 @@
//! A bare "− 3 +" said neither how high 3 is nor how far it goes, and the two
//! settings run over different ranges. A meter shows both at a glance: one bar
//! per level, filled up to the current one. Clicking a bar sets that level;
//! clicking the highest filled bar steps one below it, so the lowest level (Off
//! for smoothing, Precise for detection) stays one click away. The wheel steps
//! a separate dot sets the lowest level (Off for smoothing, Precise for
//! detection) in one click. Clicking the current level keeps it. The wheel steps
//! one level either way, and each level has a name for tooltips and assistive
//! tech.
//!
@@ -14,6 +14,9 @@
use super::*;
/// Logical width reserved for the zero-level dot before the bars.
pub(crate) const METER_ZERO_SLOT_W: f64 = 22.0;
/// Names of pen smoothing levels 0..=6, weakest first.
const SMOOTHING_LEVEL_NAMES: [&str; 7] = [
"Off", "Minimal", "Light", "Medium", "Strong", "Stronger", "Maximum",
@@ -45,6 +48,7 @@ pub(crate) struct StylePillMeterSegment {
pub(crate) struct StylePillMeter {
pub(crate) level: u8,
pub(crate) max: u8,
pub(crate) zero: StylePillMeterSegment,
pub(crate) segments: Vec<StylePillMeterSegment>,
}
@@ -131,7 +135,7 @@ impl StrokeSetting {
let segments = (1..=max)
.map(|bar| {
let target = clicked_level(level, bar);
let target = bar;
StylePillMeterSegment {
id: format!("{id_prefix}.level-{bar}"),
filled: bar <= level,
@@ -147,10 +151,29 @@ impl StrokeSetting {
StylePillMeter {
level,
max,
zero: self.zero_segment(snapshot, id_prefix),
segments,
}
}
pub(crate) fn zero_segment(
self,
snapshot: &ToolbarSnapshot,
id_prefix: &str,
) -> StylePillMeterSegment {
StylePillMeterSegment {
id: format!("{id_prefix}.level-0"),
filled: true,
event: self.event(0),
tooltip: format!(
"{}: {}. Click for {}",
self.name(),
self.current_level_name(snapshot),
self.level_name(0)
),
}
}
/// The event `steps` whole wheel notches apply: that many levels up for
/// `steps > 0`, down for `steps < 0`, clamped to the range. `None` when the
/// level would not change, so a wheel at either end is ignored.
@@ -192,13 +215,17 @@ impl StrokeSetting {
}
}
/// The level a click on bar `bar` (1-based) applies: that level, or one below
/// it when the bar is already the highest filled one.
fn clicked_level(level: u8, bar: u8) -> u8 {
if bar == level { bar - 1 } else { bar }
}
impl StylePillControl {
/// The stroke setting with a direct zero target, including steppers.
pub(crate) fn stroke_setting(self) -> Option<StrokeSetting> {
match self {
Self::PenSmoothingMeter | Self::PenSmoothingStepper => Some(StrokeSetting::Smoothing),
Self::ShapeSensitivityMeter | Self::ShapeSensitivityStepper => {
Some(StrokeSetting::ShapeDetection)
}
_ => None,
}
}
/// The setting an inline meter adjusts, `None` for every other control.
pub(crate) fn meter_setting(self) -> Option<StrokeSetting> {
match self {
@@ -271,15 +298,23 @@ mod tests {
}
#[test]
fn a_bar_sets_its_level_and_the_top_filled_bar_steps_one_below() {
let meter = StylePillControl::PenSmoothingMeter.required_meter(&snapshot(3, 3));
assert_eq!(meter.segments[4].event, ToolbarEvent::SetPenSmoothing(5));
assert_eq!(meter.segments[0].event, ToolbarEvent::SetPenSmoothing(1));
assert_eq!(meter.segments[2].event, ToolbarEvent::SetPenSmoothing(2));
let lowest = StylePillControl::PenSmoothingMeter.required_meter(&snapshot(1, 3));
assert_eq!(lowest.segments[0].event, ToolbarEvent::SetPenSmoothing(0));
fn the_dot_sets_zero_and_every_bar_sets_its_exact_level() {
for setting in [StrokeSetting::Smoothing, StrokeSetting::ShapeDetection] {
for current in 0..=setting.max() {
let meter = setting.meter(&snapshot(current, current), "test");
assert_eq!(meter.zero.event, setting.event(0));
assert!(meter.zero.filled, "zero is always reached");
assert!(
meter
.zero
.tooltip
.contains(&format!("Click for {}", setting.level_name(0)))
);
for (index, bar) in meter.segments.iter().enumerate() {
assert_eq!(bar.event, setting.event(index as u8 + 1));
}
}
}
}
#[test]
+3
View File
@@ -8,6 +8,7 @@ public sealed class GtkGateParityTests
{
private const string PopupMarker = "EXECUTED: GTK native popup presentation regression";
private const string MenuMarker = "EXECUTED: GTK native menu presentation assertions";
private const string PenFeelMarker = "EXECUTED: GTK Pen feel fractional-scale render assertions";
private const string FixtureSocket = "WAYSCRIBER_FIXTURE_SOCKET";
private const string FixtureOutput = "WAYSCRIBER_FIXTURE_OUTPUT";
private const string FixtureExit = "WAYSCRIBER_FIXTURE_EXIT";
@@ -18,6 +19,7 @@ public sealed class GtkGateParityTests
[InlineData( null, ExitCodes.Success )]
[InlineData( PopupMarker, ExitCodes.Success )]
[InlineData( MenuMarker, ExitCodes.Success )]
[InlineData( PenFeelMarker, ExitCodes.Success )]
[InlineData( null, ExitCodes.Failure )]
public async Task BothGatesRequireNativeAssertionsAndCleanUpTheirPrivateDisplay( string? omittedMarker, int childExitCode )
{
@@ -71,6 +73,7 @@ public sealed class GtkGateParityTests
"EXECUTED: GTK widget contract assertions",
PopupMarker,
MenuMarker,
PenFeelMarker,
];
File.WriteAllLines( output, markers.Where( marker => marker != omittedMarker ) );
RuntimeLog = Path.Combine( _directory.Path, "runtime" );
+2 -1
View File
@@ -245,7 +245,8 @@ internal static class DevelopmentCommands
var combinedOutput = result.StandardOutput + result.StandardError;
foreach ( var marker in new[] { "EXECUTED: GTK focus and slider assertions", "EXECUTED: GTK widget contract assertions",
"EXECUTED: GTK native popup presentation regression", "EXECUTED: GTK native menu presentation assertions" } )
"EXECUTED: GTK native popup presentation regression", "EXECUTED: GTK native menu presentation assertions",
"EXECUTED: GTK Pen feel fractional-scale render assertions" } )
{
if ( !combinedOutput.Contains( marker, StringComparison.Ordinal ) )
{
+1
View File
@@ -36,5 +36,6 @@ dbus-run-session -- cargo test --locked -p wayscriber --all-features --lib \
toolbar_gtk:: -- --test-threads=1 --nocapture 2>&1 | tee "$GTK_TEST_RUNTIME/tests.log"
grep -Fq 'EXECUTED: GTK focus and slider assertions' "$GTK_TEST_RUNTIME/tests.log"
grep -Fq 'EXECUTED: GTK widget contract assertions' "$GTK_TEST_RUNTIME/tests.log"
grep -Fq 'EXECUTED: GTK Pen feel fractional-scale render assertions' "$GTK_TEST_RUNTIME/tests.log"
grep -Fq 'EXECUTED: GTK native popup presentation regression' "$GTK_TEST_RUNTIME/tests.log"
grep -Fq 'EXECUTED: GTK native menu presentation assertions' "$GTK_TEST_RUNTIME/tests.log"