diff --git a/crates/app/src/ui/canvas/navigation.rs b/crates/app/src/ui/canvas/navigation.rs index 0afd3961..2d8ef5d5 100644 --- a/crates/app/src/ui/canvas/navigation.rs +++ b/crates/app/src/ui/canvas/navigation.rs @@ -42,6 +42,29 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, }); let typing = ui.ctx().egui_wants_keyboard_input(); + // A double-click is reported on the second release. By then a zero-distance + // box or axis zoom has already started on the second press, so handle the + // click before the in-flight zoom completion path can consume that release. + if dbl + && let Some(p) = hover.filter(|p| rect.contains(*p)) + && !command + && let Some((id, outer, plot)) = plot_under_cursor(app, ci, rect, p) + { + app.finish_pending_wheel_zoom(now, true); + app.finish_pending_wheel_property(now, true); + // A pan already moved the viewport and `cancel_interaction` has no Pan + // arm, so cancelling would leave that move applied with no undo record + // and make the reset's own record start from the panned view. Commit it + // first: the pan and the reset then undo as the two edits they are. + if matches!(app.interaction(), Interaction::Pan(_)) { + commit_data_pan(app); + } else if app.interaction().is_active() { + app.cancel_interaction(); + } + reset_plot_viewport(app, ci, id, outer, plot, p); + return true; + } + // Every viewport zoom drag is owned here start-to-finish so the ambient // Alt+drag box gesture and the dedicated Browse Zoom tool share one path. let active_zoom = match &app.session.ui.interaction { @@ -111,11 +134,6 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect, return true; } - if dbl && let Some((id, outer, plot)) = data_target { - reset_plot_viewport(app, ci, id, outer, plot, p); - return true; - } - let pinch = (zoom_delta - 1.0).abs() > 0.001; let wheel = scroll.y.abs() > 0.0; if !typing && (pinch || wheel) { @@ -636,3 +654,7 @@ pub(crate) fn zoom_plot_viewport( ui.ctx() .request_repaint_after(std::time::Duration::from_millis(200)); } + +#[cfg(test)] +#[path = "navigation_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/canvas/navigation_tests.rs b/crates/app/src/ui/canvas/navigation_tests.rs new file mode 100644 index 00000000..43a25c67 --- /dev/null +++ b/crates/app/src/ui/canvas/navigation_tests.rs @@ -0,0 +1,261 @@ +use super::*; + +fn pointer_frame( + ctx: &egui::Context, + app: &mut PlotxApp, + screen: egui::Rect, + pointer: Pos2, + time: f64, + pressed: bool, +) -> bool { + let input = egui::RawInput { + screen_rect: Some(screen), + time: Some(time), + events: vec![ + egui::Event::PointerMoved(pointer), + egui::Event::PointerButton { + pos: pointer, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + }, + ], + ..Default::default() + }; + let mut consumed = false; + let _ = ctx.run_ui(input, |ui| { + consumed = handle_navigation(app, 0, screen, ui); + }); + consumed +} + +#[test] +fn a_double_click_release_beats_the_zero_distance_box_zoom_and_resets_the_plot() { + let (mut app, ids) = crate::ui::properties::fixture::contour_page(1); + let object = ids[0]; + app.set_tool(Tool::BrowseZoom); + app.session.board = BoardViewport { + zoom: 1.0, + pan: [0.0, 0.0], + auto_fit: false, + }; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0)); + let plot = plot_inner_rect(&app, 0, object, screen).expect("plot is on the board"); + let pointer = Pos2::new( + (plot.left + plot.right()) * 0.5, + (plot.top + plot.bottom()) * 0.5, + ); + + let plot_object = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("fixture plot"); + let full_x = plot_object.viewport.full_x; + let full_y = plot_object.viewport.full_y; + plot_object.viewport.view_x = AxisRange::new( + full_x.min + full_x.span() * 0.25, + full_x.max - full_x.span() * 0.25, + ); + plot_object.viewport.view_y = AxisRange::new( + full_y.min + full_y.span() * 0.25, + full_y.max - full_y.span() * 0.25, + ); + plot_object.apply_viewport(); + + let ctx = egui::Context::default(); + pointer_frame(&ctx, &mut app, screen, pointer, 0.00, true); + pointer_frame(&ctx, &mut app, screen, pointer, 0.05, false); + pointer_frame(&ctx, &mut app, screen, pointer, 0.10, true); + app.begin_interaction(Interaction::Zoom(ZoomDrag { + canvas: 0, + object, + start: [pointer.x, pointer.y], + current: [pointer.x, pointer.y], + axis: ZoomAxis::Box, + })); + assert!( + pointer_frame(&ctx, &mut app, screen, pointer, 0.15, false), + "the double-click is consumed as navigation" + ); + + let plot_object = app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .expect("fixture plot remains"); + assert_eq!(plot_object.viewport.view_x, full_x); + assert_eq!(plot_object.viewport.view_y, full_y); + assert!(matches!(app.interaction(), Interaction::Idle)); +} + +/// The reset preempts every in-flight gesture, but a pan has already moved the +/// viewport. Cancelling it would drop that move's undo record and leave the +/// reset's own record starting from the panned view, so one undo would land on +/// the pan the user never asked to keep. +#[test] +fn a_double_click_during_a_pan_commits_the_pan_before_it_resets() { + let (mut app, ids) = crate::ui::properties::fixture::contour_page(1); + let object = ids[0]; + app.session.board = BoardViewport { + zoom: 1.0, + pan: [0.0, 0.0], + auto_fit: false, + }; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0)); + let plot = plot_inner_rect(&app, 0, object, screen).expect("plot is on the board"); + let pointer = Pos2::new( + (plot.left + plot.right()) * 0.5, + (plot.top + plot.bottom()) * 0.5, + ); + + let plot_object = app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .expect("fixture plot"); + let full_x = plot_object.viewport.full_x; + let full_y = plot_object.viewport.full_y; + let before = plot_object.viewport.clone(); + + let ctx = egui::Context::default(); + pointer_frame(&ctx, &mut app, screen, pointer, 0.00, true); + pointer_frame(&ctx, &mut app, screen, pointer, 0.05, false); + pointer_frame(&ctx, &mut app, screen, pointer, 0.10, true); + + // A pan in flight, with the viewport already dragged away from the fit. + app.begin_interaction(Interaction::Pan(PanDrag { + canvas: 0, + object, + before, + })); + let plot_object = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("fixture plot"); + let panned_x = AxisRange::new( + full_x.min + full_x.span() * 0.25, + full_x.max - full_x.span() * 0.25, + ); + plot_object.viewport.view_x = panned_x; + plot_object.apply_viewport(); + let undo_before = app.session.undo_stack.len(); + + assert!( + pointer_frame(&ctx, &mut app, screen, pointer, 0.15, false), + "the double-click is consumed as navigation" + ); + + let viewport = |app: &PlotxApp| { + app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .expect("fixture plot remains") + .viewport + .clone() + }; + assert_eq!(viewport(&app).view_x, full_x); + assert_eq!(viewport(&app).view_y, full_y); + assert!(matches!(app.interaction(), Interaction::Idle)); + assert_eq!( + app.session.undo_stack.len(), + undo_before + 2, + "the pan and the reset are two edits" + ); + + app.undo(); + assert_eq!(viewport(&app).view_x, panned_x, "one undo returns the pan"); + app.undo(); + assert_eq!( + viewport(&app).view_x, + full_x, + "the second undo returns the pre-pan view" + ); +} + +fn assert_axis_double_click_resets_only(axis: ZoomAxis) { + let (mut app, ids) = crate::ui::properties::fixture::contour_page(1); + let object = ids[0]; + app.session.board = BoardViewport { + zoom: 1.0, + pan: [0.0, 0.0], + auto_fit: false, + }; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0)); + let outer = object_screen_rect(app.session.board, &app.doc.canvases[0], object, screen) + .expect("plot is on the board"); + let plot = plot_inner_rect(&app, 0, object, screen).expect("plot has an inner rectangle"); + let pointer = match axis { + ZoomAxis::X => Pos2::new( + (plot.left + plot.right()) * 0.5, + (plot.bottom() + outer.bottom()) * 0.5, + ), + ZoomAxis::Y => Pos2::new( + (outer.left + plot.left) * 0.5, + (plot.top + plot.bottom()) * 0.5, + ), + ZoomAxis::Box => panic!("axis-strip test needs one axis"), + }; + assert!( + matches!( + hit_zone(pointer, plot_rect(outer), plot), + HitZone::XAxis if axis == ZoomAxis::X + ) || matches!( + hit_zone(pointer, plot_rect(outer), plot), + HitZone::YAxis if axis == ZoomAxis::Y + ) + ); + + let plot_object = app.doc.canvases[0] + .object_mut(object) + .and_then(|object| object.plot_mut()) + .expect("fixture plot"); + let full_x = plot_object.viewport.full_x; + let full_y = plot_object.viewport.full_y; + let narrow_x = AxisRange::new( + full_x.min + full_x.span() * 0.25, + full_x.max - full_x.span() * 0.25, + ); + let narrow_y = AxisRange::new( + full_y.min + full_y.span() * 0.25, + full_y.max - full_y.span() * 0.25, + ); + plot_object.viewport.view_x = narrow_x; + plot_object.viewport.view_y = narrow_y; + plot_object.viewport.auto_y = false; + plot_object.apply_viewport(); + + let ctx = egui::Context::default(); + pointer_frame(&ctx, &mut app, screen, pointer, 0.00, true); + pointer_frame(&ctx, &mut app, screen, pointer, 0.05, false); + pointer_frame(&ctx, &mut app, screen, pointer, 0.10, true); + assert!( + pointer_frame(&ctx, &mut app, screen, pointer, 0.15, false), + "the double-click is consumed as navigation" + ); + + let viewport = &app.doc.canvases[0] + .object(object) + .and_then(|object| object.plot()) + .expect("fixture plot remains") + .viewport; + match axis { + ZoomAxis::X => { + assert_eq!(viewport.view_x, full_x); + assert_eq!(viewport.view_y, narrow_y); + } + ZoomAxis::Y => { + assert_eq!(viewport.view_x, narrow_x); + assert_eq!(viewport.view_y, full_y); + } + ZoomAxis::Box => unreachable!(), + } + assert!(matches!(app.interaction(), Interaction::Idle)); +} + +#[test] +fn a_double_click_on_the_x_axis_resets_only_x() { + assert_axis_double_click_resets_only(ZoomAxis::X); +} + +#[test] +fn a_double_click_on_the_y_axis_resets_only_y() { + assert_axis_double_click_resets_only(ZoomAxis::Y); +} diff --git a/crates/app/src/ui/properties/control.rs b/crates/app/src/ui/properties/control.rs index 938970b7..4ec1e0cd 100644 --- a/crates/app/src/ui/properties/control.rs +++ b/crates/app/src/ui/properties/control.rs @@ -1,9 +1,12 @@ //! Property row controls, value descriptions, and gesture edges. use super::*; +use plotx_core::properties::{contour, line}; use plotx_core::state::CanvasSizeUnit; use std::borrow::Cow; +const LINE_WIDTH_PRESETS: [(&str, f64); 3] = [("Fine", 0.5), ("Medium", 0.75), ("Bold", 1.25)]; + pub(super) struct RowEdits<'a> { pub pending: &'a mut Option, pub gesture: &'a mut Option<(PropertyId, GestureEdge)>, @@ -25,7 +28,7 @@ pub(super) fn property_row( .scope(|ui| { ui.horizontal(|ui| { ui.label(row.presentation.localized_label.get()) - .on_hover_text(row.definition.canonical_label); + .on_hover_text(property_hint(row)); match row.representative.availability { plotx_core::properties::Availability::Editable => { control( @@ -154,6 +157,16 @@ fn modified_marker(row: &Row, pending: &mut Option, ui: &mut Ui) { } } +fn property_hint(row: &Row) -> &'static str { + match row.presentation.id { + line::STROKE_WIDTH => "Width of spectrum and line-series strokes, in points.", + contour::LINE_WIDTH => { + "Width of contour strokes, in points. Lowest level and Levels control which features are drawn." + } + _ => row.definition.canonical_label, + } +} + /// Draw the control for one row. /// /// When the row has no single value the widget still edits — one gesture must @@ -470,6 +483,7 @@ fn float_control( )); } draw_unit(ui, &projection.caption); + line_width_presets(row, current, mixed, pending, ui); if let Some(PropertyReadout::ContourBase(readout)) = &row.readout && let Some(suffix) = super::super::readout::resolution_suffix(readout) { @@ -484,6 +498,43 @@ fn float_control( } } +fn line_width_presets( + row: &Row, + current: f64, + mixed: bool, + pending: &mut Option, + ui: &mut Ui, +) { + if !matches!( + row.presentation.id, + line::STROKE_WIDTH | contour::LINE_WIDTH + ) { + return; + } + ui.menu_button("Presets", |ui| { + for (name, value) in LINE_WIDTH_PRESETS { + // Every preset is exactly representable, and widths round-trip + // through `PositiveFiniteF32`, so the stored value either is the + // preset or is a notch away from it. Equality is the honest test. + let selected = !mixed && current == value; + if ui + .selectable_label(selected, format!("{name} — {value:.2} pt")) + .clicked() + { + if !selected { + *pending = Some(Pending::Write( + row.presentation.id, + PropertyValue::Float(value), + )); + } + ui.close(); + } + } + }) + .response + .on_hover_text("Choose a common data-line width"); +} + struct FloatControlInput { bounds: plotx_core::properties::FloatBounds, display: plotx_core::properties::FloatDisplay, @@ -654,146 +705,5 @@ fn describe<'a>(row: &Row, value: &'a PropertyValue) -> Cow<'a, str> { } #[cfg(test)] -mod tests { - use super::*; - use plotx_core::properties::{FloatBounds, FloatDisplay, axis}; - - #[test] - fn stepped_drag_candidates_snap_to_the_schema_lattice() { - for candidate in 3..=15 { - let snapped = snapped_stepped_int(candidate, 3, 15, 2); - assert!((3..=15).contains(&snapped)); - assert_eq!((snapped - 3) % 2, 0, "candidate {candidate}"); - } - assert_eq!(snapped_stepped_int(10, 3, 15, 2), 11); - } - - #[test] - fn continuous_text_input_commits_one_undo_record() { - let (mut app, ids) = crate::ui::properties::fixture::contour_page(1); - let target = app.object_target(0, ids[0]).expect("plot target"); - let undo_before = app.session.undo_stack.len(); - let mut editing = true; - let mut buffer = String::new(); - let mut submissions = 0; - - for character in ["A", "x", "i", "s"] { - buffer.push_str(character); - if should_submit_text_edit(&mut editing, true, false, false) { - submissions += 1; - } - } - if should_submit_text_edit(&mut editing, false, true, false) { - submissions += 1; - let commit = app - .plan_property_write( - axis::X_LABEL, - std::slice::from_ref(&target), - &PropertyValue::Text(buffer), - ) - .expect("text edit plans"); - app.commit_property(commit); - } - - assert_eq!(submissions, 1); - assert_eq!(app.session.undo_stack.len(), undo_before + 1); - assert_eq!( - app.doc.canvases[0].objects[0] - .plot() - .expect("plot") - .axis_overrides - .x_label - .as_deref(), - Some("Axis") - ); - app.undo(); - assert_eq!( - app.doc.canvases[0].objects[0] - .plot() - .expect("plot") - .axis_overrides - .x_label, - None - ); - } - - #[test] - fn continuous_text_box_input_commits_one_undo_record() { - use plotx_core::state::{ - CanvasDocument, CanvasObject, CanvasObjectKind, ObjectFrame, TextBox, - }; - let mut app = PlotxApp::new(); - let mut canvas = CanvasDocument::new("text".to_owned(), [120.0, 80.0]); - let id = canvas.allocate_object_id(); - canvas.objects.push(CanvasObject { - id, - name: "Caption".to_owned(), - frame: ObjectFrame::new(0.0, 0.0, 40.0, 20.0), - locked: false, - visible: true, - group: None, - kind: CanvasObjectKind::Text(TextBox::label(String::new())), - }); - app.doc.canvases.push(canvas); - let target = app.object_target(0, id).unwrap(); - let undo_before = app.session.undo_stack.len(); - let mut editing = true; - let mut buffer = String::new(); - let mut submissions = 0; - for character in ["P", "l", "o", "t", "X"] { - buffer.push_str(character); - if should_submit_text_edit(&mut editing, true, false, false) { - submissions += 1; - } - } - if should_submit_text_edit(&mut editing, false, true, false) { - submissions += 1; - let commit = app - .plan_property_write( - plotx_core::properties::object::TEXT, - std::slice::from_ref(&target), - &PropertyValue::Text(buffer), - ) - .unwrap(); - app.commit_property(commit); - } - assert_eq!(submissions, 1); - assert_eq!(app.session.undo_stack.len(), undo_before + 1); - assert_eq!( - app.doc.canvases[0].object(id).unwrap().text().unwrap().text, - "PlotX" - ); - app.undo(); - let text = app.doc.canvases[0].object(id).unwrap().text().unwrap(); - assert!(text.text.is_empty()); - } - - #[test] - fn a_drag_across_zero_never_emits_a_kernel_rejected_divisor() { - let bounds = FloatBounds::excluding_magnitude(-f64::MAX, f64::MAX, f64::MIN_POSITIVE); - let next = admitted_float_from_control(bounds, 1.0, 0.0, FloatDisplay::Linear(""), 0.1); - assert!(next < 0.0, "a downward drag crosses to the negative side"); - assert!(bounds.admits(next)); - assert!(next.abs() > f64::MIN_POSITIVE); - } - - #[test] - fn canvas_length_projection_changes_value_caption_and_write_space_together() { - let projection = FloatControlProjection::new( - true, - FloatBounds::inclusive(0.0, 100.0), - FloatDisplay::Linear("mm"), - 25.4, - CanvasSizeUnit::Inch, - Some(1.0), - ); - assert!((projection.displayed - 1.0).abs() < 1.0e-6); - assert_eq!(projection.caption, "in"); - assert_eq!(projection.decimals, Some(3)); - assert_eq!(projection.speed, CanvasSizeUnit::Inch.drag_speed()); - assert!( - (projection.to_domain(2.0) - 50.8).abs() < 1.0e-5, - "the catalog always receives millimetres" - ); - } -} +#[path = "control_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/properties/control_tests.rs b/crates/app/src/ui/properties/control_tests.rs new file mode 100644 index 00000000..a645c451 --- /dev/null +++ b/crates/app/src/ui/properties/control_tests.rs @@ -0,0 +1,147 @@ +use super::*; +use plotx_core::properties::{FloatBounds, FloatDisplay, axis}; + +#[test] +fn stepped_drag_candidates_snap_to_the_schema_lattice() { + for candidate in 3..=15 { + let snapped = snapped_stepped_int(candidate, 3, 15, 2); + assert!((3..=15).contains(&snapped)); + assert_eq!((snapped - 3) % 2, 0, "candidate {candidate}"); + } + assert_eq!(snapped_stepped_int(10, 3, 15, 2), 11); +} + +#[test] +fn continuous_text_input_commits_one_undo_record() { + let (mut app, ids) = crate::ui::properties::fixture::contour_page(1); + let target = app.object_target(0, ids[0]).expect("plot target"); + let undo_before = app.session.undo_stack.len(); + let mut editing = true; + let mut buffer = String::new(); + let mut submissions = 0; + + for character in ["A", "x", "i", "s"] { + buffer.push_str(character); + if should_submit_text_edit(&mut editing, true, false, false) { + submissions += 1; + } + } + if should_submit_text_edit(&mut editing, false, true, false) { + submissions += 1; + let commit = app + .plan_property_write( + axis::X_LABEL, + std::slice::from_ref(&target), + &PropertyValue::Text(buffer), + ) + .expect("text edit plans"); + app.commit_property(commit); + } + + assert_eq!(submissions, 1); + assert_eq!(app.session.undo_stack.len(), undo_before + 1); + assert_eq!( + app.doc.canvases[0].objects[0] + .plot() + .expect("plot") + .axis_overrides + .x_label + .as_deref(), + Some("Axis") + ); + app.undo(); + assert_eq!( + app.doc.canvases[0].objects[0] + .plot() + .expect("plot") + .axis_overrides + .x_label, + None + ); +} + +#[test] +fn continuous_text_box_input_commits_one_undo_record() { + use plotx_core::state::{CanvasDocument, CanvasObject, CanvasObjectKind, ObjectFrame, TextBox}; + let mut app = PlotxApp::new(); + let mut canvas = CanvasDocument::new("text".to_owned(), [120.0, 80.0]); + let id = canvas.allocate_object_id(); + canvas.objects.push(CanvasObject { + id, + name: "Caption".to_owned(), + frame: ObjectFrame::new(0.0, 0.0, 40.0, 20.0), + locked: false, + visible: true, + group: None, + kind: CanvasObjectKind::Text(TextBox::label(String::new())), + }); + app.doc.canvases.push(canvas); + let target = app.object_target(0, id).unwrap(); + let undo_before = app.session.undo_stack.len(); + let mut editing = true; + let mut buffer = String::new(); + let mut submissions = 0; + for character in ["P", "l", "o", "t", "X"] { + buffer.push_str(character); + if should_submit_text_edit(&mut editing, true, false, false) { + submissions += 1; + } + } + if should_submit_text_edit(&mut editing, false, true, false) { + submissions += 1; + let commit = app + .plan_property_write( + plotx_core::properties::object::TEXT, + std::slice::from_ref(&target), + &PropertyValue::Text(buffer), + ) + .unwrap(); + app.commit_property(commit); + } + assert_eq!(submissions, 1); + assert_eq!(app.session.undo_stack.len(), undo_before + 1); + assert_eq!( + app.doc.canvases[0].object(id).unwrap().text().unwrap().text, + "PlotX" + ); + app.undo(); + let text = app.doc.canvases[0].object(id).unwrap().text().unwrap(); + assert!(text.text.is_empty()); +} + +#[test] +fn a_drag_across_zero_never_emits_a_kernel_rejected_divisor() { + let bounds = FloatBounds::excluding_magnitude(-f64::MAX, f64::MAX, f64::MIN_POSITIVE); + let next = admitted_float_from_control(bounds, 1.0, 0.0, FloatDisplay::Linear(""), 0.1); + assert!(next < 0.0, "a downward drag crosses to the negative side"); + assert!(bounds.admits(next)); + assert!(next.abs() > f64::MIN_POSITIVE); +} + +#[test] +fn canvas_length_projection_changes_value_caption_and_write_space_together() { + let projection = FloatControlProjection::new( + true, + FloatBounds::inclusive(0.0, 100.0), + FloatDisplay::Linear("mm"), + 25.4, + CanvasSizeUnit::Inch, + Some(1.0), + ); + assert!((projection.displayed - 1.0).abs() < 1.0e-6); + assert_eq!(projection.caption, "in"); + assert_eq!(projection.decimals, Some(3)); + assert_eq!(projection.speed, CanvasSizeUnit::Inch.drag_speed()); + assert!( + (projection.to_domain(2.0) - 50.8).abs() < 1.0e-5, + "the catalog always receives millimetres" + ); +} + +#[test] +fn line_width_presets_include_the_default_and_two_emphasis_levels() { + assert_eq!( + LINE_WIDTH_PRESETS, + [("Fine", 0.5), ("Medium", 0.75), ("Bold", 1.25)] + ); +} diff --git a/crates/app/src/ui/properties/tests.rs b/crates/app/src/ui/properties/tests.rs index 9f276daf..860f5684 100644 --- a/crates/app/src/ui/properties/tests.rs +++ b/crates/app/src/ui/properties/tests.rs @@ -391,15 +391,21 @@ fn only_physical_canvas_lengths_follow_the_users_canvas_unit() { ); } -/// §12: only the lowest level is Essential on a contour; the ladder shape and -/// the negative-half colour are for users who went looking for them. +/// The two contour controls that directly decide what dense data looks like +/// stay visible; the ladder shape and colours remain Advanced. #[test] -fn only_the_lowest_contour_level_is_essential() { +fn contour_level_and_line_width_are_essential() { let essential: Vec<&str> = essential_in(panel::CONTOUR_SECTION) .iter() .map(|entry| entry.id.as_str()) .collect(); - assert_eq!(essential, [contour::BASE_MAGNITUDE.as_str()]); + assert_eq!( + essential, + [ + contour::BASE_MAGNITUDE.as_str(), + contour::LINE_WIDTH.as_str() + ] + ); } /// The row checkbox is compact chrome, not a second presentation channel. Its diff --git a/crates/core/src/properties/contour.rs b/crates/core/src/properties/contour.rs index 14b246e7..f2a61b1a 100644 --- a/crates/core/src/properties/contour.rs +++ b/crates/core/src/properties/contour.rs @@ -55,6 +55,7 @@ const MAX_RATIO: f64 = 10.0; const RATIO_BOUNDS: FloatBounds = FloatBounds::above(1.0, MAX_RATIO); /// Below this a stroke is invisible on screen and hairline in print. const LINE_WIDTH_BOUNDS: FloatBounds = FloatBounds::inclusive(0.05, 10.0); +const LINE_WIDTH_STEP: f64 = 0.05; /// The base-policy choices and the capability each one needs. `FractionOfRange` /// requires a bounded field and is withheld from signed ones: "four percent of @@ -195,13 +196,13 @@ pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[ scope_kind: ScopeKind::Object, value_schema: ValueSchema::Float { bounds: LINE_WIDTH_BOUNDS, - display: FloatDisplay::Linear(""), - drag_step: None, + display: FloatDisplay::Linear("pt"), + drag_step: Some(LINE_WIDTH_STEP), }, access: PropertyAccess::ReadWrite, applicability: CONTOUR, default_policy: DefaultPolicy::EncodingFactory, - tier: Tier::Advanced, + tier: Tier::Essential, copies: ValueCopies::PerTarget, canonical_label: "Contour line width", canonical_aliases: &["contour width", "line width", "stroke width"], diff --git a/crates/core/src/properties/line.rs b/crates/core/src/properties/line.rs index 55d5770b..f041594e 100644 --- a/crates/core/src/properties/line.rs +++ b/crates/core/src/properties/line.rs @@ -16,14 +16,14 @@ use plotx_figure::{PositiveFiniteF32, SeriesEncoding}; pub const STROKE_WIDTH: PropertyId = PropertyId("series.line.stroke_width"); const WIDTH_BOUNDS: FloatBounds = FloatBounds::inclusive(0.05, 10.0); -const WIDTH_STEP: f64 = 0.25; +const WIDTH_STEP: f64 = 0.05; pub(crate) const DEFINITIONS: &[PropertyDefinition] = &[PropertyDefinition { id: STROKE_WIDTH, scope_kind: ScopeKind::Object, value_schema: ValueSchema::Float { bounds: WIDTH_BOUNDS, - display: FloatDisplay::Linear(""), + display: FloatDisplay::Linear("pt"), drag_step: Some(WIDTH_STEP), }, access: PropertyAccess::ReadWrite, diff --git a/crates/core/src/properties/provider_tests.rs b/crates/core/src/properties/provider_tests.rs index d65e379e..8bc8a6e8 100644 --- a/crates/core/src/properties/provider_tests.rs +++ b/crates/core/src/properties/provider_tests.rs @@ -90,6 +90,22 @@ fn all_three_typography_sizes_share_the_declared_point_schema() { } } +#[test] +fn data_line_widths_share_the_fine_point_schema() { + for property in [line::STROKE_WIDTH, contour::LINE_WIDTH] { + let definition = definition(property).expect("line width is registered"); + assert_eq!( + definition.value_schema, + ValueSchema::Float { + bounds: FloatBounds::inclusive(0.05, 10.0), + display: FloatDisplay::Linear("pt"), + drag_step: Some(0.05), + } + ); + assert_eq!(definition.tier, Tier::Essential); + } +} + /// A write of the value already in typed storage is not an applied edit. The /// caller gets an explicit skip, and the empty composite cannot create a fake /// undo/revision entry. @@ -257,6 +273,8 @@ fn a_line_readout_dispatches_by_property_address() { assert_eq!( app.property_readout(&PropertyAddress::new(target, line::STROKE_WIDTH)) .expect("the line readout resolves"), - PropertyReadout::Value(PropertyValue::Float(1.0)) + PropertyReadout::Value(PropertyValue::Float(f64::from( + plotx_figure::DEFAULT_DATA_LINE_WIDTH_PT, + ))) ); } diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index c77ff147..8bc5d2c1 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -164,7 +164,7 @@ impl PlotxApp { Some(plotx_figure::AxisTrace { points, color: Color::TRACE, - width: 1.0, + width: plotx_figure::DEFAULT_DATA_LINE_WIDTH_PT, }) } @@ -175,7 +175,7 @@ impl PlotxApp { Some(plotx_figure::AxisTrace { points: n.spectrum.real_points(), color: Color::TRACE, - width: 1.0, + width: plotx_figure::DEFAULT_DATA_LINE_WIDTH_PT, }) } diff --git a/crates/figure/src/encoding.rs b/crates/figure/src/encoding.rs index a74bea07..aa644f48 100644 --- a/crates/figure/src/encoding.rs +++ b/crates/figure/src/encoding.rs @@ -1,6 +1,9 @@ use crate::{Color, ColormapId}; use serde::{Deserialize, Deserializer, Serialize, de}; +/// Default authored width, in points, for data traces and contour strokes. +pub const DEFAULT_DATA_LINE_WIDTH_PT: f32 = 0.5; + /// A finite, strictly positive scalar used by persisted presentation settings. /// Constructors reject non-finite values so encodings cannot poison renderer keys. #[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize)] @@ -116,7 +119,8 @@ impl Default for LineEncoding { Self { color: ColorSource::Explicit(Color::TRACE), scale: 1.0, - width: PositiveFiniteF32::new(1.0).expect("literal width is valid"), + width: PositiveFiniteF32::new(DEFAULT_DATA_LINE_WIDTH_PT) + .expect("literal width is valid"), } } } @@ -228,7 +232,8 @@ impl Default for ContourStyle { Self { positive_color: ColorSource::Explicit(Color::TRACE), negative_color: ColorSource::Explicit(Color::rgb(0xd1, 0x24, 0x2a)), - width: PositiveFiniteF32::new(0.7).expect("literal width is valid"), + width: PositiveFiniteF32::new(DEFAULT_DATA_LINE_WIDTH_PT) + .expect("literal width is valid"), } } } @@ -292,6 +297,18 @@ mod tests { ); } + #[test] + fn line_and_contour_defaults_share_the_fine_data_stroke() { + assert_eq!( + LineEncoding::default().width.get(), + DEFAULT_DATA_LINE_WIDTH_PT + ); + assert_eq!( + ContourStyle::default().width.get(), + DEFAULT_DATA_LINE_WIDTH_PT + ); + } + #[test] fn persisted_encoding_has_no_auto_variant() { let value = serde_json::to_value(SeriesEncoding::default()).unwrap(); diff --git a/crates/figure/src/lib.rs b/crates/figure/src/lib.rs index 487306eb..eb887c07 100644 --- a/crates/figure/src/lib.rs +++ b/crates/figure/src/lib.rs @@ -7,8 +7,8 @@ mod encoding; pub use colormap::ColormapId; pub use encoding::{ ColorSource, ContourBasePolicy, ContourLevelSpec, ContourSpec, ContourStyle, - EstimatorSelection, HeatmapSpec, ImageInterpolation, ImageSpec, LineEncoding, - PositiveFiniteF32, PositiveFiniteF64, SeriesEncoding, UnitInterval, + DEFAULT_DATA_LINE_WIDTH_PT, EstimatorSelection, HeatmapSpec, ImageInterpolation, ImageSpec, + LineEncoding, PositiveFiniteF32, PositiveFiniteF64, SeriesEncoding, UnitInterval, }; /// An RGB color, 0–255 per channel. @@ -209,7 +209,7 @@ impl Series { name: name.into(), points, color: Color::TRACE, - width: 1.0, + width: DEFAULT_DATA_LINE_WIDTH_PT, kind: SeriesKind::Line, } } diff --git a/docs/src/content/docs/guides/automation.md b/docs/src/content/docs/guides/automation.md index 0c1b7c31..f3bf7848 100644 --- a/docs/src/content/docs/guides/automation.md +++ b/docs/src/content/docs/guides/automation.md @@ -50,8 +50,8 @@ application**). | **Negative contours** | `series.contour.negative.enabled` | `true` or `false` | | **Positive colour** | `series.contour.positive_color` | `"#rrggbb"` | | **Negative colour** | `series.contour.negative_color` | `"#rrggbb"` | -| **Line width** | `series.contour.line_width` | 0.05 to 10 | -| **Stroke width**, in the **Line** section | `series.line.stroke_width` | 0.05 to 10 | +| **Line width** | `series.contour.line_width` | 0.05 to 10 pt; default 0.5 | +| **Stroke width**, in the **Line** section | `series.line.stroke_width` | 0.05 to 10 pt; default 0.5 | | **Tick-label size**, in the **Figure typography** section | `document.figure.typography.tick_pt` | 1 to 72 | Application preferences take the same three tools. For diff --git a/docs/src/content/docs/guides/contour-levels.md b/docs/src/content/docs/guides/contour-levels.md index 10662a2d..666c17c8 100644 --- a/docs/src/content/docs/guides/contour-levels.md +++ b/docs/src/content/docs/guides/contour-levels.md @@ -17,9 +17,9 @@ follows the page's active plot. ## Lowest level -**Lowest level** is the only setting shown until you open **Advanced**, because -it decides what you see: everything below it is not drawn at all. Raise it to -suppress noise, lower it to reveal weak cross-peaks. +**Lowest level** and **Line width** are the two rows shown before you open +**Advanced**. Lowest level decides what you see: everything below it is not +drawn at all. Raise it to suppress noise, lower it to reveal weak cross-peaks. What the number means depends on the anchor (below). Under the default anchor for a phase-sensitive NMR plane it is a multiple of the noise floor, so `5` @@ -89,6 +89,15 @@ showing one of them. Stepping is an ordinary edit: it can be undone, and a step past the highest value the current anchor allows is refused, with the reason in the status bar. +## Line width + +**Line width** sets how heavy contour strokes are printed, in points. Contours +start at 0.5 pt; drag or type any value from 0.05 to 10 pt, or take one from +**Presets** — *Fine* 0.50 pt, *Medium* 0.75 pt, *Bold* 1.25 pt. A thin stroke is +what keeps a dense ladder legible at journal size. It does not change which +features are drawn — that is the lowest level and **Levels** — only how heavy +each one looks. + ## Anchor and ladder Open **Advanced** for the rest of the ladder. @@ -113,8 +122,8 @@ Open **Advanced** for the rest of the ladder. - **Level ratio** — the factor between one level and the next. It must be greater than 1, and at most 10. -**Negative contours**, **Positive colour**, **Negative colour** and **Line -width** (0.05 to 10) are in the same section. +**Negative contours**, **Positive colour** and **Negative colour** are in the +same section. The ladder stops early when the next level would be above the data's peak, so a plot can show fewer levels than **Levels** asks for. That is not an error: the diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index dc3e4428..131a5a15 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -147,8 +147,10 @@ What you control directly: document rather than to a plot, the section is shown whatever is selected — including when nothing is. - **Line** in the Object inspector sets the **Stroke width** of the selected - plot's line series, from 0.05 to 10. The section appears only when something - in the selection is drawn as a line, and its header counts the series it is + plot's line series, in points. New line series are 0.5 pt; drag or type any + value from 0.05 to 10 pt, or take one from **Presets** — *Fine* 0.50 pt, + *Medium* 0.75 pt, *Bold* 1.25 pt. The section appears only when something in + the selection is drawn as a line, and its header counts the series it is about to change. Select several plots and it edits them together; select nothing and it follows the page's active plot. diff --git a/docs/src/content/docs/zh-cn/guides/automation.md b/docs/src/content/docs/zh-cn/guides/automation.md index efead0c1..6bbff861 100644 --- a/docs/src/content/docs/zh-cn/guides/automation.md +++ b/docs/src/content/docs/zh-cn/guides/automation.md @@ -46,8 +46,8 @@ selection** 载入当前选择——再选择一个工具,点击 **Preflight** | **Negative contours** | `series.contour.negative.enabled` | `true` 或 `false` | | **Positive colour** | `series.contour.positive_color` | `"#rrggbb"` | | **Negative colour** | `series.contour.negative_color` | `"#rrggbb"` | -| **Line width** | `series.contour.line_width` | 0.05 到 10 | -| **Stroke width**(**Line** 区域) | `series.line.stroke_width` | 0.05 到 10 | +| **Line width** | `series.contour.line_width` | 0.05 到 10 pt;默认 0.5 | +| **Stroke width**(**Line** 区域) | `series.line.stroke_width` | 0.05 到 10 pt;默认 0.5 | | **Tick-label size**(**Figure typography** 区域) | `document.figure.typography.tick_pt` | 1 到 72 | 应用偏好设置同样用这三个工具。对于 diff --git a/docs/src/content/docs/zh-cn/guides/contour-levels.md b/docs/src/content/docs/zh-cn/guides/contour-levels.md index 6e1007db..ad4c5ef6 100644 --- a/docs/src/content/docs/zh-cn/guides/contour-levels.md +++ b/docs/src/content/docs/zh-cn/guides/contour-levels.md @@ -14,9 +14,9 @@ description: 设置 2D 等高线图的最低层、层级阶梯与颜色。 ## 最低层 -**Lowest level**(最低层)是展开 **Advanced**(高级)之前唯一显示的设置,因为它 -决定了你能看到什么:低于它的一切都不会被绘制。调高它可以压掉噪声,调低它可以显出 -弱交叉峰。 +展开 **Advanced**(高级)之前显示的只有 **Lowest level**(最低层)和 +**Line width**(线宽)两行。最低层决定了你能看到什么:低于它的一切都不会被 +绘制。调高它可以压掉噪声,调低它可以显出弱交叉峰。 这个数字的含义取决于所选的锚定方式(见下)。相敏 NMR 平面的默认锚定是噪声下限的 倍数,因此 `5` 表示"从五倍噪声起画"。倍数本身并不能说明这一层究竟落在什么强度 @@ -71,6 +71,14 @@ threshold below 1800.*(正半区阈值 20000 高于该场的正向峰值 1800 编辑:可以撤销;若一步会越过当前锚定允许的最大值,该步会被拒绝,原因显示在 状态栏。 +## 线宽 + +**Line width**(线宽)决定等高线打印出来有多重,单位为 pt。等高线默认 0.5 pt; +可以拖动或输入 0.05 到 10 pt 之间的任意值,也可以从 **Presets**(预设)中取一个 +——*Fine* 0.50 pt、*Medium* 0.75 pt、*Bold* 1.25 pt。密集的阶梯要在期刊尺寸下仍 +然分得清,靠的就是够细的线。它不改变哪些特征会被绘制——那取决于最低层和 +**Levels**(层数)——只改变每条线看上去有多重。 + ## 锚定与阶梯 展开 **Advanced**(高级)可以看到阶梯的其余设置。 @@ -90,9 +98,8 @@ threshold below 1800.*(正半区阈值 20000 高于该场的正向峰值 1800 - **Levels**(层数)——阶梯共有多少层,取值 1 到 256。 - **Level ratio**(层间比值)——相邻两层之间的倍数,必须大于 1,且不超过 10。 -**Negative contours**(负等高线)、**Positive colour**(正等高线颜色)、 -**Negative colour**(负等高线颜色)和 **Line width**(线宽,0.05 到 10)也在同一 -分节中。 +**Negative contours**(负等高线)、**Positive colour**(正等高线颜色)和 +**Negative colour**(负等高线颜色)也在同一分节中。 当下一层会超过数据峰值时,阶梯会提前终止,因此图上实际显示的层数可能少于 **Levels** 所要求的。这不是错误:多出的层本来也画不出任何东西。 diff --git a/docs/src/content/docs/zh-cn/guides/layout-and-export.md b/docs/src/content/docs/zh-cn/guides/layout-and-export.md index 96a024da..98dde239 100644 --- a/docs/src/content/docs/zh-cn/guides/layout-and-export.md +++ b/docs/src/content/docs/zh-cn/guides/layout-and-export.md @@ -122,9 +122,11 @@ NMR 核素质量数。新数据集默认使用 89 × 60 mm 单栏画布:单个 是文档里的同一个值。它属于文档而不属于某个图,因此无论当前选中什么 (包括什么都没选中)都会显示。 - 对象检查器的 **Line** 区域中的 **Stroke width** 设定所选图中线条序列的 - 线宽,取值 0.05 到 10。只有当所选内容中有以线条绘制的序列时才会出现该 - 区域,标题处会数出即将改动的序列条数。选中多个图即一并编辑;什么都不选 - 时,作用于页面上的活动图。 + 线宽,单位为 pt;新线条序列默认为 0.5 pt。可以拖动或输入 0.05 到 10 pt + 之间的任意值,也可以从 **Presets**(预设)中取一个——*Fine* 0.50 pt、 + *Medium* 0.75 pt、*Bold* 1.25 pt。只有当所选内容中有以线条绘制的序列时才 + 会出现该区域,标题处会数出即将改动的序列条数。选中多个图即一并编辑;什么 + 都不选时,作用于页面上的活动图。 当所选序列的线宽并不一致时,控件显示破折号和 *mixed*,而不会把其中一 个值当成当前设置;此时写入一个值正是让它们重新一致的方式。所选序列中