diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 89778615..72e1a4cb 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -30,4 +30,4 @@ jobs: fail: true # When given a directory, lychee checks only markdown, html and text files, everything else we have to glob in manually. args: | - --base . --cache --max-cache-age 1d . "**/*.rs" "**/*.toml" "**/*.hpp" "**/*.cpp" "**/CMakeLists.txt" "**/*.py" "**/*.yml" + --cache --max-cache-age 1d . "**/*.rs" "**/*.toml" "**/*.hpp" "**/*.cpp" "**/CMakeLists.txt" "**/*.py" "**/*.yml" diff --git a/egui_plot/src/bound.rs b/egui_plot/src/bound.rs index 1e135d77..cc9fb5c4 100644 --- a/egui_plot/src/bound.rs +++ b/egui_plot/src/bound.rs @@ -15,7 +15,6 @@ impl Interval { let start_inf = self.start.is_infinite(); let end_inf = self.end.is_infinite(); - if start_inf && end_inf { // (-∞, +∞) if self.start.is_sign_negative() && self.end.is_sign_positive() { @@ -26,12 +25,10 @@ impl Interval { return 0.0; } - if start_inf || end_inf { return f64::INFINITY; } - (self.end - self.start).max(0.0) } diff --git a/egui_plot/src/items/tooltip.rs b/egui_plot/src/items/tooltip.rs index 19348f2e..094c8355 100644 --- a/egui_plot/src/items/tooltip.rs +++ b/egui_plot/src/items/tooltip.rs @@ -40,8 +40,7 @@ //! - Series highlighting currently matches by **series name**. Prefer unique names. use egui::{ - self, Align2, Area, Color32, Frame, Grid, Id, Key, Order, Pos2, Rect, RichText, Stroke, - TextStyle, + self, Align2, Area, Color32, Frame, Grid, Id, Order, Pos2, Rect, RichText, Stroke, TextStyle, }; use crate::{PlotPoint, PlotUi, items::PlotGeometry}; @@ -193,6 +192,7 @@ impl PlotUi<'_> { let transform = self.transform().clone(); let frame = transform.frame(); + let nav = *self.navigation_config(); // Draw existing pins (rails + markers) on a foreground layer: let mut pins = load_pins(&ctx, self.response.id); draw_pins_overlay( @@ -391,13 +391,17 @@ impl PlotUi<'_> { } if hits.is_empty() { - if self.response.hovered() { + if self.response.hovered() && nav.pinning_enabled { ctx.input(|i| { - if i.key_pressed(Key::U) { - pins.pop(); + if let Some(k) = nav.pin_remove_key { + if i.key_pressed(k) { + pins.pop(); + } } - if i.key_pressed(Key::Delete) { - pins.clear(); + if let Some(k) = nav.pins_clear_key { + if i.key_pressed(k) { + pins.clear(); + } } }); save_pins(&ctx, self.response.id, pins); @@ -422,20 +426,26 @@ impl PlotUi<'_> { } } - if self.response.hovered() { + if self.response.hovered() && nav.pinning_enabled { ctx.input(|i| { - if i.key_pressed(Key::P) { - let pointer_plot = transform.value_from_position(pointer_screen); - pins.push(PinnedPoints { - hits: hits.clone(), - plot_x: pointer_plot.x, - }); + if let Some(k) = nav.pin_add_key { + if i.key_pressed(k) { + let pointer_plot = transform.value_from_position(pointer_screen); + pins.push(PinnedPoints { + hits: hits.clone(), + plot_x: pointer_plot.x, + }); + } } - if i.key_pressed(Key::U) { - pins.pop(); + if let Some(k) = nav.pin_remove_key { + if i.key_pressed(k) { + pins.pop(); + } } - if i.key_pressed(Key::Delete) { - pins.clear(); + if let Some(k) = nav.pins_clear_key { + if i.key_pressed(k) { + pins.clear(); + } } }); save_pins(&ctx, self.response.id, pins.clone()); diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index 05eaeca3..6d657370 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -14,6 +14,7 @@ mod collect_events; mod items; mod legend; mod memory; +mod navigation; mod plot_ui; mod segmented_axis; mod span; @@ -24,6 +25,7 @@ mod action; pub use crate::action::PlotEvent; pub use crate::action::{ActionExecutor, ActionQueue}; pub use crate::action::{BoundsChangeCause, InputInfo, PinSnapshot}; +pub use navigation::{AxisToggle, BoxZoomConfig, NavigationConfig, ResetBehavior, ZoomConfig}; pub use crate::segmented_axis::SegmentedAxis; pub use crate::{ @@ -41,9 +43,9 @@ pub use crate::{ }; use ahash::HashMap; use egui::{ - Align2, Color32, CursorIcon, Id, Layout, NumExt as _, PointerButton, Pos2, Rangef, Rect, - Response, Sense, Shape, Stroke, TextStyle, Ui, Vec2, Vec2b, WidgetText, epaint, remap_clamp, - vec2, + Align2, Color32, CursorIcon, Id, Layout, Modifiers, NumExt as _, PointerButton, Pos2, Rangef, + Rect, Response, Sense, Shape, Stroke, TextStyle, Ui, Vec2, Vec2b, WidgetText, epaint, + remap_clamp, vec2, }; pub use span::{HSpan, VSpan}; pub use span_utils::interval_to_screen_x; @@ -55,7 +57,7 @@ use emath::Float as _; use axis::AxisWidget; use items::{horizontal_line, rulers_color, vertical_line}; use legend::LegendWidget; -use egui::pos2; + type LabelFormatterFn<'a> = dyn Fn(&str, &PlotPoint) -> String + 'a; pub type LabelFormatter<'a> = Option>>; @@ -213,6 +215,8 @@ pub struct Plot<'a> { sense: Sense, segmented_x_axis: Option, + + navigation: Option, } impl<'a> Plot<'a> { @@ -263,8 +267,15 @@ impl<'a> Plot<'a> { sense: egui::Sense::click_and_drag(), segmented_x_axis: None, + navigation: None, } } + + /// custom navigation configuration. + pub fn navigation(mut self, config: NavigationConfig) -> Self { + self.navigation = Some(config); + self + } pub fn segmented_x_axis(mut self, segmented: Option) -> Self { self.segmented_x_axis = segmented; self @@ -845,12 +856,32 @@ impl<'a> Plot<'a> { grid_spacers, sense, segmented_x_axis, + navigation, } = self; + let mut nav = if let Some(cfg) = navigation { + cfg + } else { + NavigationConfig::from_legacy_flags( + allow_drag, + allow_zoom, + allow_scroll, + allow_axis_zoom_drag, + allow_double_click_reset, + allow_boxed_zoom, + boxed_zoom_pointer_button, + ) + }; + // Disable interaction if ui is disabled. - let allow_zoom = allow_zoom.and(ui.is_enabled()); - let allow_drag = allow_drag.and(ui.is_enabled()); - let allow_scroll = allow_scroll.and(ui.is_enabled()); + let ui_enabled = ui.is_enabled(); + if !ui_enabled { + nav.drag.enabled = false; + nav.scroll.enabled = false; + nav.zoom.enabled = false; + nav.double_click_reset = false; + nav.box_zoom.enabled = false; + } // Determine position of widget. let pos = ui.available_rect_before_wrap().min; @@ -910,7 +941,7 @@ impl<'a> Plot<'a> { .iter() .map(|widget| { let axis_resp = ui.allocate_rect(widget.rect, Sense::drag()); - if allow_axis_zoom_drag.x { + if nav.axis_zoom_drag.x { axis_resp.on_hover_cursor(CursorIcon::ResizeHorizontal) } else { axis_resp @@ -922,7 +953,7 @@ impl<'a> Plot<'a> { .iter() .map(|widget| { let axis_resp = ui.allocate_rect(widget.rect, Sense::drag()); - if allow_axis_zoom_drag.y { + if nav.axis_zoom_drag.y { axis_resp.on_hover_cursor(CursorIcon::ResizeVertical) } else { axis_resp @@ -951,9 +982,10 @@ impl<'a> Plot<'a> { last_click_pos_for_zoom: None, x_axis_thickness: Default::default(), y_axis_thickness: Default::default(), + original_bounds: None, }); - let last_plot_transform = mem.transform; + let last_plot_transform = mem.transform.clone(); // Call the plot build function. let mut plot_ui = PlotUi { ctx: ui.ctx().clone(), @@ -963,6 +995,7 @@ impl<'a> Plot<'a> { last_auto_bounds: mem.auto_bounds, response: response.clone(), called_once: false, + navigation: nav, }; let inner = build_fn(&mut plot_ui); @@ -1031,6 +1064,7 @@ impl<'a> Plot<'a> { let frames: &mut CursorLinkGroups = data.get_temp_mut_or_default(Id::NULL); let cursors = frames.0.entry(*id).or_default(); // Look for our previous frame + if let Some(index) = cursors .iter() .enumerate() @@ -1068,17 +1102,23 @@ impl<'a> Plot<'a> { }); } - // Double-click reset - if allow_double_click_reset && response.double_clicked() { - mem.auto_bounds = true.into(); - events.push(PlotEvent::ResetApplied { - input: InputInfo { - pointer: ui.input(|i| i.pointer.hover_pos()), - button: Some(PointerButton::Primary), - modifiers: ui.input(|i| i.modifiers), - }, - }); - last_user_cause = Some(BoundsChangeCause::Reset); + // Double-click reset to original bounds (if configured). + if nav.double_click_reset && response.double_clicked() { + if let Some(orig) = mem.original_bounds { + bounds = orig; + + // Once the user explicitly resets, stop auto-bounds. + mem.auto_bounds = false.into(); + + events.push(PlotEvent::ResetApplied { + input: InputInfo { + pointer: ui.input(|i| i.pointer.hover_pos()), + button: Some(PointerButton::Primary), + modifiers: ui.input(|i| i.modifiers), + }, + }); + last_user_cause = Some(BoundsChangeCause::Reset); + } } if mem.auto_bounds.x { @@ -1139,7 +1179,10 @@ impl<'a> Plot<'a> { } // Pan - if allow_drag.any() && response.dragged_by(PointerButton::Primary) { + if nav.drag.enabled + && (nav.drag.axis.x || nav.drag.axis.y) + && response.dragged_by(PointerButton::Primary) + { response = response.on_hover_cursor(CursorIcon::Grabbing); if response.drag_started() { @@ -1153,10 +1196,10 @@ impl<'a> Plot<'a> { } let mut delta = -response.drag_delta(); - if !allow_drag.x { + if !nav.drag.axis.x { delta.x = 0.0; } - if !allow_drag.y { + if !nav.drag.axis.y { delta.y = 0.0; } @@ -1173,13 +1216,13 @@ impl<'a> Plot<'a> { if mem.transform.segment_xaxis().is_some() { mem.transform.translate_segment_offset(-delta.x); - mem.transform.translate_bounds((0.0, delta.y as f64)); } else { mem.transform .translate_bounds((delta.x as f64, delta.y as f64)); } - mem.auto_bounds = mem.auto_bounds.and(!allow_drag); + + mem.auto_bounds = mem.auto_bounds.and(!nav.drag.axis); last_user_cause = Some(BoundsChangeCause::Pan); if response.drag_stopped() { @@ -1195,7 +1238,7 @@ impl<'a> Plot<'a> { // Axis zoom drag for d in 0..2 { - if allow_axis_zoom_drag[d] { + if nav.axis_zoom_drag[d] { if let Some(axis_resp) = (if d == 0 { &x_axis_responses } else { @@ -1267,27 +1310,35 @@ impl<'a> Plot<'a> { // Boxed zoom let mut boxed_zoom_rect = None; - if allow_boxed_zoom { - // Save last click to allow boxed zooming - - if response.drag_started() && response.dragged_by(boxed_zoom_pointer_button) { - // it would be best for egui that input has a memory of the last click pos because it's a common pattern + if nav.box_zoom.enabled { + let modifiers_ok = |cur: Modifiers, req: Modifiers| -> bool { + (!req.alt || cur.alt) + && (!req.ctrl || cur.ctrl) + && (!req.shift || cur.shift) + && (!req.command || cur.command) + && (!req.mac_cmd || cur.mac_cmd) + }; + if response.drag_started() + && response.dragged_by(nav.box_zoom.button) + && modifiers_ok(ui.input(|i| i.modifiers), nav.box_zoom.required_mods) + { mem.last_click_pos_for_zoom = response.hover_pos(); events.push(PlotEvent::BoxZoomStarted { screen_start: mem.last_click_pos_for_zoom.unwrap_or(plot_rect.center()), input: InputInfo { pointer: mem.last_click_pos_for_zoom, - button: Some(boxed_zoom_pointer_button), + button: Some(nav.box_zoom.button), modifiers: ui.input(|i| i.modifiers), }, }); } + let (start, end) = (mem.last_click_pos_for_zoom, response.hover_pos()); if let (Some(s), Some(e)) = (start, end) { - // while dragging prepare a Shape and draw it later on top of the plot - - if response.dragged_by(boxed_zoom_pointer_button) { + if response.dragged_by(nav.box_zoom.button) + && modifiers_ok(ui.input(|i| i.modifiers), nav.box_zoom.required_mods) + { response = response.on_hover_cursor(CursorIcon::ZoomIn); let rect = epaint::Rect::from_two_pos(s, e); boxed_zoom_rect = Some(( @@ -1296,16 +1347,16 @@ impl<'a> Plot<'a> { 0.0, epaint::Stroke::new(4., Color32::DARK_BLUE), egui::StrokeKind::Middle, - ), // Outer stroke + ), epaint::RectShape::stroke( rect, 0.0, epaint::Stroke::new(2., Color32::WHITE), egui::StrokeKind::Middle, - ), // Inner stroke + ), )); } - // when the click is release perform the zoom + if response.drag_stopped() { let s_val = mem.transform.value_from_position(s); let e_val = mem.transform.value_from_position(e); @@ -1323,13 +1374,12 @@ impl<'a> Plot<'a> { new_y, input: InputInfo { pointer: response.hover_pos(), - button: Some(boxed_zoom_pointer_button), + button: Some(nav.box_zoom.button), modifiers: ui.input(|i| i.modifiers), }, }); last_user_cause = Some(BoundsChangeCause::BoxZoom); } - // reset the boxed zoom state mem.last_click_pos_for_zoom = None; } } @@ -1342,25 +1392,38 @@ impl<'a> Plot<'a> { response.contains_pointer(), ui.input(|i| i.pointer.hover_pos()), ) { - if allow_zoom.any() { - let mut zoom_factor = if data_aspect.is_some() { + // Zoom + if nav.zoom.enabled && (nav.zoom.axis.x || nav.zoom.axis.y) { + let mut z = if data_aspect.is_some() { Vec2::splat(ui.input(|i| i.zoom_delta())) } else { ui.input(|i| i.zoom_delta_2d()) }; - if !allow_zoom.x { - zoom_factor.x = 1.0; + + if !nav.zoom.axis.x { + z.x = 1.0; + } + if !nav.zoom.axis.y { + z.y = 1.0; } - if !allow_zoom.y { - zoom_factor.y = 1.0; + + if nav.zoom.wheel_factor_exp != 1.0 { + z.x = z.x.powf(nav.zoom.wheel_factor_exp); + z.y = z.y.powf(nav.zoom.wheel_factor_exp); } - if zoom_factor != Vec2::splat(1.0) { - mem.transform.zoom(zoom_factor, hover_pos); + + if z != Vec2::splat(1.0) { + let center = if nav.zoom.zoom_to_mouse { + hover_pos + } else { + plot_rect.center() + }; + mem.transform.zoom(z, center); events.push(PlotEvent::ZoomDelta { - factor_x: zoom_factor.x, - factor_y: zoom_factor.y, - center_plot_x: mem.transform.value_from_position(hover_pos).x, - center_plot_y: mem.transform.value_from_position(hover_pos).y, + factor_x: z.x, + factor_y: z.y, + center_plot_x: mem.transform.value_from_position(center).x, + center_plot_y: mem.transform.value_from_position(center).y, input: InputInfo { pointer: Some(hover_pos), button: None, @@ -1368,22 +1431,35 @@ impl<'a> Plot<'a> { }, }); last_user_cause = Some(BoundsChangeCause::Zoom); - mem.auto_bounds = mem.auto_bounds.and(!allow_zoom); + + let ab = Vec2b::new( + if nav.zoom.axis.x { + false + } else { + mem.auto_bounds.x + }, + if nav.zoom.axis.y { + false + } else { + mem.auto_bounds.y + }, + ); + mem.auto_bounds = ab; } } - if allow_scroll.any() { + // Scroll pan + if nav.scroll.enabled && (nav.scroll.axis.x || nav.scroll.axis.y) { let mut scroll = ui.input(|i| i.smooth_scroll_delta); - if !allow_scroll.x { + if !nav.scroll.axis.x { scroll.x = 0.0; } - if !allow_scroll.y { + if !nav.scroll.axis.y { scroll.y = 0.0; } if scroll != Vec2::ZERO { if mem.transform.segment_xaxis().is_some() { mem.transform.translate_segment_offset(-scroll.x); - mem.transform.translate_bounds((0.0, -scroll.y as f64)); } else { mem.transform @@ -1393,6 +1469,7 @@ impl<'a> Plot<'a> { } } } + // --- transform initialized // Add legend widgets to plot @@ -1454,18 +1531,6 @@ impl<'a> Plot<'a> { let (plot_cursors, mut hovered_plot_item) = prepared.ui(ui, &response); - if let Some(gaps) = mem.transform.segment_x_gap_screen_ranges() { - let frame = mem.transform.frame(); - let gap_color = ui.visuals().extreme_bg_color; - for (left, right) in gaps { - let gap_rect = - Rect::from_min_max(pos2(left, frame.top()), pos2(right, frame.bottom())); - ui.painter() - .with_clip_rect(*frame) - .add(egui::Shape::rect_filled(gap_rect, 0.0, gap_color)); - } - } - // Click/Context menu -> events if response.clicked() { events.push(PlotEvent::Activate { @@ -1525,22 +1590,6 @@ impl<'a> Plot<'a> { ); }); } - - let transform = mem.transform.clone(); - mem.store(ui.ctx(), plot_id); - - response = if show_x || show_y { - response.on_hover_cursor(CursorIcon::Crosshair) - } else { - response - }; - ui.advance_cursor_after_rect(complete_rect); - - if let Some(screen) = response.hover_pos() { - let pos = transform.value_from_position(screen); - events.push(PlotEvent::Hover { pos }); - } - if response.has_focus() || response.contains_pointer() { let pressed = |k: egui::Key| ui.ctx().input(|i| i.key_pressed(k)); let released = |k: egui::Key| ui.ctx().input(|i| i.key_released(k)); @@ -1569,27 +1618,51 @@ impl<'a> Plot<'a> { } } - if ui.ctx().input(|i| i.key_pressed(egui::Key::P)) { - if let Some(ptr) = ui.ctx().input(|i| i.pointer.latest_pos()) { - let plot = transform.value_from_position(ptr); - events.push(PlotEvent::PinAdded { - snapshot: crate::action::PinSnapshot { - plot_x: plot.x, - rows: Vec::new(), - }, - }); + // Fit-to-view shortcut + if let Some(k) = nav.fit_to_view_key { + if ui.ctx().input(|i| i.key_pressed(k)) { + mem.auto_bounds = true.into(); + last_user_cause = Some(BoundsChangeCause::AutoFit); } } - if ui.ctx().input(|i| i.key_pressed(egui::Key::U)) { - events.push(PlotEvent::PinRemoved { index: 0 }); - } - if ui.ctx().input(|i| i.key_pressed(egui::Key::Delete)) { - events.push(PlotEvent::PinsCleared); + + // Pinning shortcuts + if nav.pinning_enabled { + if let Some(k) = nav.pin_add_key { + if ui.ctx().input(|i| i.key_pressed(k)) { + if let Some(ptr) = ui.ctx().input(|i| i.pointer.latest_pos()) { + let plot = mem.transform.value_from_position(ptr); + events.push(PlotEvent::PinAdded { + snapshot: crate::action::PinSnapshot { + plot_x: plot.x, + rows: Vec::new(), + }, + }); + } + } + } + + // Remove latest / first pin + if let Some(k) = nav.pin_remove_key { + if ui.ctx().input(|i| i.key_pressed(k)) { + events.push(PlotEvent::PinRemoved { index: 0 }); + } + } + + // Clear all pins + if let Some(k) = nav.pins_clear_key { + if ui.ctx().input(|i| i.key_pressed(k)) { + events.push(PlotEvent::PinsCleared); + } + } } } let old_bounds = *last_plot_transform.bounds(); - let new_bounds = *transform.bounds(); + let new_bounds = *mem.transform.bounds(); + if mem.original_bounds.is_none() { + mem.original_bounds = Some(new_bounds); + } if old_bounds != new_bounds { events.push(PlotEvent::BoundsChanged { old: old_bounds, @@ -1597,6 +1670,20 @@ impl<'a> Plot<'a> { cause: last_user_cause.unwrap_or(BoundsChangeCause::Programmatic), }); } + let transform = mem.transform.clone(); + mem.store(ui.ctx(), plot_id); + + response = if show_x || show_y { + response.on_hover_cursor(CursorIcon::Crosshair) + } else { + response + }; + ui.advance_cursor_after_rect(complete_rect); + + if let Some(screen) = response.hover_pos() { + let pos = transform.value_from_position(screen); + events.push(PlotEvent::Hover { pos }); + } PlotResponse { inner, diff --git a/egui_plot/src/memory.rs b/egui_plot/src/memory.rs index d292690d..da3d32ec 100644 --- a/egui_plot/src/memory.rs +++ b/egui_plot/src/memory.rs @@ -32,6 +32,9 @@ pub struct PlotMemory { /// in order to fit the labels, if necessary. pub(crate) x_axis_thickness: BTreeMap, pub(crate) y_axis_thickness: BTreeMap, + + /// first bounds that has been shown. + pub original_bounds: Option, } impl PlotMemory { diff --git a/egui_plot/src/navigation.rs b/egui_plot/src/navigation.rs new file mode 100644 index 00000000..0629d6ef --- /dev/null +++ b/egui_plot/src/navigation.rs @@ -0,0 +1,293 @@ +//! Navigation module. + +use egui::{Key, Modifiers, PointerButton, Vec2b}; + +/// A reset operation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResetBehavior { + /// Restore the original bounds from the first frame the plot was shown. + OriginalBounds, +} + +/// Per-axis enable flags. +#[derive(Clone, Copy, Debug)] +pub struct AxisToggle { + /// Master flag. If `false`, the feature is disabled even if individual axes are `true`. + pub enabled: bool, + /// Which axes are affected (`x` and/or `y`). + pub axis: Vec2b, +} + +impl AxisToggle { + #[inline] + pub const fn new(enabled: bool, axis: Vec2b) -> Self { + Self { enabled, axis } + } +} + +/// Zoom configuration. +#[derive(Clone, Copy, Debug)] +pub struct ZoomConfig { + /// Master enable. + pub enabled: bool, + /// Axes to zoom (`x` and/or `y`). + pub axis: Vec2b, + /// If `true`, zoom centers at the mouse position; otherwise at plot center. + pub zoom_to_mouse: bool, + /// Exponent applied to `egui` zoom delta (1.0 = unchanged). + /// Values >1.0 make zoom more aggressive; <1.0 make it gentler. + pub wheel_factor_exp: f32, +} + +impl ZoomConfig { + #[inline] + pub const fn new(enabled: bool, axis: Vec2b) -> Self { + Self { + enabled, + axis, + zoom_to_mouse: true, + wheel_factor_exp: 1.0, + } + } + + #[inline] + pub fn zoom_to_mouse(mut self, v: bool) -> Self { + self.zoom_to_mouse = v; + self + } + + #[inline] + pub fn wheel_factor_exp(mut self, exp: f32) -> Self { + self.wheel_factor_exp = exp; + self + } +} + +/// Box (rubber-band) zoom settings. +#[derive(Clone, Copy, Debug)] +pub struct BoxZoomConfig { + /// Enable boxed zoom. + pub enabled: bool, + /// Which pointer button starts the box. + pub button: PointerButton, + /// Which modifiers must be down. Any `true` field here must be pressed at runtime. + pub required_mods: Modifiers, +} + +impl BoxZoomConfig { + #[inline] + pub const fn new(enabled: bool, button: PointerButton, required_mods: Modifiers) -> Self { + Self { + enabled, + button, + required_mods, + } + } +} + +/// All navigation & shortcut controls in one place. +#[derive(Clone, Copy, Debug)] +pub struct NavigationConfig { + /// Dragging (per axis). + pub drag: AxisToggle, + /// Scrolling/panning with mouse wheel/touchpad (per axis). + pub scroll: AxisToggle, + /// Axis-zoom-drag (drag on axis strips). + pub axis_zoom_drag: Vec2b, + /// Wheel/pinch zoom. + pub zoom: ZoomConfig, + /// Box zoom. + pub box_zoom: BoxZoomConfig, + /// What double-click reset does. + pub reset_behavior: ResetBehavior, + /// Allow double-click reset. + pub double_click_reset: bool, + /// Enable pinning (P/U/Delete by default). + pub pinning_enabled: bool, + /// Shortcut: fit to view (e.g., `Key::F`). `None` disables shortcut. + pub fit_to_view_key: Option, + + /// Pin shortcuts. + pub pin_add_key: Option, + pub pin_remove_key: Option, + pub pins_clear_key: Option, +} + +impl Default for NavigationConfig { + fn default() -> Self { + Self { + drag: AxisToggle::new(true, Vec2b::new(true, true)), + scroll: AxisToggle::new(true, Vec2b::new(true, true)), + axis_zoom_drag: Vec2b::new(false, false), + zoom: ZoomConfig::new(true, Vec2b::new(true, true)) + .zoom_to_mouse(true) + .wheel_factor_exp(1.0), + box_zoom: BoxZoomConfig::new(false, PointerButton::Secondary, Modifiers::NONE), + reset_behavior: ResetBehavior::OriginalBounds, + double_click_reset: true, + pinning_enabled: true, + fit_to_view_key: Some(Key::F), + + pin_add_key: Some(Key::P), + pin_remove_key: Some(Key::U), + pins_clear_key: Some(Key::Delete), + } + } +} +impl NavigationConfig { + #[allow(clippy::fn_params_excessive_bools)] + /// Build a `NavigationConfig`. + pub fn from_legacy_flags( + allow_drag: Vec2b, + allow_zoom: Vec2b, + allow_scroll: Vec2b, + allow_axis_zoom_drag: Vec2b, + allow_double_click_reset: bool, + allow_boxed_zoom: bool, + boxed_zoom_button: PointerButton, + ) -> Self { + Self { + drag: AxisToggle::new(allow_drag.any(), allow_drag), + scroll: AxisToggle::new(allow_scroll.any(), allow_scroll), + axis_zoom_drag: allow_axis_zoom_drag, + zoom: ZoomConfig::new(allow_zoom.any(), allow_zoom) + .zoom_to_mouse(true) + .wheel_factor_exp(1.0), + box_zoom: BoxZoomConfig::new(allow_boxed_zoom, boxed_zoom_button, Modifiers::NONE), + + ..Self::default().reset_controls( + ResetBehavior::OriginalBounds, + allow_double_click_reset, + Some(Key::R), + ) + } + } + + /// Configure drag behavior for the given axes. + /// + /// The `axes` parameter uses `(x, y)` ordering: + /// - `Some(Vec2b::new(true, true))` → drag on both X and Y + /// - `Some(Vec2b::new(true, false))` → drag on X only + /// - `Some(Vec2b::new(false, true))` → drag on Y only + /// - `None` → dragging completely disabled + #[inline] + pub fn drag(mut self, axes: Option) -> Self { + match axes { + Some(axis) => { + self.drag = AxisToggle::new(true, axis); + } + None => { + self.drag = AxisToggle::new(false, Vec2b::new(false, false)); + } + } + self + } + + /// Configure scrolling/panning with the mouse wheel or touchpad. + /// + /// Same `(x, y)` ordering as `drag`: + /// - `Some(Vec2b::new(true, false))` → scroll horizontally only + /// - `None` → disable scroll-based navigation + #[inline] + pub fn scroll(mut self, axes: Option) -> Self { + match axes { + Some(axis) => { + self.scroll = AxisToggle::new(true, axis); + } + None => { + self.scroll = AxisToggle::new(false, Vec2b::new(false, false)); + } + } + self + } + + /// Configure zoom-drag on the axis strips. + /// + /// `axis` selects which axes can be zoomed by dragging on their axis strips. + #[inline] + pub fn axis_zoom(mut self, axis: Vec2b) -> Self { + self.axis_zoom_drag = axis; + self + } + + /// Set the full zoom configuration. + #[inline] + pub fn scroll_zoom(mut self, cfg: ZoomConfig) -> Self { + self.zoom = cfg; + self + } + + /// Set the box-zoom configuration. + #[inline] + pub fn box_zoom(mut self, cfg: BoxZoomConfig) -> Self { + self.box_zoom = cfg; + self + } + + /// Configure all reset-related controls in a single place. + /// + /// `behavior` defines how reset behaves, `double_click` toggles double-click + /// reset, and `fit_key` / `restore_key` configure keyboard shortcuts. + #[inline] + pub fn reset_controls( + mut self, + behavior: ResetBehavior, + double_click: bool, + fit_key: Option, + ) -> Self { + self.reset_behavior = behavior; + self.double_click_reset = double_click; + self.fit_to_view_key = fit_key; + + self + } + + /// Set the reset behavior + /// + /// This keeps other reset-related fields (double click, shortcuts) unchanged. + #[inline] + pub fn reset_behavior(self, behavior: ResetBehavior) -> Self { + self.reset_controls(behavior, self.double_click_reset, self.fit_to_view_key) + } + + /// Enable or disable double-click reset. + /// + /// This keeps the reset behavior and shortcuts unchanged. + #[inline] + pub fn double_click_reset(self, on: bool) -> Self { + self.reset_controls(self.reset_behavior, on, self.fit_to_view_key) + } + + /// Configure keyboard shortcuts for "fit to view" and "restore original". + /// + /// Pass `None` to disable a shortcut. + #[inline] + pub fn shortcuts_fit_restore(self, fit: Option) -> Self { + self.reset_controls(self.reset_behavior, self.double_click_reset, fit) + } + + /// Enable or disable pinning (tooltip pin add/remove/clear). + /// + /// This affects keyboard shortcuts for pins and any pin-related UI. + #[inline] + pub fn pinning(mut self, on: bool) -> Self { + self.pinning_enabled = on; + self + } + + /// Configure keyboard shortcuts for pin management. + /// + /// `add`, `remove`, and `clear` control pin creation and deletion. + #[inline] + pub fn shortcuts_pin( + mut self, + add: Option, + remove: Option, + clear: Option, + ) -> Self { + self.pin_add_key = add; + self.pin_remove_key = remove; + self.pins_clear_key = clear; + self + } +} diff --git a/egui_plot/src/plot_ui.rs b/egui_plot/src/plot_ui.rs index 07e13101..abc0b397 100644 --- a/egui_plot/src/plot_ui.rs +++ b/egui_plot/src/plot_ui.rs @@ -2,7 +2,9 @@ use std::ops::RangeInclusive; use egui::{Color32, Pos2, Response, Vec2, Vec2b, epaint::Hsva}; -use crate::{PlotBounds, PlotItem, PlotPoint, PlotTransform, action::ActionQueue}; +use crate::{ + NavigationConfig, PlotBounds, PlotItem, PlotPoint, PlotTransform, action::ActionQueue, +}; #[allow(unused_imports)] // for links in docstrings use crate::Plot; @@ -17,9 +19,14 @@ pub struct PlotUi<'a> { pub(crate) last_auto_bounds: Vec2b, pub(crate) response: Response, pub(crate) called_once: bool, + pub(crate) navigation: NavigationConfig, } impl<'a> PlotUi<'a> { + #[inline] + pub fn navigation_config(&self) -> &NavigationConfig { + &self.navigation + } #[inline] pub fn set_segmented_x_axis(&mut self, segment: Option) { self.last_plot_transform.set_segment_xaxis(segment); diff --git a/egui_plot/src/transform.rs b/egui_plot/src/transform.rs index 353d200d..9a8641aa 100644 --- a/egui_plot/src/transform.rs +++ b/egui_plot/src/transform.rs @@ -660,7 +660,7 @@ impl PlotTransform { let total_len: f64 = bx .segments .iter() - .map(|seg| seg.len().max(f64::EPSILON)) + .map(|seg| seg.len().max(f64::EPSILON)) .sum(); let total_gap_px: f32 = if n >= 2 { diff --git a/examples/navigation/Cargo.toml b/examples/navigation/Cargo.toml new file mode 100644 index 00000000..780f0182 --- /dev/null +++ b/examples/navigation/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "navigation" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +eframe = { workspace = true, features = ["default"] } +egui_plot.workspace = true +[lints] +workspace = true diff --git a/examples/navigation/src/main.rs b/examples/navigation/src/main.rs new file mode 100644 index 00000000..881b69d2 --- /dev/null +++ b/examples/navigation/src/main.rs @@ -0,0 +1,80 @@ +#![allow(rustdoc::missing_crate_level_docs)] + +use eframe::{App, Frame, egui}; +use egui::{Color32, Context, Key, Modifiers, PointerButton, Vec2b}; +use egui_plot::{ + Line, Plot, TooltipOptions, {BoxZoomConfig, NavigationConfig, ResetBehavior, ZoomConfig}, +}; + +fn main() -> eframe::Result<()> { + eframe::run_native( + "Line::new_xy + custom navigation", + eframe::NativeOptions::default(), + Box::new(|_| Ok(Box::new(Demo::new()))), + ) +} + +struct Demo { + xs: Vec, + f1: Vec, + f2: Vec, +} + +impl Demo { + fn new() -> Self { + let n = 500; + let xs: Vec = (0..n).map(|i| i as f64 * 0.02).collect(); + let f1: Vec = xs.iter().map(|&t| t.sin()).collect(); + let f2: Vec = xs + .iter() + .map(|&t| (t * 0.6 + 0.8).sin() * 0.8 + 0.2) + .collect(); + + Self { xs, f1, f2 } + } +} + +impl App for Demo { + fn update(&mut self, ctx: &Context, _frame: &mut Frame) { + egui::CentralPanel::default().show(ctx, |ui| { + ui.heading("Line::new_xy + completely custom navigation"); + + let nav = NavigationConfig::default() + .drag(Some(Vec2b::new(true, false))) + .scroll(Some(Vec2b::new(true, false))) + .axis_zoom(Vec2b::new(true, false)) + .scroll_zoom( + ZoomConfig::new(true, Vec2b::new(true, true)) + .zoom_to_mouse(true) + .wheel_factor_exp(1.15), + ) + .box_zoom(BoxZoomConfig::new( + true, + PointerButton::Secondary, + Modifiers { + shift: true, + ..Modifiers::NONE + }, + )) + .reset_behavior(ResetBehavior::OriginalBounds) + .double_click_reset(true) + .shortcuts_fit_restore(Some(Key::R)) + .shortcuts_pin(Some(Key::D), Some(Key::U), Some(Key::Delete)); + + Plot::new("demo_plot").navigation(nav).show(ui, |plot_ui| { + plot_ui.line( + Line::new_xy("f1", &self.xs, &self.f1) + .color(Color32::from_rgb(200, 100, 100)) + .width(2.0), + ); + plot_ui.line( + Line::new_xy("f2", &self.xs, &self.f2) + .color(Color32::from_rgb(100, 160, 240)) + .width(2.0), + ); + + plot_ui.show_tooltip_with_options(&TooltipOptions::default()); + }); + }); + } +}