From 6a3f91dd1e3d46deee8451433d7f37ae17cb5b18 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Mon, 27 Jul 2026 16:52:25 +0800 Subject: [PATCH] feat(app): give data lines a fine default width and presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spectra, line series and contours each carried their own authored stroke width — 1.0, 1.0 and 0.7 pt — so a plot's strokes were both inconsistent and, on dense 2D data, thick enough to close up the gaps between contour rings. One shared DEFAULT_DATA_LINE_WIDTH_PT of 0.5 pt now backs every data stroke. Width is also a setting users reach for, not one they should have to go hunting for. Contour line width moves from Advanced to Essential, both width rows declare their unit as pt and a 0.05 pt drag notch, and each offers Fine, Medium and Bold presets alongside the drag field. Separately, double-clicking a plot to reset its axes did nothing under Browse Zoom, Alt+drag or on an axis strip: the second press began a zero-distance zoom whose release the zoom-completion path consumed. The reset is now handled on that release first, so it matches the behavior the shortcut reference already documents. Marker radius, error bars and the integral overlay keep their own widths; the overlay stays at 1.0 pt to read against the finer trace. --- crates/app/src/ui/canvas/navigation.rs | 32 ++- crates/app/src/ui/canvas/navigation_tests.rs | 261 ++++++++++++++++++ crates/app/src/ui/properties/control.rs | 198 ++++--------- crates/app/src/ui/properties/control_tests.rs | 147 ++++++++++ crates/app/src/ui/properties/tests.rs | 14 +- crates/core/src/properties/contour.rs | 7 +- crates/core/src/properties/line.rs | 4 +- crates/core/src/properties/provider_tests.rs | 20 +- crates/core/src/state/app_impl.rs | 4 +- crates/figure/src/encoding.rs | 21 +- crates/figure/src/lib.rs | 6 +- docs/src/content/docs/guides/automation.md | 4 +- .../src/content/docs/guides/contour-levels.md | 19 +- .../content/docs/guides/layout-and-export.md | 6 +- .../content/docs/zh-cn/guides/automation.md | 4 +- .../docs/zh-cn/guides/contour-levels.md | 19 +- .../docs/zh-cn/guides/layout-and-export.md | 8 +- 17 files changed, 588 insertions(+), 186 deletions(-) create mode 100644 crates/app/src/ui/canvas/navigation_tests.rs create mode 100644 crates/app/src/ui/properties/control_tests.rs 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*,而不会把其中一 个值当成当前设置;此时写入一个值正是让它们重新一致的方式。所选序列中