Skip to content
Merged
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
3 changes: 2 additions & 1 deletion crates/app/src/ui/canvas/geometry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@ pub(crate) fn clear_canvas_interaction_state(
ci: usize,
scope: CanvasInteractionClearScope,
) {
app.finish_pending_wheel_zoom(f64::INFINITY, true);
app.finish_pending_wheel_property(f64::INFINITY, true);
app.reset_interaction();
app.session.ui.wheel_zoom = None;

if matches!(
scope,
Expand Down
3 changes: 2 additions & 1 deletion crates/app/src/ui/canvas/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use egui::{Color32, Pos2, Rect as EguiRect, Sense, Stroke, StrokeKind, Ui, Vec2};
use plotx_core::actions::{Action, PendingViewportEdit};
use plotx_core::actions::{Action, PendingViewportEdit, PendingWheelPropertyEdit};
use plotx_core::layout::{self, MovableEdges, SnapGuide, SnapTargets};
use plotx_core::state::region_color;
use plotx_core::state::{
Expand Down Expand Up @@ -232,6 +232,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) {
paint_panel_label_selection(app, ci, rect, &painter, chrome);
paint_object_selection(app, ci, rect, page, &painter, chrome);
paint_property_readouts(app, ci, rect, &painter, chrome, ui.visuals().dark_mode);
paint_wheel_target_hint(app, ci, rect, ui, &painter, chrome, ui.visuals().dark_mode);
paint_tile_ghost(app, &painter, chrome);
paint_tile_preview(app, rect, &painter, chrome);
super::canvas_size::page_size_chrome(app, ci, page, rect, ui);
Expand Down
303 changes: 270 additions & 33 deletions crates/app/src/ui/canvas/navigation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,23 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect,
i.pointer.button_released(egui::PointerButton::Middle),
)
});
let (command, space_down, shift, alt, now) = ui.input(|i| {
let (command, space_down, alt, now) = ui.input(|i| {
(
i.modifiers.command || i.modifiers.ctrl,
i.key_down(egui::Key::Space),
i.modifiers.shift,
i.modifiers.alt,
i.time,
)
});
let typing = ui.ctx().egui_wants_keyboard_input();

// A single-axis strip zoom is owned here start-to-finish so it rides under
// any tool: track its band and commit on release, even off the canvas.
let axis_zoom = match &app.session.ui.interaction {
Interaction::Zoom(d) if d.axis != ZoomAxis::Box => Some(*d),
// 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 {
Interaction::Zoom(d) => Some(*d),
_ => None,
};
if let Some(drag) = axis_zoom {
if let Some(drag) = active_zoom {
if let Some(pp) = hover
&& let Interaction::Zoom(d) = &mut app.session.ui.interaction
{
Expand All @@ -59,7 +58,11 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect,
if let Some(plot) = plot_inner_rect(app, drag.canvas, drag.object, rect)
&& let Interaction::Zoom(d) = app.take_interaction()
{
finish_axis_zoom(app, drag.canvas, drag.object, plot, d);
if d.axis == ZoomAxis::Box {
finish_zoom_drag(app, drag.canvas, drag.object, plot, d);
} else {
finish_axis_zoom(app, drag.canvas, drag.object, plot, d);
}
} else {
app.reset_interaction();
}
Expand Down Expand Up @@ -116,17 +119,28 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect,
let pinch = (zoom_delta - 1.0).abs() > 0.001;
let wheel = scroll.y.abs() > 0.0;
if !typing && (pinch || wheel) {
match data_target {
let consumed = match data_target {
Some((id, outer, plot)) => {
let scale = if pinch {
(1.0 / f64::from(zoom_delta)).clamp(0.2, 5.0)
if pinch {
app.finish_pending_wheel_property(now, true);
let scale = (1.0 / f64::from(zoom_delta)).clamp(0.2, 5.0);
app.session.board_fit = None;
zoom_plot_viewport(app, ci, id, outer, plot, p, scale, (true, true), now, ui);
true
} else if alt && hit_zone(p, outer, plot) == HitZone::Plot {
app.finish_pending_wheel_zoom(now, true);
adjust_plot_display(app, ci, id, plot, p, scroll.y, now, ui)
} else {
f64::from((-scroll.y * WHEEL_ZOOM_SPEED).exp()).clamp(0.2, 5.0)
};
app.session.board_fit = None;
zoom_plot_viewport(
app, ci, id, outer, plot, p, scale, pinch, shift, alt, now, ui,
);
app.finish_pending_wheel_property(now, true);
let axes = wheel_zoom_axes(app, ci, id, hit_zone(p, outer, plot));
let Some(axes) = axes else {
return false;
};
let scale = f64::from((-scroll.y * WHEEL_ZOOM_SPEED).exp()).clamp(0.2, 5.0);
app.session.board_fit = None;
zoom_plot_viewport(app, ci, id, outer, plot, p, scale, axes, now, ui);
true
}
}
None => {
let factor = if pinch {
Expand All @@ -137,9 +151,10 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect,
app.session.board_fit = None;
zoom_board_view(app, rect, p, factor);
ui.ctx().request_repaint();
true
}
}
return true;
};
return consumed;
}

if !typing
Expand All @@ -151,6 +166,7 @@ pub(crate) fn handle_navigation(app: &mut PlotxApp, ci: usize, rect: egui::Rect,
let axis = match hit_zone(p, outer, plot) {
HitZone::XAxis => Some(ZoomAxis::X),
HitZone::YAxis => Some(ZoomAxis::Y),
HitZone::Plot if alt => Some(ZoomAxis::Box),
HitZone::Plot | HitZone::None => None,
};
if let Some(axis) = axis {
Expand Down Expand Up @@ -330,31 +346,252 @@ pub(crate) fn reset_plot_viewport(
app.commit_object_viewport(ci, object_id, before, after);
}

/// Zoom a plot's data viewport around the cursor. The axis is chosen by hit zone
/// and modifiers: over the body Shift constrains to x and Alt to y; over an axis
/// strip only that axis. Coalesces into one undo step via the pending wheel edit.
/// The axes a plain wheel gesture addresses. A line plot is conventionally
/// navigated along its independent x coordinate; a raster-like field has two
/// spatial coordinates and navigates both. Axis strips always override that
/// body convention with the one axis they explicitly name.
fn wheel_zoom_axes(
app: &PlotxApp,
canvas: usize,
object: ObjectId,
zone: HitZone,
) -> Option<(bool, bool)> {
match zone {
HitZone::XAxis => Some((true, false)),
HitZone::YAxis => Some((false, true)),
HitZone::Plot => {
let two_dimensional = app.doc.canvases[canvas]
.object(object)
.and_then(|object| object.plot())
.is_some_and(|plot| {
plot.binding.series.iter().any(|series| {
matches!(
&series.encoding,
plotx_figure::SeriesEncoding::Contour(_)
| plotx_figure::SeriesEncoding::Heatmap(_)
| plotx_figure::SeriesEncoding::Image(_)
)
})
});
Some((true, two_dimensional))
}
HitZone::None => None,
}
}

/// Alt+wheel changes the unique display-sensitivity property exposed by the
/// hovered series. If there is no such property, a numeric 1D plot falls back
/// to y-scale intensity. Different eligible encodings are refused explicitly.
#[allow(clippy::too_many_arguments)]
fn adjust_plot_display(
app: &mut PlotxApp,
canvas: usize,
object: ObjectId,
plot: PlotRect,
pointer: Pos2,
scroll_y: f32,
now: f64,
ui: &Ui,
) -> bool {
use crate::ui::properties::discovery::{CanvasStepTarget, canvas_step_target};

if app.doc.canvases[canvas]
.object(object)
.is_some_and(|object| object.locked)
{
app.session.status = crate::ui::properties::discovery::LOCKED_REASON.to_owned();
return true;
}

match canvas_step_target(app, canvas, object) {
CanvasStepTarget::Ambiguous { labels } => {
app.finish_pending_wheel_property(now, true);
app.session.status = format!(
"Alt+scroll is ambiguous here ({}). Choose a layer in the Object inspector.",
labels.join(" / ")
);
true
}
CanvasStepTarget::Unique {
property,
label,
targets,
} => {
step_wheel_property(
app, canvas, object, property, label, targets, scroll_y, now, ui,
);
true
}
CanvasStepTarget::None => {
app.finish_pending_wheel_property(now, true);
scale_line_intensity(app, canvas, object, plot, pointer, scroll_y, now, ui)
}
}
}

#[allow(clippy::too_many_arguments)]
fn step_wheel_property(
app: &mut PlotxApp,
canvas: usize,
object: ObjectId,
property: plotx_core::properties::PropertyId,
label: &'static str,
targets: Vec<plotx_core::automation::TargetRef>,
scroll_y: f32,
now: f64,
ui: &Ui,
) {
let step_delta = ui
.ctx()
.options(|options| options.input_options.line_scroll_speed)
.max(1.0);
let changed_target = app
.session
.ui
.wheel_property
.as_ref()
.is_some_and(|pending| {
pending.canvas != canvas
|| pending.object != object
|| pending.property != property
|| pending.targets != targets
});
if changed_target {
app.finish_pending_wheel_property(now, true);
}
if app.session.ui.wheel_property.is_none() {
app.session.ui.wheel_property = Some(PendingWheelPropertyEdit {
canvas,
object,
property,
targets: targets.clone(),
accumulator: 0.0,
last_input_time: now,
gesture_started: false,
});
}
let pending = app.session.ui.wheel_property.as_mut().unwrap();
pending.accumulator += scroll_y;
pending.last_input_time = now;
let steps = (pending.accumulator.abs() / step_delta).floor() as usize;
if steps == 0 {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(200));
return;
}
let direction = if pending.accumulator > 0.0 {
plotx_core::properties::PropertyStep::Lower
} else {
plotx_core::properties::PropertyStep::Raise
};
let signed_step = if pending.accumulator > 0.0 {
step_delta
} else {
-step_delta
};
pending.accumulator -= signed_step * steps as f32;

for _ in 0..steps.min(8) {
match app.plan_property_step(property, &targets, direction) {
Ok(commit) => {
if !app
.session
.ui
.wheel_property
.as_ref()
.is_some_and(|pending| pending.gesture_started)
{
app.begin_property_gesture(property);
if let Some(pending) = app.session.ui.wheel_property.as_mut() {
pending.gesture_started = true;
}
}
let applied = app.commit_property(commit);
app.session.status = format!(
"Adjusted {label} on {applied} series. Alt+scroll controls display sensitivity."
);
}
Err(error) => {
app.session.status = format!("Could not adjust {label}: {error}");
break;
}
}
}
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(200));
}

#[allow(clippy::too_many_arguments)]
fn scale_line_intensity(
app: &mut PlotxApp,
canvas: usize,
object: ObjectId,
plot: PlotRect,
pointer: Pos2,
scroll_y: f32,
now: f64,
ui: &Ui,
) -> bool {
let Some(plot_object) = app.doc.canvases[canvas]
.object(object)
.and_then(|object| object.plot())
else {
return false;
};
if plot_object.figure().y.categories.is_some() {
app.session.status =
"This categorical plot has no continuous display-intensity scale.".to_owned();
return true;
}
let before = plot_object.viewport.clone();
let y_axis = plot_object.figure().y.clone();
let view = before.view_y;
let anchor = if view.min <= 0.0 && view.max >= 0.0 {
0.0
} else {
(view.min + view.max) * 0.5
};
let scale = f64::from((-scroll_y * WHEEL_ZOOM_SPEED).exp()).clamp(0.2, 5.0);
zoom_plot_viewport(
app,
canvas,
object,
plot_rect(plot),
plot,
Pos2::new(
pointer.x,
y_to_screen(anchor, plot, y_axis.min, y_axis.span(), y_axis.reversed),
),
scale,
(false, true),
now,
ui,
);
app.session.status =
"Adjusted plot intensity; automatic Y scaling is now off. Double-click Y to reset."
.to_owned();
true
}

/// Zoom a plot's data viewport around the cursor on the requested axes.
/// Coalesces one stream of wheel or pinch events into one undo step.
#[allow(clippy::too_many_arguments)]
pub(crate) fn zoom_plot_viewport(
app: &mut PlotxApp,
ci: usize,
object_id: ObjectId,
outer_rect: EguiRect,
_outer_rect: EguiRect,
plot: PlotRect,
p: Pos2,
scale: f64,
both_axes: bool,
shift: bool,
alt: bool,
axes: (bool, bool),
now: f64,
ui: &Ui,
) {
let (zoom_x, zoom_y) = match hit_zone(p, outer_rect, plot) {
HitZone::XAxis => (true, false),
HitZone::YAxis => (false, true),
HitZone::Plot => (!alt, !shift),
HitZone::None if both_axes => (true, true),
HitZone::None => return,
};
let (zoom_x, zoom_y) = axes;
if !zoom_x && !zoom_y {
return;
}

if app
.session
Expand Down
Loading
Loading