diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 62802f0..0bd4bbc 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -62,6 +62,7 @@ mod slices; mod snap; mod symmetry; mod tiling; +mod view_fit; pub(crate) use authoring::*; pub(crate) use board::*; @@ -91,6 +92,7 @@ pub(crate) use slices::*; pub(crate) use snap::*; pub(crate) use symmetry::*; pub(crate) use tiling::*; +pub(crate) use view_fit::*; fn finite_rect_intersects(a: egui::Rect, b: egui::Rect) -> bool { let finite = |r: egui::Rect| { @@ -146,9 +148,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { let geometry = super::workspace_geometry(app, resp.rect, ui.ctx()); let rect = geometry.board_rect; let painter = painter.with_clip_rect(rect); - ui.ctx().data_mut(|data| { - data.insert_temp(egui::Id::new("plotx.canvas.navigation_rect"), rect); - }); + store_navigation_rect(ui.ctx(), rect); let chrome = ChromeStyle::from_visuals(ui.visuals(), app.settings.appearance.canvas_accent); consume_board_reveal(app, ui.ctx()); drive_board_fit(app, ui, &geometry); diff --git a/crates/app/src/ui/canvas/view_fit.rs b/crates/app/src/ui/canvas/view_fit.rs new file mode 100644 index 0000000..3939f73 --- /dev/null +++ b/crates/app/src/ui/canvas/view_fit.rs @@ -0,0 +1,221 @@ +//! Keyboard data-viewport fits. `H` fits the intensity axis to the data +//! visible inside the current x window (the NMR convention for a vertical +//! fit); `F` over a plot's data area fits both axes. Both are the keyboard +//! form of the double-click viewport resets in `navigation.rs` and commit +//! through the same undoable viewport action. + +use super::*; + +const NAVIGATION_RECT_ID: &str = "plotx.canvas.navigation_rect"; + +/// Which data-viewport axes a keyboard fit resets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PlotFitAxes { + /// Fit the y range to the data visible in the current x window and + /// re-enable automatic y scaling; the x window stays put. + Y, + /// Fit both axes to the full data range. + Both, +} + +/// Publish the board rectangle canvas navigation ran against this frame, so +/// keyboard commands can hit-test the pointer without re-deriving layout. +pub(crate) fn store_navigation_rect(ctx: &egui::Context, rect: EguiRect) { + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new(NAVIGATION_RECT_ID), rect); + }); +} + +fn navigation_rect(ctx: &egui::Context) -> Option { + ctx.data(|data| data.get_temp::(egui::Id::new(NAVIGATION_RECT_ID))) +} + +/// The pointer position, unless a floating task card sits under it — over a +/// card the pointer does not address the plot below (mirrors +/// `task_card::pointer_allows_canvas` for gesture dispatch). +fn uncovered_pointer(app: &PlotxApp, ctx: &egui::Context) -> Option { + let p = ctx.input(|input| input.pointer.hover_pos())?; + let covered = crate::ui::tools::task_card::visible_area_id(app) + .and_then(|id| ctx.memory(|memory| memory.area_rect(id))) + .is_some_and(|rect| rect.expand(6.0).contains(p)); + (!covered).then_some(p) +} + +/// Whether the plain `F` chord currently addresses a plot's data viewport: +/// the pointer rests on the data area of a plot on the active canvas. Outside +/// that context the chord keeps its board meaning (Zoom to Selection). +pub(crate) fn pointer_in_plot_data(app: &PlotxApp, ctx: &egui::Context) -> bool { + let Some(ci) = app.session.active_canvas else { + return false; + }; + let Some(rect) = navigation_rect(ctx) else { + return false; + }; + let Some(p) = uncovered_pointer(app, ctx).filter(|p| rect.contains(*p)) else { + return false; + }; + plot_under_cursor(app, ci, rect, p) + .is_some_and(|(_, outer, plot)| hit_zone(p, outer, plot) == HitZone::Plot) +} + +/// The plot a keyboard fit addresses: the plot under the pointer when there is +/// one, otherwise the active plot object — so the palette (no meaningful +/// pointer) still acts on the plot the user is working with. +fn fit_target(app: &PlotxApp, ctx: &egui::Context) -> Option<(usize, ObjectId)> { + let ci = app.session.active_canvas?; + let pointed = navigation_rect(ctx) + .zip(uncovered_pointer(app, ctx)) + .filter(|(rect, p)| rect.contains(*p)) + .and_then(|(rect, p)| plot_under_cursor(app, ci, rect, p)) + .map(|(id, _, _)| id); + pointed + .or_else(|| app.doc.canvases.get(ci)?.active_plot_object_id()) + .map(|id| (ci, id)) +} + +/// Reset the target plot's data viewport on the requested axes as one +/// undoable step. Returns whether a plot was fitted. +pub(crate) fn fit_plot_viewport( + app: &mut PlotxApp, + ctx: &egui::Context, + axes: PlotFitAxes, +) -> bool { + let Some((ci, object_id)) = fit_target(app, ctx) else { + return false; + }; + let Some(plot_object) = app.doc.canvases[ci] + .object(object_id) + .and_then(|object| object.plot()) + else { + return false; + }; + let before = plot_object.viewport.clone(); + let mut after = before.clone(); + match axes { + PlotFitAxes::Y => after.reset_y(plot_object.figure()), + PlotFitAxes::Both => after.reset_all(), + } + app.commit_object_viewport(ci, object_id, before, after); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use plotx_core::state::{CanvasViewport, PlotObject}; + use plotx_figure::{Axis, Figure, Series}; + + const PLOT_ID: ObjectId = ObjectId::new(1); + + /// One line plot whose trace is small inside x = 2..8 and spikes outside, + /// so a window-scoped intensity fit is distinguishable from a full fit. + fn line_plot_app() -> PlotxApp { + let mut app = PlotxApp::new(); + let mut canvas = CanvasDocument::new("page".to_owned(), [200.0, 120.0]); + let mut figure = Figure::new( + "plot", + Axis::new("x", 0.0, 10.0), + Axis::new("y", -1.0, 100.0), + ); + figure.series.push(Series::line( + "trace", + vec![ + [0.0, 100.0], + [1.0, 90.0], + [3.0, 1.0], + [5.0, 2.0], + [7.0, 3.0], + [9.0, 80.0], + [10.0, 100.0], + ], + )); + let viewport = CanvasViewport { + full_x: AxisRange::new(0.0, 10.0), + full_y: AxisRange::new(-1.0, 100.0), + view_x: AxisRange::new(2.0, 8.0), + view_y: AxisRange::new(-50.0, 50.0), + auto_y: false, + }; + viewport.apply_to(&mut figure); + canvas.objects.push(CanvasObject { + id: PLOT_ID, + name: "Plot".to_owned(), + frame: ObjectFrame::new(10.0, 10.0, 180.0, 100.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Plot(Box::new(PlotObject::new( + None, + plotx_core::state::SeriesId::new(1), + plotx_core::state::DataBinding { series: Vec::new() }, + plotx_core::state::ChartSpec::default(), + plotx_core::state::StackSpec::default(), + plotx_core::state::AxisProjections::default(), + plotx_core::state::AxisOverrides::default(), + figure, + viewport, + ))), + }); + app.doc.canvases.push(canvas); + app.session.active_canvas = Some(0); + app + } + + fn viewport(app: &PlotxApp) -> CanvasViewport { + app.doc.canvases[0] + .object(PLOT_ID) + .and_then(|object| object.plot()) + .expect("fixture plot") + .viewport + .clone() + } + + #[test] + fn y_fit_scales_to_the_data_visible_in_the_current_x_window() { + let mut app = line_plot_app(); + let ctx = egui::Context::default(); + + assert!(fit_plot_viewport(&mut app, &ctx, PlotFitAxes::Y)); + + let fitted = viewport(&app); + assert_eq!(fitted.view_x, AxisRange::new(2.0, 8.0)); + assert!(fitted.auto_y); + // Only the points at x = 3, 5, 7 (y = 1..3) are inside the window; the + // fitted y range is that span plus the auto-fit padding, far below the + // out-of-window spikes. + assert!((fitted.view_y.min - 0.9).abs() < 1e-9); + assert!((fitted.view_y.max - 3.16).abs() < 1e-9); + + app.undo(); + let restored = viewport(&app); + assert_eq!(restored.view_y, AxisRange::new(-50.0, 50.0)); + assert!(!restored.auto_y); + } + + #[test] + fn both_axes_fit_resets_the_full_data_range_undoably() { + let mut app = line_plot_app(); + let ctx = egui::Context::default(); + + assert!(fit_plot_viewport(&mut app, &ctx, PlotFitAxes::Both)); + + let fitted = viewport(&app); + assert_eq!(fitted.view_x, AxisRange::new(0.0, 10.0)); + assert_eq!(fitted.view_y, AxisRange::new(-1.0, 100.0)); + assert!(fitted.auto_y); + + app.undo(); + assert_eq!(viewport(&app).view_x, AxisRange::new(2.0, 8.0)); + } + + #[test] + fn fit_without_a_plot_reports_no_target() { + let mut app = PlotxApp::new(); + app.doc + .canvases + .push(CanvasDocument::new("empty".to_owned(), [100.0, 80.0])); + app.session.active_canvas = Some(0); + let ctx = egui::Context::default(); + + assert!(!fit_plot_viewport(&mut app, &ctx, PlotFitAxes::Both)); + } +} diff --git a/crates/app/src/ui/command_exec.rs b/crates/app/src/ui/command_exec.rs index b53ca70..b20fe3e 100644 --- a/crates/app/src/ui/command_exec.rs +++ b/crates/app/src/ui/command_exec.rs @@ -133,6 +133,16 @@ fn execute_inner( _ => format!("Fit {count} selected frames to view."), }; } + CommandId::FitPlotY => { + if super::canvas::fit_plot_viewport(app, ctx, super::canvas::PlotFitAxes::Y) { + app.session.status = "Fit intensity to the visible window.".into(); + } + } + CommandId::FitPlotXY => { + if super::canvas::fit_plot_viewport(app, ctx, super::canvas::PlotFitAxes::Both) { + app.session.status = "Fit plot to the full data range.".into(); + } + } CommandId::UiScaleUp => crate::scale::nudge_ui_zoom(app, ctx, 1), CommandId::UiScaleDown => crate::scale::nudge_ui_zoom(app, ctx, -1), CommandId::UiScaleReset => crate::scale::reset_ui_zoom(app, ctx), diff --git a/crates/app/src/ui/commands.rs b/crates/app/src/ui/commands.rs index 6b68f0d..2bb1266 100644 --- a/crates/app/src/ui/commands.rs +++ b/crates/app/src/ui/commands.rs @@ -13,7 +13,7 @@ use identity::command_identity; pub(crate) use identity::recent_entry_label; mod helpers; pub(super) use helpers::chart_plot_target; -use helpers::{requires, selected_paths_unlocked, tool_commands}; +use helpers::{has_active_plot, requires, selected_paths_unlocked, tool_commands}; mod ribbon; use ribbon::ribbon_placement; pub use ribbon::{Applicability, RibbonPlacement}; @@ -68,6 +68,8 @@ pub enum CommandId { ToggleSecondarySidebar, ZoomToFit, ZoomToSelection, + FitPlotY, + FitPlotXY, UiScaleUp, UiScaleDown, UiScaleReset, @@ -199,6 +201,8 @@ pub fn catalog(app: &PlotxApp) -> Vec { CommandId::ToggleSecondarySidebar, CommandId::ZoomToFit, CommandId::ZoomToSelection, + CommandId::FitPlotY, + CommandId::FitPlotXY, CommandId::UiScaleUp, CommandId::UiScaleDown, CommandId::UiScaleReset, @@ -497,6 +501,10 @@ pub fn describe(app: &PlotxApp, id: CommandId) -> CommandDescriptor { "Create a panel before renumbering panel labels.", ), CommandId::ZoomToFit => requires(has_canvas, "Open a canvas before zooming to fit."), + CommandId::FitPlotY | CommandId::FitPlotXY => requires( + has_active_plot(app), + "Plot a dataset on the canvas before fitting its data view.", + ), CommandId::ZoomToSelection => { requires(has_canvas, "Open a canvas before zooming to the selection.") } diff --git a/crates/app/src/ui/commands/helpers.rs b/crates/app/src/ui/commands/helpers.rs index e5a826d..cec4e3d 100644 --- a/crates/app/src/ui/commands/helpers.rs +++ b/crates/app/src/ui/commands/helpers.rs @@ -32,6 +32,13 @@ impl CommandId { } } +pub(super) fn has_active_plot(app: &PlotxApp) -> bool { + app.session + .active_canvas + .and_then(|ci| app.doc.canvases.get(ci)) + .is_some_and(|canvas| canvas.active_plot_object_id().is_some()) +} + pub(super) fn requires(ok: bool, reason: &'static str) -> Result<(), &'static str> { if ok { Ok(()) } else { Err(reason) } } diff --git a/crates/app/src/ui/commands/identity.rs b/crates/app/src/ui/commands/identity.rs index 23400b0..ebc9e58 100644 --- a/crates/app/src/ui/commands/identity.rs +++ b/crates/app/src/ui/commands/identity.rs @@ -132,6 +132,16 @@ pub(super) fn command_identity( ), CommandId::ZoomToFit => ("Zoom to Fit".into(), Some(icon::ARROWS_OUT), None), CommandId::ZoomToSelection => ("Zoom to Selection".into(), None, None), + CommandId::FitPlotY => ( + "Fit Plot Vertically".into(), + Some(icon::ARROWS_VERTICAL), + None, + ), + CommandId::FitPlotXY => ( + "Fit Plot to Data".into(), + Some(icon::ARROWS_OUT_SIMPLE), + None, + ), CommandId::UiScaleUp => ( ui_scale_label(app, "Increase UI Scale"), Some(icon::MAGNIFYING_GLASS_PLUS), @@ -421,6 +431,8 @@ fn simple_stable_id(id: CommandId) -> &'static str { CommandId::ToggleSecondarySidebar => "view.secondary_sidebar", CommandId::ZoomToFit => "view.zoom_fit", CommandId::ZoomToSelection => "view.zoom_selection", + CommandId::FitPlotY => "view.fit_plot_y", + CommandId::FitPlotXY => "view.fit_plot_xy", CommandId::UiScaleUp => "view.ui_scale_up", CommandId::UiScaleDown => "view.ui_scale_down", CommandId::UiScaleReset => "view.ui_scale_reset", diff --git a/crates/app/src/ui/menus.rs b/crates/app/src/ui/menus.rs index 10f78a9..bac22ff 100644 --- a/crates/app/src/ui/menus.rs +++ b/crates/app/src/ui/menus.rs @@ -129,6 +129,8 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec)> { Separator, Command(CommandId::ZoomToFit), Command(CommandId::ZoomToSelection), + Command(CommandId::FitPlotY), + Command(CommandId::FitPlotXY), Command(CommandId::Present), Separator, Command(CommandId::UiScaleUp), diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 61808ab..cf61020 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -116,6 +116,7 @@ pub fn render( || batch_workflow.is_open(); if !modal_open { primary_sidebar::selection::handle_keyboard_selection(app, &ctx); + handle_plot_fit_shortcut(app, clipboard_table_paste, &ctx); handle_command_shortcuts(app, clipboard_table_paste, &ctx); handle_escape_shortcut(app, &ctx); handle_rename_shortcut(app, &ctx); diff --git a/crates/app/src/ui/shortcuts.rs b/crates/app/src/ui/shortcuts.rs index 0cc2218..18dafd7 100644 --- a/crates/app/src/ui/shortcuts.rs +++ b/crates/app/src/ui/shortcuts.rs @@ -141,10 +141,28 @@ static BINDINGS: &[CommandBinding] = &[ dispatch: false, menu_accelerator: true, }, + // Plain `F` is context-split by `handle_plot_fit_shortcut`: over a plot's + // data area it runs FitPlotXY, elsewhere ZoomToSelection. Both bindings + // stay in the table so labels derive normally, but neither dispatches + // through the chord table. CommandBinding { id: commands::CommandId::ZoomToSelection, primary: plain(egui::Key::F), aliases: &[], + dispatch: false, + menu_accelerator: false, + }, + CommandBinding { + id: commands::CommandId::FitPlotXY, + primary: plain(egui::Key::F), + aliases: &[], + dispatch: false, + menu_accelerator: false, + }, + CommandBinding { + id: commands::CommandId::FitPlotY, + primary: plain(egui::Key::H), + aliases: &[], dispatch: true, menu_accelerator: false, }, @@ -251,6 +269,30 @@ pub(super) fn handle_palette_shortcut( } } +/// Sole owner of the plain `F` chord, which is context-split: with the pointer +/// on a plot's data area it fits that plot's data viewport on both axes, and +/// everywhere else it keeps its original board meaning, Zoom to Selection. The +/// split lives in this focused handler instead of the dispatch table so both +/// commands keep their own identity, gating and palette entries. +pub(super) fn handle_plot_fit_shortcut( + app: &mut PlotxApp, + clipboard: &mut clipboard_table::ClipboardTablePaste, + ctx: &egui::Context, +) { + if ctx.egui_wants_keyboard_input() { + return; + } + if !ctx.input(|i| chord_pressed(i, plain(egui::Key::F))) { + return; + } + let id = if canvas::pointer_in_plot_data(app, ctx) { + commands::CommandId::FitPlotXY + } else { + commands::CommandId::ZoomToSelection + }; + commands::execute(id, app, clipboard, ctx); +} + /// Route global bindings through the same command dispatcher used by menus, /// the Ribbon and the command palette. Direct-manipulation-only keys remain in /// their focused handlers below. @@ -602,121 +644,5 @@ pub(super) fn handle_delete_shortcut(app: &mut PlotxApp, ctx: &egui::Context) { } #[cfg(test)] -mod tests { - use super::*; - - /// Two dispatchable bindings must never share an effective chord. The - /// matcher ignores Shift for plain keys, so those normalize shift away. - #[test] - fn dispatchable_chords_are_unambiguous() { - let mut seen = std::collections::HashSet::new(); - for binding in BINDINGS.iter().filter(|binding| binding.dispatch) { - for chord in std::iter::once(binding.primary).chain(binding.aliases.iter().copied()) { - assert!( - seen.insert((chord.command, chord.command && chord.shift, chord.key)), - "chord {chord:?} bound twice" - ); - } - } - } - - #[test] - fn labels_derive_from_the_binding_table() { - let label = shortcut_label(commands::CommandId::SaveProject).unwrap(); - assert!(label.ends_with("+S")); - assert!( - shortcut_label(commands::CommandId::PasteImage) - .is_some_and(|label| label.ends_with("+V")) - ); - assert_eq!( - shortcut_label(commands::CommandId::Tool(Tool::Select)).as_deref(), - Some("V") - ); - assert_eq!( - shortcut_label(commands::CommandId::CycleCursor).as_deref(), - Some("C") - ); - assert!(shortcut_label(commands::CommandId::Tool(Tool::Symmetry)).is_none()); - assert!(shortcut_label(commands::CommandId::About).is_none()); - } - - fn paste_key_event() -> egui::Event { - egui::Event::Key { - key: egui::Key::V, - physical_key: Some(egui::Key::V), - pressed: true, - repeat: false, - modifiers: egui::Modifiers::CTRL, - } - } - - #[test] - fn restored_ctrl_v_and_platform_paste_events_route_to_paste_image() { - for event in [ - paste_key_event(), - egui::Event::Paste("clipboard".to_owned()), - ] { - let ctx = egui::Context::default(); - let input = egui::RawInput { - events: vec![event], - modifiers: egui::Modifiers::CTRL, - ..Default::default() - }; - let mut command = None; - let _ = ctx.run_ui(input, |ui| command = shortcut_command(ui.ctx())); - assert_eq!(command, Some(commands::CommandId::PasteImage)); - } - } - - #[test] - fn focused_text_edit_keeps_ctrl_v_for_text_paste() { - let ctx = egui::Context::default(); - let mut text = String::new(); - let _ = ctx.run_ui(egui::RawInput::default(), |ui| { - ui.add(egui::TextEdit::singleline(&mut text)) - .request_focus(); - }); - let input = egui::RawInput { - events: vec![egui::Event::Paste("text".to_owned())], - modifiers: egui::Modifiers::CTRL, - ..Default::default() - }; - let mut command = None; - let _ = ctx.run_ui(input, |ui| { - command = shortcut_command(ui.ctx()); - ui.add(egui::TextEdit::singleline(&mut text)); - }); - assert_eq!(command, None); - assert_eq!(text, "text"); - } - - #[test] - fn escape_exits_an_active_tool_after_other_fallbacks() { - let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); - app.set_tool(Tool::Integrate); - - handle_escape(&mut app, 0.0); - - assert_eq!(app.session.tool, Tool::BrowseZoom); - assert_eq!(app.session.status, "Exited tool mode."); - } - - #[test] - fn escape_finishes_a_pending_wheel_property_gesture() { - let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); - app.session.ui.wheel_property = Some(plotx_core::actions::PendingWheelPropertyEdit { - canvas: 0, - object: plotx_core::state::ObjectId::new(1), - property: plotx_core::properties::contour::BASE_MAGNITUDE, - targets: Vec::new(), - accumulator: 0.0, - last_input_time: 0.0, - gesture_started: false, - }); - - handle_escape(&mut app, 1.0); - - assert!(app.session.ui.wheel_property.is_none()); - assert_eq!(app.session.status, "Cancelled interaction."); - } -} +#[path = "shortcuts_tests.rs"] +mod tests; diff --git a/crates/app/src/ui/shortcuts_tests.rs b/crates/app/src/ui/shortcuts_tests.rs new file mode 100644 index 0000000..07e0e7a --- /dev/null +++ b/crates/app/src/ui/shortcuts_tests.rs @@ -0,0 +1,189 @@ +use super::*; + +/// Two dispatchable bindings must never share an effective chord. The +/// matcher ignores Shift for plain keys, so those normalize shift away. +#[test] +fn dispatchable_chords_are_unambiguous() { + let mut seen = std::collections::HashSet::new(); + for binding in BINDINGS.iter().filter(|binding| binding.dispatch) { + for chord in std::iter::once(binding.primary).chain(binding.aliases.iter().copied()) { + assert!( + seen.insert((chord.command, chord.command && chord.shift, chord.key)), + "chord {chord:?} bound twice" + ); + } + } +} + +#[test] +fn labels_derive_from_the_binding_table() { + let label = shortcut_label(commands::CommandId::SaveProject).unwrap(); + assert!(label.ends_with("+S")); + assert!( + shortcut_label(commands::CommandId::PasteImage).is_some_and(|label| label.ends_with("+V")) + ); + assert_eq!( + shortcut_label(commands::CommandId::Tool(Tool::Select)).as_deref(), + Some("V") + ); + assert_eq!( + shortcut_label(commands::CommandId::CycleCursor).as_deref(), + Some("C") + ); + assert!(shortcut_label(commands::CommandId::Tool(Tool::Symmetry)).is_none()); + assert!(shortcut_label(commands::CommandId::About).is_none()); +} + +fn paste_key_event() -> egui::Event { + egui::Event::Key { + key: egui::Key::V, + physical_key: Some(egui::Key::V), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::CTRL, + } +} + +#[test] +fn restored_ctrl_v_and_platform_paste_events_route_to_paste_image() { + for event in [ + paste_key_event(), + egui::Event::Paste("clipboard".to_owned()), + ] { + let ctx = egui::Context::default(); + let input = egui::RawInput { + events: vec![event], + modifiers: egui::Modifiers::CTRL, + ..Default::default() + }; + let mut command = None; + let _ = ctx.run_ui(input, |ui| command = shortcut_command(ui.ctx())); + assert_eq!(command, Some(commands::CommandId::PasteImage)); + } +} + +#[test] +fn focused_text_edit_keeps_ctrl_v_for_text_paste() { + let ctx = egui::Context::default(); + let mut text = String::new(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + ui.add(egui::TextEdit::singleline(&mut text)) + .request_focus(); + }); + let input = egui::RawInput { + events: vec![egui::Event::Paste("text".to_owned())], + modifiers: egui::Modifiers::CTRL, + ..Default::default() + }; + let mut command = None; + let _ = ctx.run_ui(input, |ui| { + command = shortcut_command(ui.ctx()); + ui.add(egui::TextEdit::singleline(&mut text)); + }); + assert_eq!(command, None); + assert_eq!(text, "text"); +} + +fn f_key_event() -> egui::Event { + egui::Event::Key { + key: egui::Key::F, + physical_key: Some(egui::Key::F), + pressed: true, + repeat: false, + modifiers: egui::Modifiers::default(), + } +} + +/// Plain `F` is context-split: over a plot's data area it fits that plot's +/// data viewport, elsewhere it keeps the board Zoom-to-Selection meaning. +#[test] +fn plain_f_fits_the_plot_under_the_pointer_and_the_board_otherwise() { + let (mut app, ids) = crate::ui::properties::fixture::contour_page(1); + app.session.board = plotx_core::state::BoardViewport { + zoom: 1.0, + world_center: [500.0, 400.0], + }; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 800.0)); + let plot = canvas::plot_inner_rect(&app, 0, ids[0], screen).expect("plot on the board"); + let inside = egui::Pos2::new( + (plot.left + plot.right()) * 0.5, + (plot.top + plot.bottom()) * 0.5, + ); + + // Zoom the data viewport away from the full range first. + let plot_object = app.doc.canvases[0] + .object_mut(ids[0]) + .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 = plotx_core::state::AxisRange::new( + full_x.min + full_x.span() * 0.25, + full_x.max - full_x.span() * 0.25, + ); + plot_object.apply_viewport(); + + let ctx = egui::Context::default(); + let mut clipboard = clipboard_table::ClipboardTablePaste::default(); + let mut frame = |app: &mut PlotxApp, pointer: egui::Pos2| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(screen), + events: vec![egui::Event::PointerMoved(pointer), f_key_event()], + ..Default::default() + }, + |ui| { + canvas::store_navigation_rect(ui.ctx(), screen); + handle_plot_fit_shortcut(app, &mut clipboard, ui.ctx()); + }, + ); + }; + + frame(&mut app, inside); + let viewport = app.doc.canvases[0] + .object(ids[0]) + .and_then(|object| object.plot()) + .expect("fixture plot") + .viewport + .clone(); + assert_eq!(viewport.view_x, full_x); + assert_eq!(viewport.view_y, full_y); + assert_eq!(app.session.status, "Fit plot to the full data range."); + + // Outside any plot the chord still fits the board to the selection. + frame(&mut app, egui::Pos2::new(5.0, 5.0)); + assert!(matches!( + app.session.viewport_mode, + plotx_core::state::ViewportMode::Fit(_) + )); +} + +#[test] +fn escape_exits_an_active_tool_after_other_fallbacks() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + app.set_tool(Tool::Integrate); + + handle_escape(&mut app, 0.0); + + assert_eq!(app.session.tool, Tool::BrowseZoom); + assert_eq!(app.session.status, "Exited tool mode."); +} + +#[test] +fn escape_finishes_a_pending_wheel_property_gesture() { + let mut app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); + app.session.ui.wheel_property = Some(plotx_core::actions::PendingWheelPropertyEdit { + canvas: 0, + object: plotx_core::state::ObjectId::new(1), + property: plotx_core::properties::contour::BASE_MAGNITUDE, + targets: Vec::new(), + accumulator: 0.0, + last_input_time: 0.0, + gesture_started: false, + }); + + handle_escape(&mut app, 1.0); + + assert!(app.session.ui.wheel_property.is_none()); + assert_eq!(app.session.status, "Cancelled interaction."); +} diff --git a/docs/src/content/docs/reference/shortcuts.md b/docs/src/content/docs/reference/shortcuts.md index 6a1b652..470caed 100644 --- a/docs/src/content/docs/reference/shortcuts.md +++ b/docs/src/content/docs/reference/shortcuts.md @@ -55,9 +55,16 @@ the cursor, or on the board when the cursor is over empty space. | Drag on an axis strip | Select a range on that axis to zoom into | | Double-click a plot | Reset both axes to full range | | Double-click an axis strip | Reset that axis only | -| `F` | Zoom the board to fit the selected frames (everything when nothing is selected) | +| `H` | Fit the y axis to the data visible in the current x window (the x window stays put) | +| `F` over a plot body | Fit both axes of that plot to the full data range | +| `F` elsewhere | Zoom the board to fit the selected frames (everything when nothing is selected) | | `Enter` | Zoom the board to the selected page or sheet | +`H` acts on the plot under the pointer, or on the active plot when the pointer +is elsewhere — the vertical-fit convention NMR software users expect. Both fits +are single undoable steps, and both are available from the command palette as +**Fit Plot Vertically** and **Fit Plot to Data**. + Hovering a plot body or an axis strip outlines the area the wheel will act on and names the action in its top-left corner, including which setting `Alt` + scroll wheel would change and on how many series. Where one plot draws two diff --git a/docs/src/content/docs/zh-cn/reference/shortcuts.md b/docs/src/content/docs/zh-cn/reference/shortcuts.md index 2f224b9..d32ef9a 100644 --- a/docs/src/content/docs/zh-cn/reference/shortcuts.md +++ b/docs/src/content/docs/zh-cn/reference/shortcuts.md @@ -52,9 +52,15 @@ description: 键盘与鼠标快捷操作。 | 在坐标轴带上拖动 | 框选该轴的范围并缩放至所选区间 | | 双击图内 | 双轴恢复完整范围 | | 双击坐标轴带 | 仅恢复该轴 | -| `F` | 缩放画板以适配所选图框(未选中时适配全部) | +| `H` | 按当前 X 窗口内可见数据的强度范围适配 Y 轴(X 窗口保持不变) | +| `F`(指针在图内) | 该图双轴适配到完整数据范围 | +| `F`(指针在其它位置) | 缩放画板以适配所选图框(未选中时适配全部) | | `Enter` | 缩放画板至所选页面或工作表 | +`H` 作用于指针所在的图;指针不在任何图内时作用于当前活动的图——这正是 NMR 软件 +用户熟悉的纵向适配习惯。两种适配都是单个可撤销步骤,也都能在命令面板中找到 +(**Fit Plot Vertically** 与 **Fit Plot to Data**)。 + 光标悬停在图内或坐标轴带上时,PlotX 会勾出滚轮将要作用的区域,并在其左上角写明 操作,包括 `Alt` + 鼠标滚轮会改哪一项设置、涉及多少条谱线。若同一幅图画了两个各自带 显示参数的图层(例如等高线覆盖在热图上),`Alt` + 鼠标滚轮不会去猜你指的是哪一层,