Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions crates/app/src/ui/canvas/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ mod slices;
mod snap;
mod symmetry;
mod tiling;
mod view_fit;

pub(crate) use authoring::*;
pub(crate) use board::*;
Expand Down Expand Up @@ -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| {
Expand Down Expand Up @@ -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);
Expand Down
221 changes: 221 additions & 0 deletions crates/app/src/ui/canvas/view_fit.rs
Original file line number Diff line number Diff line change
@@ -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<EguiRect> {
ctx.data(|data| data.get_temp::<EguiRect>(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<Pos2> {
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));
}
}
10 changes: 10 additions & 0 deletions crates/app/src/ui/command_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 9 additions & 1 deletion crates/app/src/ui/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -68,6 +68,8 @@ pub enum CommandId {
ToggleSecondarySidebar,
ZoomToFit,
ZoomToSelection,
FitPlotY,
FitPlotXY,
UiScaleUp,
UiScaleDown,
UiScaleReset,
Expand Down Expand Up @@ -199,6 +201,8 @@ pub fn catalog(app: &PlotxApp) -> Vec<CommandDescriptor> {
CommandId::ToggleSecondarySidebar,
CommandId::ZoomToFit,
CommandId::ZoomToSelection,
CommandId::FitPlotY,
CommandId::FitPlotXY,
CommandId::UiScaleUp,
CommandId::UiScaleDown,
CommandId::UiScaleReset,
Expand Down Expand Up @@ -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.")
}
Expand Down
7 changes: 7 additions & 0 deletions crates/app/src/ui/commands/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
Expand Down
12 changes: 12 additions & 0 deletions crates/app/src/ui/commands/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions crates/app/src/ui/menus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ pub(crate) fn menu_bar_spec() -> Vec<(&'static str, Vec<MenuEntry>)> {
Separator,
Command(CommandId::ZoomToFit),
Command(CommandId::ZoomToSelection),
Command(CommandId::FitPlotY),
Command(CommandId::FitPlotXY),
Command(CommandId::Present),
Separator,
Command(CommandId::UiScaleUp),
Expand Down
1 change: 1 addition & 0 deletions crates/app/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading