use egui::{PointerButton, Pos2, Rect, Sense, Stroke, Ui};
use crate::core::plot::Plot;
use crate::core::roi::{ManagedRoi, RoiInteractionMode};
use crate::core::transform::{Scale, Transform};
use crate::widget::interaction::{RoiDrawKind, RoiGrab};
const ROI_GRAB_PX: f32 = 6.0;
#[derive(Clone, Copy)]
struct RoiDrag {
roi: usize,
grab: RoiGrab,
last_data: (f64, f64),
}
#[derive(Clone, Copy)]
struct MarkerDrag {
handle: crate::core::backend::ItemHandle,
anchor: (f64, f64),
}
struct Interaction {
selection: Option<egui::Rect>,
roi_changed: Option<usize>,
roi_created: Option<usize>,
roi_removed: Option<usize>,
roi_make_current: Option<usize>,
roi_set_interaction_mode: Option<(usize, RoiInteractionMode)>,
roi_preview: Option<(interaction::DrawMode, Vec<(f64, f64)>)>,
draw_event: Option<interaction::DrawEvent>,
marker_moved: Option<crate::core::backend::ItemHandle>,
marker_drag_started: Option<crate::core::backend::ItemHandle>,
marker_drag_finished: Option<crate::core::backend::ItemHandle>,
pointer_event: Option<interaction::PlotPointerEvent>,
}
use crate::render::backend_wgpu::{ClearCallback, CurveCallback, ImageCallback};
use crate::widget::{chrome, interaction};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum PlotInteractionMode {
Select,
Pan,
#[default]
Zoom,
MaskDraw,
RoiCreate(RoiDrawKind),
}
impl PlotInteractionMode {
pub fn roi_creation_message(self, roi_count: usize) -> Option<String> {
match self {
PlotInteractionMode::RoiCreate(kind) => Some(format!(
"Select {}s ({} selected)",
kind.short_name(),
roi_count
)),
_ => None,
}
}
}
pub struct PlotResponse {
pub response: egui::Response,
pub transform: Transform,
pub roi_changed: Option<usize>,
pub roi_created: Option<usize>,
pub roi_removed: Option<usize>,
pub roi_make_current: Option<usize>,
pub roi_set_interaction_mode: Option<(usize, RoiInteractionMode)>,
pub marker_moved: Option<crate::core::backend::ItemHandle>,
pub marker_drag_started: Option<crate::core::backend::ItemHandle>,
pub marker_drag_finished: Option<crate::core::backend::ItemHandle>,
pub pointer_event: Option<interaction::PlotPointerEvent>,
pub draw_event: Option<interaction::DrawEvent>,
pub interaction_mode: PlotInteractionMode,
pub colorbar_dragged_levels: Option<(f64, f64)>,
}
pub struct DrawResponse {
pub plot: PlotResponse,
pub event: Option<interaction::DrawEvent>,
}
#[derive(Default)]
pub struct PlotView<'a> {
menu_ext: Option<&'a mut dyn FnMut(&mut Ui)>,
}
impl<'a> PlotView<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn with_context_menu(mut self, ext: &'a mut dyn FnMut(&mut Ui)) -> Self {
self.menu_ext = Some(ext);
self
}
pub fn show(self, ui: &mut Ui, plot: &mut Plot) -> PlotResponse {
self.show_with_interaction(ui, plot, PlotInteractionMode::Zoom)
}
pub fn zoom_back(&self, plot: &mut Plot) -> bool {
plot.zoom_back()
}
pub fn show_with_interaction(
self,
ui: &mut Ui,
plot: &mut Plot,
interaction_mode: PlotInteractionMode,
) -> PlotResponse {
let menu_ext = self.menu_ext;
let (rect, response) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
let with_colorbar = plot.colormap.is_some() && plot.show_colorbar;
let with_y2 = plot.y2.is_some();
let axes_displayed = plot.axes_displayed();
let chrome_request = chrome::ChromeRequest {
colorbar: with_colorbar,
colorbar_interactive: plot.colorbar_interactive,
y2: with_y2,
title: plot.needs_title_gutter(),
x_label: plot.needs_x_label_gutter(),
y_label: plot.needs_y_label_gutter(),
y2_label: plot.y2_label.is_some(),
axes_hidden: !axes_displayed,
extra: plot
.extra
.iter()
.map(|a| chrome::ExtraAxisChrome {
side: a.side,
label: a.label.is_some(),
})
.collect(),
};
let chrome_layout = chrome::layout(rect, &chrome_request);
let area = plot.margins.data_area(chrome_layout.data_area);
let view = plot.transform(area);
let Interaction {
selection,
roi_changed,
roi_created,
roi_removed,
roi_make_current,
roi_set_interaction_mode,
roi_preview,
marker_moved,
marker_drag_started,
marker_drag_finished,
draw_event,
pointer_event,
} = apply_interaction(ui, &response, plot, area, &view, interaction_mode, menu_ext);
let transform = plot.transform(area);
let transform_right = plot.transform_y2(area);
let ortho_left = transform.ortho_matrix();
let axis_log_left = axis_log_flags(&transform);
let (ortho_right, axis_log_right) = match &transform_right {
Some(t) => (t.ortho_matrix(), axis_log_flags(t)),
None => (ortho_left, axis_log_left),
};
let extra_transforms: Vec<Option<crate::core::transform::Transform>> =
(0..plot.extra.len())
.map(|i| plot.transform_extra(i, area))
.collect();
let mut ortho_extra = Vec::with_capacity(extra_transforms.len());
let mut axis_log_extra = Vec::with_capacity(extra_transforms.len());
for t in &extra_transforms {
match t {
Some(t) => {
ortho_extra.push(t.ortho_matrix());
axis_log_extra.push(axis_log_flags(t));
}
None => {
ortho_extra.push(ortho_left);
axis_log_extra.push(axis_log_left);
}
}
}
let ppp = ui.ctx().pixels_per_point();
let viewport_px = [area.width() * ppp, area.height() * ppp];
let bg = egui::Rgba::from(plot.data_background).to_array();
let style = chrome::Style::from_visuals(ui.visuals())
.with_overrides(plot.foreground, plot.grid_color);
let painter = ui.painter();
painter.add(egui_wgpu::Callback::new_paint_callback(
area,
ClearCallback {
color: bg,
plot_id: plot.id,
},
));
painter.add(egui_wgpu::Callback::new_paint_callback(
area,
ImageCallback {
ortho: ortho_left,
axis_log: axis_log_left,
plot_id: plot.id,
},
));
let decimate_columns = if transform.x.scale == Scale::Linear {
viewport_px[0].ceil() as u32
} else {
0
};
painter.add(egui_wgpu::Callback::new_paint_callback(
area,
CurveCallback {
ortho_left,
axis_log_left,
ortho_right,
axis_log_right,
ortho_extra,
axis_log_extra,
viewport_px,
x_window: (transform.x.min, transform.x.max),
decimate_columns,
plot_id: plot.id,
},
));
if !plot.triangles.is_empty() {
chrome::draw_triangles(painter, &transform, &plot.triangles);
}
if axes_displayed {
chrome::draw_axes_with_x_tick_mode(
painter,
&transform,
&style,
plot.grid,
plot.x_max_ticks,
plot.y_max_ticks,
plot.x_tick_mode(),
plot.x_time_zone(),
plot.x_time_offset(),
);
if let Some(t_right) = &transform_right {
chrome::draw_y2_ticks(painter, t_right, &style);
}
for (i, slot) in chrome_layout.extra.iter().enumerate() {
if let Some(Some(t)) = extra_transforms.get(i) {
chrome::draw_extra_y_ticks(
painter,
t,
slot.side,
slot.baseline_x,
slot.label_x,
plot.extra[i].label.as_deref(),
&style,
);
}
}
}
let mut colorbar_dragged_levels = None;
if let (Some(cbar), Some(cmap)) = (chrome_layout.colorbar, plot.colormap.as_ref()) {
if plot.colorbar_interactive {
let range = plot.colorbar_value_range.unwrap_or((cmap.vmin, cmap.vmax));
let bar = crate::widget::histogram_colorbar::HistogramColorBar::new(cmap.clone())
.with_data_range(range)
.with_histogram(plot.colorbar_histogram.clone())
.with_levels(cmap.vmin, cmap.vmax)
.with_bar_bounds(cbar.top(), cbar.bottom());
colorbar_dragged_levels = bar.ui_at(ui, cbar).dragged_levels;
} else {
chrome::draw_colorbar(painter, cbar, cmap, &style);
}
}
if axes_displayed {
let x_label = plot.displayed_x_label();
let y_label = plot.displayed_y_label();
let y2_label = plot.displayed_y2_label();
chrome::draw_labels(
painter,
rect,
area,
&chrome::Labels {
title: plot.title.as_deref(),
x: x_label.as_deref(),
y: y_label.as_deref(),
y2: y2_label.as_deref(),
},
with_y2,
&style,
);
}
if plot.shapes.iter().any(|s| !s.is_overlay) {
chrome::draw_shapes(painter, &transform, &plot.shapes, false);
}
if plot.lines().iter().any(|l| !l.is_overlay) {
chrome::draw_lines(painter, &transform, plot.lines(), false);
}
if !plot.rois.is_empty() {
chrome::draw_rois(painter, &transform, &plot.rois, plot.roi_color, &style);
}
if plot.shapes.iter().any(|s| s.is_overlay) {
chrome::draw_shapes(painter, &transform, &plot.shapes, true);
}
if plot.lines().iter().any(|l| l.is_overlay) {
chrome::draw_lines(painter, &transform, plot.lines(), true);
}
if !plot.markers.is_empty() {
chrome::draw_markers(painter, &transform, transform_right.as_ref(), &plot.markers);
}
if plot.crosshair
&& let Some(p) = response.hover_pos()
&& area.contains(p)
{
chrome::draw_crosshair(
painter,
&transform,
p,
&style,
plot.x_tick_mode(),
plot.x_time_offset(),
plot.x_time_zone(),
);
}
if let Some(sel) = selection {
let corners = [
sel.left_top(),
sel.right_top(),
sel.right_bottom(),
sel.left_bottom(),
];
draw_selection_polygon(
painter,
&corners,
true,
interaction::SelectionStyle::new(interaction::FillMode::None, style.axis),
);
}
if let Some((mode, points)) = &roi_preview {
let sel_style = interaction::SelectionStyle::default();
draw_overlay(ui.painter(), &transform, *mode, points, sel_style);
if *mode == interaction::DrawMode::Polygon
&& let Some(&first) = points.first()
{
draw_polygon_first_point(
ui.painter(),
&transform,
first,
interaction::DRAW_CLOSE_THRESHOLD_PX,
sel_style.color,
);
}
}
PlotResponse {
response,
transform,
roi_changed,
roi_created,
roi_removed,
roi_make_current,
roi_set_interaction_mode,
marker_moved,
marker_drag_started,
marker_drag_finished,
pointer_event,
draw_event,
interaction_mode,
colorbar_dragged_levels,
}
}
pub fn show_with_draw(
self,
ui: &mut Ui,
plot: &mut Plot,
draw: &mut interaction::DrawState,
style: interaction::SelectionStyle,
) -> DrawResponse {
let mut plot_response =
PlotView::new().show_with_interaction(ui, plot, PlotInteractionMode::Select);
let response = &plot_response.response;
let transform = plot_response.transform;
let event = feed_draw_state(draw, response, &transform);
paint_draw_preview(ui.painter(), &transform, draw, event.as_ref(), style);
plot_response.draw_event = event.clone();
DrawResponse {
plot: plot_response,
event,
}
}
}
fn draw_overlay(
painter: &egui::Painter,
transform: &Transform,
mode: interaction::DrawMode,
points: &[(f64, f64)],
style: interaction::SelectionStyle,
) {
use interaction::DrawMode;
if points.is_empty() {
return;
}
let pix: Vec<Pos2> = points
.iter()
.map(|&(x, y)| transform.data_to_pixel(x, y))
.collect();
let closed = !matches!(mode, DrawMode::FreeHand | DrawMode::Line);
draw_selection_polygon(painter, &pix, closed, style);
}
fn draw_selection_polygon(
painter: &egui::Painter,
pix: &[Pos2],
closed: bool,
style: interaction::SelectionStyle,
) {
use interaction::FillMode;
if pix.is_empty() {
return;
}
if closed && pix.len() >= 3 {
let bb = Rect::from_points(pix);
match style.fill {
FillMode::Solid => {
let fill = crate::core::color::with_alpha(style.color, style.color.a() / 2);
painter.add(egui::Shape::convex_polygon(
pix.to_vec(),
fill,
Stroke::NONE,
));
}
FillMode::Hatch => {
let clipped = painter.with_clip_rect(bb);
for (a, b) in interaction::hatch_lines(bb, 6.0) {
clipped.line_segment([a, b], Stroke::new(1.0, style.color));
}
}
FillMode::None => {}
}
}
let stroke = Stroke::new(1.5, style.color);
let mut outline = pix.to_vec();
if closed && outline.len() >= 2 {
outline.push(outline[0]);
}
painter.add(egui::Shape::dashed_line(&outline, stroke, 6.0, 4.0));
}
fn draw_polygon_first_point(
painter: &egui::Painter,
transform: &Transform,
first: (f64, f64),
threshold_px: f32,
color: egui::Color32,
) {
let corners = polygon_first_point_box(transform, first, threshold_px);
draw_selection_polygon(
painter,
&corners,
true,
interaction::SelectionStyle::new(interaction::FillMode::None, color),
);
}
fn polygon_first_point_box(
transform: &Transform,
first: (f64, f64),
threshold_px: f32,
) -> [Pos2; 4] {
let c = transform.data_to_pixel(first.0, first.1);
let off = threshold_px;
[
Pos2::new(c.x - off, c.y - off),
Pos2::new(c.x - off, c.y + off),
Pos2::new(c.x + off, c.y + off),
Pos2::new(c.x + off, c.y - off),
]
}
pub(crate) fn paint_draw_preview(
painter: &egui::Painter,
transform: &Transform,
draw: &interaction::DrawState,
event: Option<&interaction::DrawEvent>,
style: interaction::SelectionStyle,
) {
if let Some(points) = draw.preview() {
draw_overlay(painter, transform, draw.mode(), &points, style);
if draw.mode() == interaction::DrawMode::Polygon
&& let Some(&first) = points.first()
{
draw_polygon_first_point(
painter,
transform,
first,
draw.close_threshold_px(),
style.color,
);
}
} else if let Some(interaction::DrawEvent::InProgress { mode, points }) = event {
draw_overlay(painter, transform, *mode, points, style);
}
}
pub(crate) fn feed_draw_state(
draw: &mut interaction::DrawState,
response: &egui::Response,
transform: &Transform,
) -> Option<interaction::DrawEvent> {
let mut event = None;
if let Some(p) = response.interact_pointer_pos() {
let input = interaction::DrawInput::from_pixel(transform, p);
if response.drag_started_by(PointerButton::Primary) || response.clicked() {
event = draw.on_press(input).or(event);
}
if response.dragged_by(PointerButton::Primary) {
event = draw.on_move(input).or(event);
}
if response.drag_stopped_by(PointerButton::Primary) || response.clicked() {
event = draw.on_release(input).or(event);
}
}
if !response.is_pointer_button_down_on()
&& let Some(p) = response.hover_pos()
&& draw.is_active()
{
let input = interaction::DrawInput::from_pixel(transform, p);
event = draw.on_move(input).or(event);
}
event
}
fn axis_log_flags(t: &crate::core::transform::Transform) -> [f32; 2] {
[
f32::from(t.x.scale == Scale::Log10),
f32::from(t.y.scale == Scale::Log10),
]
}
fn mode_grabs_roi_edge(mode: PlotInteractionMode) -> bool {
!matches!(
mode,
PlotInteractionMode::Pan
| PlotInteractionMode::MaskDraw
| PlotInteractionMode::RoiCreate(_)
)
}
fn mode_allows_marker_drag(mode: PlotInteractionMode) -> bool {
!matches!(
mode,
PlotInteractionMode::MaskDraw | PlotInteractionMode::RoiCreate(_)
)
}
#[cfg(test)]
fn primary_drag_gestures(mode: PlotInteractionMode) -> (bool, bool, bool) {
(
mode == PlotInteractionMode::Pan, mode == PlotInteractionMode::Zoom, mode_grabs_roi_edge(mode), )
}
fn apply_interaction(
ui: &Ui,
response: &egui::Response,
plot: &mut Plot,
area: Rect,
view: &crate::core::transform::Transform,
mode: PlotInteractionMode,
menu_ext: Option<&mut dyn FnMut(&mut Ui)>,
) -> Interaction {
let base = (view.x.min, view.x.max, view.y.min, view.y.max);
if response.has_focus() && plot.pan_with_arrow_keys() {
for (key, dir) in [
(egui::Key::ArrowLeft, interaction::PanDirection::Left),
(egui::Key::ArrowRight, interaction::PanDirection::Right),
(egui::Key::ArrowUp, interaction::PanDirection::Up),
(egui::Key::ArrowDown, interaction::PanDirection::Down),
] {
if ui.input(|i| i.key_pressed(key)) {
arrow_pan(plot, dir);
}
}
}
let id = response.id;
let press_anchor = ui
.input(|i| i.pointer.press_origin())
.or_else(|| response.interact_pointer_pos());
let marker_id = id.with("marker-drag");
let mut marker_moved = None;
let mut marker_drag_started = None;
let mut marker_drag_finished = None;
if mode_allows_marker_drag(mode)
&& response.drag_started_by(PointerButton::Primary)
&& let Some(p) = press_anchor
&& let Some(index) = interaction::marker_at(&plot.markers, view, p)
&& let Some(&handle) = plot.marker_handles.get(index)
{
let anchor = plot.markers[index].position();
ui.data_mut(|d| d.insert_temp(marker_id, MarkerDrag { handle, anchor }));
marker_drag_started = Some(handle);
}
let marker_dragging = ui
.data_mut(|d| d.get_temp::<MarkerDrag>(marker_id))
.is_some();
if let Some(md) = ui.data_mut(|d| d.get_temp::<MarkerDrag>(marker_id)) {
if response.dragged_by(PointerButton::Primary)
&& let Some(cur) = response.interact_pointer_pos()
&& let Some(index) = plot.marker_handles.iter().position(|&h| h == md.handle)
&& let Some(marker) = plot.markers.get_mut(index)
{
marker.drag(md.anchor, view.pixel_to_data(cur));
marker_moved = Some(md.handle);
}
if response.drag_stopped_by(PointerButton::Primary) {
ui.data_mut(|d| d.remove::<MarkerDrag>(marker_id));
marker_drag_finished = Some(md.handle);
}
}
let primary_pan = mode == PlotInteractionMode::Pan
&& !marker_dragging
&& response.dragged_by(PointerButton::Primary);
if response.dragged_by(PointerButton::Secondary) || primary_pan {
if response.drag_started() {
plot.push_limits();
}
let delta = ui.input(|i| i.pointer.delta());
if delta != egui::Vec2::ZERO {
let (next, next_y2) =
interaction::pan_view((base, plot.y2), area, delta, plot.x_scale, plot.y_scale);
commit(plot, next, next_y2);
}
}
if plot.scroll_zoom {
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if plot.reset_scroll_guard {
if scroll == 0.0 {
plot.reset_scroll_guard = false;
}
} else if response.hovered()
&& scroll != 0.0
&& let Some(p) = response.hover_pos()
&& area.contains(p)
{
let enabled = if plot.keep_aspect {
(true, true)
} else {
(plot.zoom_x_enabled(), plot.zoom_y_enabled())
};
if enabled != (false, false) {
let centre = view.pixel_to_data(p);
let cy2 = plot.transform_y2(area).map(|t| t.pixel_to_data(p).1);
let factor = interaction::wheel_zoom_factor(scroll);
let (next, next_y2) = interaction::zoom_view_about(
(base, plot.y2),
factor,
centre,
cy2,
plot.x_scale,
plot.y_scale,
enabled,
);
commit(plot, next, next_y2);
}
}
}
let roi_id = id.with("roi-drag");
let mut roi_changed = None;
if mode_grabs_roi_edge(mode)
&& !marker_dragging
&& response.drag_started_by(PointerButton::Primary)
&& let Some(p) = press_anchor
{
let grabbed =
interaction::roi_grab_at(&plot.rois, view, p, ROI_GRAB_PX).map(|(roi, grab)| RoiDrag {
roi,
grab,
last_data: view.pixel_to_data(p),
});
match grabbed {
Some(rd) => ui.data_mut(|d| {
d.insert_temp(roi_id, rd);
}),
None if mode == PlotInteractionMode::Zoom => ui.data_mut(|d| {
d.insert_temp(id, p);
}),
None => {}
}
}
let mut selection = None;
if let Some(mut rd) = ui.data_mut(|d| d.get_temp::<RoiDrag>(roi_id)) {
if !mode_grabs_roi_edge(mode) {
ui.data_mut(|d| d.remove::<RoiDrag>(roi_id));
} else {
if response.dragged_by(PointerButton::Primary)
&& let Some(cur) = response.interact_pointer_pos()
&& let Some(managed) = plot.rois.get_mut(rd.roi)
{
let cur_data = view.pixel_to_data(cur);
match rd.grab {
RoiGrab::Edge(edge) => {
interaction::roi_apply_edge_drag(managed, edge, cur_data)
}
RoiGrab::Translate => {
managed
.roi
.translate(cur_data.0 - rd.last_data.0, cur_data.1 - rd.last_data.1);
rd.last_data = cur_data;
ui.data_mut(|d| d.insert_temp(roi_id, rd));
}
}
roi_changed = Some(rd.roi);
}
if response.drag_stopped_by(PointerButton::Primary) {
ui.data_mut(|d| d.remove::<RoiDrag>(roi_id));
}
}
} else if !marker_dragging {
if mode == PlotInteractionMode::Zoom && response.dragged_by(PointerButton::Primary) {
let start = ui.data_mut(|d| d.get_temp::<Pos2>(id));
if let (Some(start), Some(cur)) = (start, response.interact_pointer_pos()) {
selection = Some(Rect::from_two_pos(start, cur));
}
}
if mode == PlotInteractionMode::Zoom && response.drag_stopped_by(PointerButton::Primary) {
let start = ui.data_mut(|d| {
let s = d.get_temp::<Pos2>(id);
d.remove::<Pos2>(id);
s
});
if let (Some(start), Some(end)) = (start, response.interact_pointer_pos()) {
let (dx, dy) = ((start.x - end.x).abs(), (start.y - end.y).abs());
if dx * dy >= 5.0 {
plot.push_limits();
let (ax, ay) = view.pixel_to_data(start);
let (bx, by) = view.pixel_to_data(end);
let boxed = interaction::box_zoom(ax, ay, bx, by);
let current = (view.x.min, view.x.max, view.y.min, view.y.max);
let next = if plot.keep_aspect {
boxed
} else {
interaction::constrain_zoom_axes(
boxed,
current,
plot.zoom_x_enabled(),
plot.zoom_y_enabled(),
)
};
commit(plot, next, plot.y2);
}
}
}
}
let mut roi_created = None;
let mut roi_preview = None;
let mut draw_event = None;
let roi_key = if matches!(mode, PlotInteractionMode::RoiCreate(_)) {
ui.input_mut(|i| {
let mut action = None;
i.events.retain(|e| {
if action.is_none()
&& let egui::Event::Key {
key,
pressed: true,
modifiers,
..
} = e
&& let Some(a) = interaction::roi_key_action(*key, modifiers.command)
{
action = Some(a);
return false; }
true
});
action
})
} else {
None
};
if let PlotInteractionMode::RoiCreate(kind) = mode {
let draw_id = id.with("roi-draw");
let mut draw = ui
.data_mut(|d| d.get_temp::<interaction::DrawState>(draw_id))
.unwrap_or_else(|| interaction::DrawState::new(interaction::roi_draw_mode(kind)));
let event = if roi_key == Some(interaction::RoiKeyAction::Validate) {
draw.validate()
} else {
feed_draw_state(&mut draw, response, view)
};
if let Some(interaction::DrawEvent::Finished { params, .. }) = &event {
if let Some(roi) = interaction::roi_from_draw(kind, params) {
plot.rois.push(ManagedRoi::new(roi));
roi_created = Some(plot.rois.len() - 1);
}
draw = interaction::DrawState::new(interaction::roi_draw_mode(kind));
} else if let Some(points) = draw.preview() {
roi_preview = Some((draw.mode(), points));
} else if let Some(interaction::DrawEvent::InProgress { mode: m, points }) = &event {
roi_preview = Some((*m, points.clone()));
}
ui.data_mut(|d| d.insert_temp(draw_id, draw));
draw_event = event;
}
let mut marker_cursor_set = false;
if mode_allows_marker_drag(mode) {
let cursor_marker = if marker_dragging {
ui.data_mut(|d| d.get_temp::<MarkerDrag>(marker_id))
.and_then(|md| plot.marker_handles.iter().position(|&h| h == md.handle))
.and_then(|i| plot.markers.get(i))
} else if let Some(p) = response.hover_pos().filter(|p| area.contains(*p)) {
interaction::marker_at(&plot.markers, view, p).and_then(|i| plot.markers.get(i))
} else {
None
};
if let Some(marker) = cursor_marker {
ui.ctx()
.set_cursor_icon(interaction::marker_cursor(marker).to_egui());
marker_cursor_set = true;
}
}
if mode_grabs_roi_edge(mode)
&& !marker_cursor_set
&& selection.is_none()
&& !response.dragged_by(PointerButton::Primary)
&& let Some(p) = response.hover_pos()
&& area.contains(p)
{
let grabbed = plot
.rois
.iter()
.rev()
.find_map(|managed| managed.roi.edge_at(view, p, ROI_GRAB_PX));
let shape = interaction::cursor_for_grab(grabbed, view);
if shape != interaction::CursorShape::Default {
ui.ctx().set_cursor_icon(shape.to_egui());
}
}
let mut roi_removed = match roi_key {
Some(interaction::RoiKeyAction::UndoLast) if !plot.rois.is_empty() => {
Some(plot.rois.len() - 1)
}
_ => None,
};
let mut roi_make_current = None;
let mut roi_set_interaction_mode = None;
let roi_menu_id = response.id.with("roi_context_target");
if response.secondary_clicked()
&& let Some(p) = response.interact_pointer_pos()
{
let target = interaction::roi_grab_at(&plot.rois, view, p, ROI_GRAB_PX).map(|(i, _)| i);
ui.data_mut(|d| d.insert_temp(roi_menu_id, target));
}
response.context_menu(|ui| {
let target: Option<usize> = ui.data(|d| d.get_temp(roi_menu_id)).flatten();
if let Some(index) = target
&& index < plot.rois.len()
{
let name = &plot.rois[index].name;
let title = if name.is_empty() {
format!("ROI #{index}")
} else {
name.clone()
};
ui.label(title);
if ui.button("Make current").clicked() {
roi_make_current = Some(index);
ui.close();
}
let managed = &plot.rois[index];
let modes = managed.roi.available_interaction_modes();
if !modes.is_empty() {
let current = managed.interaction_mode();
ui.menu_button("Interaction mode", |ui| {
for &mode in modes {
if ui
.radio(current == Some(mode), mode.label())
.on_hover_text(mode.description())
.clicked()
{
roi_set_interaction_mode = Some((index, mode));
ui.close();
}
}
});
}
if ui.button("Remove").clicked() {
roi_removed = Some(index);
ui.close();
}
ui.separator();
}
if ui.button("Zoom Back").clicked() {
if !plot.zoom_back() {
plot.reset_zoom();
}
ui.close();
}
if ui.button("Reset Zoom").clicked() {
plot.reset_zoom();
ui.close();
}
if let Some(ext) = menu_ext {
ui.separator();
ext(ui);
}
});
let pointer_event = detect_pointer_event(response, view, area);
Interaction {
selection,
roi_changed,
roi_created,
roi_removed,
roi_make_current,
roi_set_interaction_mode,
roi_preview,
marker_moved,
marker_drag_started,
marker_drag_finished,
draw_event,
pointer_event,
}
}
fn detect_pointer_event(
response: &egui::Response,
view: &Transform,
area: Rect,
) -> Option<interaction::PlotPointerEvent> {
use interaction::{MouseButton, PlotPointerEvent};
if let Some(p) = response.interact_pointer_pos()
&& area.contains(p)
{
if response.double_clicked() {
return Some(PlotPointerEvent::double_clicked(MouseButton::Left, view, p));
}
for button in [
egui::PointerButton::Primary,
egui::PointerButton::Secondary,
egui::PointerButton::Middle,
] {
if response.clicked_by(button) {
return Some(PlotPointerEvent::clicked(
MouseButton::from_egui(button),
view,
p,
));
}
}
}
if let Some(p) = response.hover_pos()
&& area.contains(p)
&& ui_pointer_moved(response)
{
let button = if response.dragged_by(egui::PointerButton::Primary) {
Some(MouseButton::Left)
} else if response.dragged_by(egui::PointerButton::Secondary) {
Some(MouseButton::Right)
} else if response.dragged_by(egui::PointerButton::Middle) {
Some(MouseButton::Middle)
} else {
None
};
return Some(PlotPointerEvent::moved(button, view, p));
}
None
}
fn ui_pointer_moved(response: &egui::Response) -> bool {
response
.ctx
.input(|i| i.pointer.delta() != egui::Vec2::ZERO)
}
fn arrow_pan(plot: &mut Plot, dir: interaction::PanDirection) {
use interaction::PanDirection::*;
const FACTOR: f64 = 0.1;
let x_is_log = plot.x_scale == Scale::Log10;
let y_is_log = plot.y_scale == Scale::Log10;
let (x_min, x_max, y_min, y_max) = plot.limits;
match dir {
Left | Right => {
let x_factor = if dir == Right { FACTOR } else { -FACTOR };
let (nx0, nx1) = interaction::apply_pan(x_min, x_max, x_factor, x_is_log);
commit(plot, (nx0, nx1, y_min, y_max), plot.y2);
}
Up | Down => {
let sign = if plot.y_inverted { -1.0 } else { 1.0 };
let y_factor = sign * if dir == Up { FACTOR } else { -FACTOR };
let (ny0, ny1) = interaction::apply_pan(y_min, y_max, y_factor, y_is_log);
let next_y2 = plot
.y2
.map(|(lo, hi)| interaction::apply_pan(lo, hi, y_factor, y_is_log));
commit(plot, (x_min, x_max, ny0, ny1), next_y2);
}
}
}
fn commit(plot: &mut Plot, next: interaction::Limits, next_y2: Option<(f64, f64)>) {
let next = interaction::clamp_limits(
next,
plot.x_scale == Scale::Log10,
plot.y_scale == Scale::Log10,
);
if !interaction::is_valid(next) {
return;
}
let (x0, x1, y0, y1) = next;
let (x0, x1) = plot.x_constraints.apply(x0, x1);
let (y0, y1) = plot.y_constraints.apply(y0, y1);
let constrained = (x0, x1, y0, y1);
if interaction::is_valid(constrained) {
plot.limits = constrained;
plot.y2 = next_y2
.map(|(lo, hi)| interaction::clamp_axis_limits(lo, hi, plot.y_scale == Scale::Log10));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::plot::Plot;
fn run_frame(
ctx: &egui::Context,
plot: &mut Plot,
raw: egui::RawInput,
) -> (PlotResponse, Rect) {
let mut captured: Option<(PlotResponse, Rect)> = None;
let _ = ctx.run_ui(raw, |ui| {
let resp = PlotView::new().show(ui, plot);
let area = resp.transform.area;
captured = Some((resp, area));
});
captured.expect("ui ran")
}
#[test]
fn roi_creation_message_only_while_creating() {
use interaction::RoiDrawKind;
assert_eq!(
PlotInteractionMode::RoiCreate(RoiDrawKind::Rect).roi_creation_message(0),
Some("Select rectangles (0 selected)".to_owned())
);
assert_eq!(
PlotInteractionMode::RoiCreate(RoiDrawKind::Polygon).roi_creation_message(3),
Some("Select polygons (3 selected)".to_owned())
);
for mode in [
PlotInteractionMode::Zoom,
PlotInteractionMode::Pan,
PlotInteractionMode::Select,
PlotInteractionMode::MaskDraw,
] {
assert_eq!(mode.roi_creation_message(2), None, "{mode:?}");
}
}
#[test]
fn interactive_colorbar_renders_and_reserves_wider_gutter() {
let ctx = egui::Context::default();
let screen = egui::vec2(400.0, 300.0);
let mut static_plot = Plot::new(0);
static_plot.limits = (0.0, 10.0, 0.0, 10.0);
static_plot.colormap = Some(crate::core::colormap::Colormap::viridis(0.0, 1.0));
let (sresp, sarea) = run_frame(&ctx, &mut static_plot, screen_input(screen));
assert!(sresp.colorbar_dragged_levels.is_none());
let mut plot = Plot::new(1);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.colormap = Some(crate::core::colormap::Colormap::viridis(0.0, 1.0));
plot.colorbar_interactive = true;
plot.colorbar_value_range = Some((0.0, 1.0));
plot.colorbar_histogram = Some((vec![3, 7, 2], vec![0.0, 0.33, 0.66, 1.0]));
let (resp, area) = run_frame(&ctx, &mut plot, screen_input(screen));
assert!(resp.colorbar_dragged_levels.is_none());
assert!(
area.right() < sarea.right(),
"interactive {area:?} vs static {sarea:?}"
);
}
#[test]
fn context_menu_extension_renders_inside_the_single_builtin_menu() {
fn collect_texts(shape: &egui::Shape, out: &mut Vec<String>) {
match shape {
egui::Shape::Text(t) => out.push(t.galley.text().to_owned()),
egui::Shape::Vec(v) => v.iter().for_each(|s| collect_texts(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let screen = egui::vec2(400.0, 300.0);
let pos = egui::pos2(200.0, 150.0);
let mut frame = |events: Vec<egui::Event>| {
let raw = egui::RawInput {
events,
..screen_input(screen)
};
ctx.run_ui(raw, |ui| {
let mut ext = |ui: &mut Ui| {
let _ = ui.button("Custom entry");
};
let _ = PlotView::new()
.with_context_menu(&mut ext)
.show(ui, &mut plot);
})
};
let _ = frame(vec![egui::Event::PointerMoved(pos)]);
let _ = frame(vec![egui::Event::PointerButton {
pos,
button: egui::PointerButton::Secondary,
pressed: true,
modifiers: egui::Modifiers::default(),
}]);
let _ = frame(vec![egui::Event::PointerButton {
pos,
button: egui::PointerButton::Secondary,
pressed: false,
modifiers: egui::Modifiers::default(),
}]);
let output = frame(Vec::new());
let mut texts = Vec::new();
for clipped in &output.shapes {
collect_texts(&clipped.shape, &mut texts);
}
assert!(
texts.iter().any(|t| t == "Custom entry"),
"custom menu entry not rendered; menu texts: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "Reset Zoom"),
"built-in menu item missing; menu texts: {texts:?}"
);
}
fn run_draw_frame(
ctx: &egui::Context,
plot: &mut Plot,
draw: &mut interaction::DrawState,
raw: egui::RawInput,
) -> (DrawResponse, Rect) {
let mut captured: Option<(DrawResponse, Rect)> = None;
let _ = ctx.run_ui(raw, |ui| {
let resp = PlotView::new().show_with_draw(
ui,
plot,
draw,
interaction::SelectionStyle::default(),
);
let area = resp.plot.transform.area;
captured = Some((resp, area));
});
captured.expect("ui ran")
}
fn screen_input(screen: egui::Vec2) -> egui::RawInput {
egui::RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, screen)),
..Default::default()
}
}
#[test]
fn wheel_zoom_suppressed_after_reset_until_scroll_settles() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_frame(&ctx, &mut plot, screen_input(screen));
let c = area.center();
let mut warm = screen_input(screen);
warm.events.push(egui::Event::PointerMoved(c));
let _ = run_frame(&ctx, &mut plot, warm);
let wheel_at = |dy: f32| {
let mut f = screen_input(screen);
f.events.push(egui::Event::PointerMoved(c));
f.events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, dy),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::default(),
});
f
};
let restored = (0.0, 10.0, 0.0, 10.0);
plot.limits = restored;
plot.reset_scroll_guard = true;
let _ = run_frame(&ctx, &mut plot, wheel_at(7.0));
assert_eq!(
plot.limits, restored,
"momentum must not re-zoom the reset view"
);
assert!(
plot.reset_scroll_guard,
"guard stays armed while scroll is non-zero"
);
let mut park = screen_input(screen);
park.events.push(egui::Event::PointerMoved(c));
let _ = run_frame(&ctx, &mut plot, park);
assert_eq!(plot.limits, restored, "settling does not change the view");
assert!(
!plot.reset_scroll_guard,
"guard disarms once the scroll settles to zero"
);
let _ = run_frame(&ctx, &mut plot, wheel_at(7.0));
assert_ne!(
plot.limits, restored,
"a fresh scroll after settling zooms again"
);
}
#[test]
fn scroll_zoom_disabled_ignores_the_wheel() {
let ctx = egui::Context::default();
let screen = egui::vec2(200.0, 200.0);
let start = (0.0, 10.0, 0.0, 10.0);
let wheel_frame = |ctx: &egui::Context, plot: &mut Plot, c: Pos2| {
let mut f = screen_input(screen);
f.events.push(egui::Event::PointerMoved(c));
f.events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, 7.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::default(),
});
run_frame(ctx, plot, f)
};
let mut off = Plot::new(0);
off.limits = start;
off.scroll_zoom = false;
let (_r0, area) = run_frame(&ctx, &mut off, screen_input(screen));
let c = area.center();
let mut warm = screen_input(screen);
warm.events.push(egui::Event::PointerMoved(c));
let _ = run_frame(&ctx, &mut off, warm);
let _ = wheel_frame(&ctx, &mut off, c);
assert_eq!(
off.limits, start,
"wheel must not zoom when scroll_zoom is disabled"
);
let mut on = Plot::new(0);
on.limits = start;
let (_r1, area) = run_frame(&ctx, &mut on, screen_input(screen));
let c = area.center();
let mut warm = screen_input(screen);
warm.events.push(egui::Event::PointerMoved(c));
let _ = run_frame(&ctx, &mut on, warm);
let _ = wheel_frame(&ctx, &mut on, c);
assert_ne!(
on.limits, start,
"wheel zooms normally when scroll_zoom is enabled"
);
}
#[test]
fn wheel_zoom_scales_y2_with_left_axes() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.y2 = Some((0.0, 100.0));
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_frame(&ctx, &mut plot, screen_input(screen));
let c = area.center();
let mut warm = screen_input(screen);
warm.events.push(egui::Event::PointerMoved(c));
let _ = run_frame(&ctx, &mut plot, warm);
let mut f = screen_input(screen);
f.events.push(egui::Event::PointerMoved(c));
f.events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, 7.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::default(),
});
let _ = run_frame(&ctx, &mut plot, f);
let (_, _, y0, y1) = plot.limits;
let (a, b) = plot.y2.expect("y2 axis still present");
assert_ne!((a, b), (0.0, 100.0), "wheel zoom must scale y2");
let ratio_left = (y1 - y0) / 10.0;
let ratio_y2 = (b - a) / 100.0;
assert!(
(ratio_left - ratio_y2).abs() <= 1e-9,
"left {ratio_left} vs y2 {ratio_y2}"
);
assert!(ratio_left < 1.0, "scroll up zooms in");
}
fn drive_wheel_at_centre(ctx: &egui::Context, plot: &mut Plot, scroll_y: f32) {
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_frame(ctx, plot, screen_input(screen));
let c = area.center();
let mut warm = screen_input(screen);
warm.events.push(egui::Event::PointerMoved(c));
let _ = run_frame(ctx, plot, warm);
let mut f = screen_input(screen);
f.events.push(egui::Event::PointerMoved(c));
f.events.push(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, scroll_y),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::default(),
});
let _ = run_frame(ctx, plot, f);
}
#[test]
fn wheel_zoom_does_not_push_limits_history() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
drive_wheel_at_centre(&ctx, &mut plot, 7.0);
assert_ne!(plot.limits, (0.0, 10.0, 0.0, 10.0), "the wheel zoomed");
assert_eq!(plot.limits_history_len(), 0, "but pushed no history");
}
#[test]
fn wheel_zoom_honours_zoom_enabled_axes() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.y2 = Some((0.0, 100.0));
plot.set_zoom_enabled_axes(true, false);
drive_wheel_at_centre(&ctx, &mut plot, 7.0);
let (x0, x1, y0, y1) = plot.limits;
assert!(x1 - x0 < 10.0, "X zooms: {:?}", plot.limits);
assert_eq!((y0, y1), (0.0, 10.0), "disabled Y keeps its range");
assert_eq!(plot.y2, Some((0.0, 100.0)), "y2 follows the Y flag");
}
#[test]
fn wheel_zoom_with_all_axes_disabled_does_nothing() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.set_zoom_enabled_axes(false, false);
drive_wheel_at_centre(&ctx, &mut plot, 7.0);
assert_eq!(plot.limits, (0.0, 10.0, 0.0, 10.0));
assert_eq!(plot.limits_history_len(), 0, "no history push either");
}
#[test]
fn wheel_zoom_keep_aspect_overrides_disabled_axes() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.set_zoom_enabled_axes(false, false);
plot.keep_aspect = true;
drive_wheel_at_centre(&ctx, &mut plot, 400.0);
let (x0, x1, y0, y1) = plot.limits;
assert!(
x1 - x0 < 10.0,
"X zooms under keep-aspect: {:?}",
plot.limits
);
assert!(
y1 - y0 < 10.0,
"Y zooms under keep-aspect: {:?}",
plot.limits
);
}
#[test]
fn pan_drag_shifts_y2_with_left_axes() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.y2 = Some((0.0, 100.0));
let mode = PlotInteractionMode::Pan;
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let start = area.center();
let end = start + egui::vec2(0.0, 30.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, start));
let _ = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, end));
let _ = run_mode_frame(&ctx, &mut plot, mode, release_at(screen, end));
let (_, _, y0, y1) = plot.limits;
let (a, b) = plot.y2.expect("y2 axis still present");
assert_ne!((a, b), (0.0, 100.0), "drag pan must shift y2");
assert!((b - a - 100.0).abs() <= 1e-9, "y2 span preserved: {a} {b}");
assert!((y1 - y0 - 10.0).abs() <= 1e-9, "left span preserved");
let frac_left = y0 / 10.0;
let frac_y2 = a / 100.0;
assert!(
(frac_left - frac_y2).abs() <= 1e-9,
"left {frac_left} vs y2 {frac_y2}"
);
assert!(frac_left > 0.0, "downward drag raises the data Y window");
}
#[test]
fn click_emits_clicked_event_with_correct_data_coords() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_frame(&ctx, &mut plot, screen_input(screen));
let click_px = area.center();
let mut press = screen_input(screen);
press.events.push(egui::Event::PointerMoved(click_px));
press.events.push(egui::Event::PointerButton {
pos: click_px,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
});
let _ = run_frame(&ctx, &mut plot, press);
let mut release = screen_input(screen);
release.events.push(egui::Event::PointerButton {
pos: click_px,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
});
let (resp, _area3) = run_frame(&ctx, &mut plot, release);
let event = resp.pointer_event.expect("a pointer event on click frame");
match event {
interaction::PlotPointerEvent::Clicked {
button,
data,
pixel,
} => {
assert_eq!(button, interaction::MouseButton::Left);
let expected = resp.transform.pixel_to_data(click_px);
assert!(
(data.0 - expected.0).abs() < 1e-6,
"x {data:?} {expected:?}"
);
assert!(
(data.1 - expected.1).abs() < 1e-6,
"y {data:?} {expected:?}"
);
assert!((data.0 - 5.0).abs() < 0.5, "x≈5: {}", data.0);
assert!((data.1 - 5.0).abs() < 0.5, "y≈5: {}", data.1);
assert!((pixel.0 - click_px.x).abs() < 1e-3);
assert!((pixel.1 - click_px.y).abs() < 1e-3);
}
other => panic!("expected Clicked, got {other:?}"),
}
}
#[test]
fn bare_move_emits_moved_event() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_frame(&ctx, &mut plot, screen_input(screen));
let p0 = area.center();
let mut f2 = screen_input(screen);
f2.events.push(egui::Event::PointerMoved(p0));
let _ = run_frame(&ctx, &mut plot, f2);
let p1 = p0 + egui::vec2(7.0, -5.0);
let mut f3 = screen_input(screen);
f3.events.push(egui::Event::PointerMoved(p1));
let (resp, _area) = run_frame(&ctx, &mut plot, f3);
let event = resp.pointer_event.expect("a moved event");
match event {
interaction::PlotPointerEvent::Moved {
button,
data,
pixel,
} => {
assert_eq!(button, None);
let expected = resp.transform.pixel_to_data(p1);
assert!(
(data.0 - expected.0).abs() < 1e-6,
"x {data:?} {expected:?}"
);
assert!(
(data.1 - expected.1).abs() < 1e-6,
"y {data:?} {expected:?}"
);
assert!((pixel.0 - p1.x).abs() < 1e-3);
assert!((pixel.1 - p1.y).abs() < 1e-3);
}
other => panic!("expected Moved, got {other:?}"),
}
}
fn click_cycle(
ctx: &egui::Context,
plot: &mut Plot,
screen: egui::Vec2,
px: Pos2,
button: egui::PointerButton,
) -> PlotResponse {
let mut press = screen_input(screen);
press.events.push(egui::Event::PointerMoved(px));
press.events.push(egui::Event::PointerButton {
pos: px,
button,
pressed: true,
modifiers: egui::Modifiers::default(),
});
let _ = run_frame(ctx, plot, press);
let mut release = screen_input(screen);
release.events.push(egui::Event::PointerButton {
pos: px,
button,
pressed: false,
modifiers: egui::Modifiers::default(),
});
run_frame(ctx, plot, release).0
}
#[test]
fn right_and_middle_click_emit_clicked_with_correct_button() {
let screen = egui::vec2(200.0, 200.0);
for (button, expected) in [
(
egui::PointerButton::Secondary,
interaction::MouseButton::Right,
),
(
egui::PointerButton::Middle,
interaction::MouseButton::Middle,
),
] {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let (_r0, area) = run_frame(&ctx, &mut plot, screen_input(screen));
let px = area.center();
let resp = click_cycle(&ctx, &mut plot, screen, px, button);
match resp.pointer_event {
Some(interaction::PlotPointerEvent::Clicked {
button: got, data, ..
}) => {
assert_eq!(got, expected, "button for {button:?}");
let want = resp.transform.pixel_to_data(px);
assert!((data.0 - want.0).abs() < 1e-6, "x {data:?} {want:?}");
assert!((data.1 - want.1).abs() < 1e-6, "y {data:?} {want:?}");
}
other => panic!("expected Clicked({expected:?}), got {other:?}"),
}
}
}
#[test]
fn double_click_emits_double_clicked_event() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_frame(&ctx, &mut plot, screen_input(screen));
let px = area.center();
let button_frame = |pressed: bool, time: f64| {
let mut raw = screen_input(screen);
raw.time = Some(time);
raw.events.push(egui::Event::PointerMoved(px));
raw.events.push(egui::Event::PointerButton {
pos: px,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
});
raw
};
let _ = run_frame(&ctx, &mut plot, button_frame(true, 0.10));
let _ = run_frame(&ctx, &mut plot, button_frame(false, 0.12));
let _ = run_frame(&ctx, &mut plot, button_frame(true, 0.18));
let (resp, _) = run_frame(&ctx, &mut plot, button_frame(false, 0.20));
match resp.pointer_event {
Some(interaction::PlotPointerEvent::DoubleClicked { button, .. }) => {
assert_eq!(button, interaction::MouseButton::Left);
}
other => panic!("expected DoubleClicked, got {other:?}"),
}
}
#[test]
fn double_click_no_longer_resets_view() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_frame(&ctx, &mut plot, screen_input(screen));
let px = area.center();
plot.limits = (5.0, 15.0, 5.0, 15.0);
let button_frame = |pressed: bool, time: f64| {
let mut raw = screen_input(screen);
raw.time = Some(time);
raw.events.push(egui::Event::PointerMoved(px));
raw.events.push(egui::Event::PointerButton {
pos: px,
button: egui::PointerButton::Primary,
pressed,
modifiers: egui::Modifiers::default(),
});
raw
};
let _ = run_frame(&ctx, &mut plot, button_frame(true, 0.10));
let _ = run_frame(&ctx, &mut plot, button_frame(false, 0.12));
let _ = run_frame(&ctx, &mut plot, button_frame(true, 0.18));
let (resp, _) = run_frame(&ctx, &mut plot, button_frame(false, 0.20));
assert!(matches!(
resp.pointer_event,
Some(interaction::PlotPointerEvent::DoubleClicked { .. })
));
assert_eq!(plot.limits, (5.0, 15.0, 5.0, 15.0));
}
#[test]
fn show_with_draw_surfaces_finished_draw_event_through_plot_response() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let mut draw = interaction::DrawState::new(interaction::DrawMode::Line);
let screen = egui::vec2(200.0, 200.0);
let (_d0, area) = run_draw_frame(&ctx, &mut plot, &mut draw, screen_input(screen));
let p0 = area.center() - egui::vec2(20.0, 20.0);
let p1 = area.center() + egui::vec2(20.0, 20.0);
let mut f2 = screen_input(screen);
f2.events.push(egui::Event::PointerMoved(p0));
f2.events.push(egui::Event::PointerButton {
pos: p0,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
});
let _ = run_draw_frame(&ctx, &mut plot, &mut draw, f2);
let mut f3 = screen_input(screen);
f3.events.push(egui::Event::PointerMoved(p1));
let _ = run_draw_frame(&ctx, &mut plot, &mut draw, f3);
let mut f4 = screen_input(screen);
f4.events.push(egui::Event::PointerButton {
pos: p1,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
});
let (resp, _a) = run_draw_frame(&ctx, &mut plot, &mut draw, f4);
assert_eq!(resp.event, resp.plot.draw_event);
match resp.plot.draw_event {
Some(interaction::DrawEvent::Finished {
mode: interaction::DrawMode::Line,
..
}) => {}
other => panic!("expected Finished Line draw event, got {other:?}"),
}
}
#[test]
fn plain_show_leaves_draw_event_none_and_surfaces_mode() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
let (resp, _area) = run_frame(&ctx, &mut plot, screen_input(egui::vec2(200.0, 200.0)));
assert!(resp.draw_event.is_none());
assert_eq!(resp.interaction_mode, PlotInteractionMode::Zoom);
}
#[test]
fn mask_draw_reserves_primary_drag_for_painting() {
assert_ne!(PlotInteractionMode::MaskDraw, PlotInteractionMode::Pan);
assert_ne!(PlotInteractionMode::MaskDraw, PlotInteractionMode::Zoom);
assert_ne!(PlotInteractionMode::MaskDraw, PlotInteractionMode::Select);
assert_eq!(
primary_drag_gestures(PlotInteractionMode::MaskDraw),
(false, false, false),
"MaskDraw must fire no primary-drag plot gesture",
);
assert_eq!(
primary_drag_gestures(PlotInteractionMode::Pan),
(true, false, false),
);
assert_eq!(
primary_drag_gestures(PlotInteractionMode::Zoom),
(false, true, true),
);
assert_eq!(
primary_drag_gestures(PlotInteractionMode::Select),
(false, false, true),
);
assert!(!mode_grabs_roi_edge(PlotInteractionMode::MaskDraw));
assert!(!mode_grabs_roi_edge(PlotInteractionMode::Pan));
assert!(mode_grabs_roi_edge(PlotInteractionMode::Zoom));
assert!(mode_grabs_roi_edge(PlotInteractionMode::Select));
}
fn run_mode_frame(
ctx: &egui::Context,
plot: &mut Plot,
mode: PlotInteractionMode,
raw: egui::RawInput,
) -> (PlotResponse, Rect) {
let mut captured: Option<(PlotResponse, Rect)> = None;
let _ = ctx.run_ui(raw, |ui| {
let resp = PlotView::new().show_with_interaction(ui, plot, mode);
let area = resp.transform.area;
captured = Some((resp, area));
});
captured.expect("ui ran")
}
fn press_at(screen: egui::Vec2, p: Pos2) -> egui::RawInput {
let mut raw = screen_input(screen);
raw.events.push(egui::Event::PointerMoved(p));
raw.events.push(egui::Event::PointerButton {
pos: p,
button: egui::PointerButton::Primary,
pressed: true,
modifiers: egui::Modifiers::default(),
});
raw
}
fn move_to(screen: egui::Vec2, p: Pos2) -> egui::RawInput {
let mut raw = screen_input(screen);
raw.events.push(egui::Event::PointerMoved(p));
raw
}
fn release_at(screen: egui::Vec2, p: Pos2) -> egui::RawInput {
let mut raw = screen_input(screen);
raw.events.push(egui::Event::PointerButton {
pos: p,
button: egui::PointerButton::Primary,
pressed: false,
modifiers: egui::Modifiers::default(),
});
raw
}
#[test]
fn roi_create_mode_reserves_primary_drag_like_mask_draw() {
let mode = PlotInteractionMode::RoiCreate(RoiDrawKind::Rect);
assert_eq!(primary_drag_gestures(mode), (false, false, false));
assert!(!mode_grabs_roi_edge(mode));
assert!(!mode_allows_marker_drag(mode));
assert!(mode_allows_marker_drag(PlotInteractionMode::Select));
assert!(mode_allows_marker_drag(PlotInteractionMode::Zoom));
}
#[test]
fn roi_create_point_single_click_appends_roi() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let mode = PlotInteractionMode::RoiCreate(RoiDrawKind::Point);
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let click_px = area.center();
let (press_resp, _a) = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, click_px));
let (release_resp, _a) =
run_mode_frame(&ctx, &mut plot, mode, release_at(screen, click_px));
assert_eq!(plot.rois.len(), 1);
assert!(matches!(
plot.rois[0].roi,
crate::core::roi::Roi::Point { .. }
));
let reported: Vec<usize> = [press_resp.roi_created, release_resp.roi_created]
.into_iter()
.flatten()
.collect();
assert_eq!(
reported,
vec![0],
"create index reported exactly once on the finishing frame"
);
}
#[test]
fn roi_create_line_drag_appends_roi_and_rearms() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let mode = PlotInteractionMode::RoiCreate(RoiDrawKind::Line);
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let a = area.center() - egui::vec2(20.0, 20.0);
let b = area.center() + egui::vec2(20.0, 20.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, a));
let _ = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, b));
let (resp1, _) = run_mode_frame(&ctx, &mut plot, mode, release_at(screen, b));
assert_eq!(plot.rois.len(), 1);
assert!(matches!(
plot.rois[0].roi,
crate::core::roi::Roi::Line { .. }
));
assert_eq!(resp1.roi_created, Some(0));
let c = area.center() + egui::vec2(30.0, -10.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, area.center()));
let _ = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, c));
let (resp2, _) = run_mode_frame(&ctx, &mut plot, mode, release_at(screen, c));
assert_eq!(plot.rois.len(), 2);
assert_eq!(resp2.roi_created, Some(1));
}
#[test]
fn roi_create_preview_surfaced_mid_drag() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let mode = PlotInteractionMode::RoiCreate(RoiDrawKind::Rect);
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let a = area.center() - egui::vec2(20.0, 20.0);
let b = area.center() + egui::vec2(20.0, 20.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, a));
let (mid, _) = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, b));
assert!(plot.rois.is_empty());
assert_eq!(mid.roi_created, None);
}
#[test]
fn roi_create_surfaces_draw_event_progress_then_finished() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let mode = PlotInteractionMode::RoiCreate(RoiDrawKind::Line);
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let a = area.center() - egui::vec2(20.0, 20.0);
let b = area.center() + egui::vec2(20.0, 20.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, a));
let (mid, _) = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, b));
assert!(
matches!(
mid.draw_event,
Some(interaction::DrawEvent::InProgress { .. })
),
"drawingProgress surfaced mid-drag: {:?}",
mid.draw_event
);
let (end, _) = run_mode_frame(&ctx, &mut plot, mode, release_at(screen, b));
assert!(
matches!(
end.draw_event,
Some(interaction::DrawEvent::Finished { .. })
),
"drawingFinished surfaced on release: {:?}",
end.draw_event
);
assert_eq!(end.roi_created, Some(0));
}
#[test]
fn box_zoom_rubber_band_commits_only_on_release() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let before = plot.limits;
let mode = PlotInteractionMode::Zoom;
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let a = area.center() - egui::vec2(20.0, 20.0);
let b = area.center() + egui::vec2(20.0, 20.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, a));
let _ = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, b));
assert_eq!(plot.limits, before, "box zoom must not commit mid-drag");
let _ = run_mode_frame(&ctx, &mut plot, mode, release_at(screen, b));
let (x0, x1, y0, y1) = plot.limits;
assert!(
x1 - x0 < before.1 - before.0 && y1 - y0 < before.3 - before.2,
"box zoom narrows the view on release: {:?} -> {:?}",
before,
plot.limits
);
}
#[test]
fn box_zoom_keep_aspect_ignores_disabled_axes() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.set_zoom_enabled_axes(false, false);
plot.keep_aspect = true;
let before = plot.limits;
let mode = PlotInteractionMode::Zoom;
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let a = area.center() - egui::vec2(20.0, 20.0);
let b = area.center() + egui::vec2(20.0, 20.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, a));
let _ = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, b));
let _ = run_mode_frame(&ctx, &mut plot, mode, release_at(screen, b));
let (x0, x1, y0, y1) = plot.limits;
assert!(
x1 - x0 < before.1 - before.0 && y1 - y0 < before.3 - before.2,
"keep-aspect box zoom narrows both axes: {:?} -> {:?}",
before,
plot.limits
);
}
#[test]
fn box_zoom_disabled_axis_keeps_current_range() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.set_zoom_enabled_axes(true, false);
let before = plot.limits;
let mode = PlotInteractionMode::Zoom;
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let a = area.center() - egui::vec2(20.0, 20.0);
let b = area.center() + egui::vec2(20.0, 20.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, a));
let _ = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, b));
let _ = run_mode_frame(&ctx, &mut plot, mode, release_at(screen, b));
let (x0, x1, y0, y1) = plot.limits;
assert!(
x1 - x0 < before.1 - before.0,
"X narrows: {:?}",
plot.limits
);
assert_eq!((y0, y1), (0.0, 10.0), "disabled Y keeps its range");
}
#[test]
fn box_zoom_rejects_drag_below_surface_threshold() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let before = plot.limits;
let mode = PlotInteractionMode::Zoom;
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let a = area.center() - egui::vec2(10.0, 0.0);
let b = area.center() + egui::vec2(10.0, 0.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, a));
let _ = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, b));
let _ = run_mode_frame(&ctx, &mut plot, mode, release_at(screen, b));
assert_eq!(plot.limits, before, "zero-area drag must be rejected");
assert_eq!(plot.limits_history_len(), 0, "no history push either");
}
#[test]
fn polygon_first_point_box_is_centered_on_first_vertex() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let (r0, _area) = run_mode_frame(
&ctx,
&mut plot,
PlotInteractionMode::Zoom,
screen_input(egui::vec2(200.0, 200.0)),
);
let t = r0.transform;
let first = (3.0, 4.0);
let off = 4.0_f32;
let bb = Rect::from_points(&polygon_first_point_box(&t, first, off));
let c = t.data_to_pixel(first.0, first.1);
assert!((bb.center().x - c.x).abs() < 1e-3, "cx {bb:?} vs {c:?}");
assert!((bb.center().y - c.y).abs() < 1e-3, "cy {bb:?} vs {c:?}");
assert!((bb.width() - 2.0 * off).abs() < 1e-3, "w {}", bb.width());
assert!((bb.height() - 2.0 * off).abs() < 1e-3, "h {}", bb.height());
}
#[test]
fn select_mode_body_drag_translates_roi() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.rois.push(ManagedRoi::new(crate::core::roi::Roi::Rect {
x: (1.0, 9.0),
y: (1.0, 9.0),
}));
let mode = PlotInteractionMode::Select;
let screen = egui::vec2(200.0, 200.0);
let (r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let t = r0.transform;
let c = area.center();
let a_px = c + egui::vec2(8.0, 8.0);
let b_px = c + egui::vec2(18.0, -2.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, c));
let _ = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, a_px));
let before = match &plot.rois[0].roi {
crate::core::roi::Roi::Rect { x, y } => (*x, *y),
other => panic!("{other:?}"),
};
let a_data = t.pixel_to_data(a_px);
let b_data = t.pixel_to_data(b_px);
let (ddx, ddy) = (b_data.0 - a_data.0, b_data.1 - a_data.1);
let (resp, _) = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, b_px));
assert_eq!(resp.roi_changed, Some(0));
match &plot.rois[0].roi {
crate::core::roi::Roi::Rect { x, y } => {
assert!((x.0 - (before.0.0 + ddx)).abs() < 1e-6, "x0 {x:?}");
assert!((x.1 - (before.0.1 + ddx)).abs() < 1e-6, "x1 {x:?}");
assert!((y.0 - (before.1.0 + ddy)).abs() < 1e-6, "y0 {y:?}");
assert!((y.1 - (before.1.1 + ddy)).abs() < 1e-6, "y1 {y:?}");
assert!(ddx.abs() > 1e-6 || ddy.abs() > 1e-6);
}
other => panic!("{other:?}"),
}
}
#[test]
fn draggable_marker_drag_emits_started_moved_finished_triad() {
use crate::core::marker::Marker;
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
let mode = PlotInteractionMode::Select;
let screen = egui::vec2(200.0, 200.0);
let (r0, area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let c = area.center();
let (mx, my) = r0.transform.pixel_to_data(c);
let mut marker = Marker::point(mx, my);
marker.is_draggable = true;
plot.markers.push(marker);
plot.marker_handles.push(42);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, c));
let (r1, _) = run_mode_frame(
&ctx,
&mut plot,
mode,
move_to(screen, c + egui::vec2(12.0, 9.0)),
);
assert_eq!(
r1.marker_drag_started,
Some(42),
"drag start on the grab frame"
);
assert_eq!(
r1.marker_moved,
Some(42),
"first markerMoving on the same frame as the grab"
);
assert_eq!(r1.marker_drag_finished, None);
let (r2, _) = run_mode_frame(
&ctx,
&mut plot,
mode,
move_to(screen, c + egui::vec2(24.0, 3.0)),
);
assert_eq!(r2.marker_drag_started, None, "start fires exactly once");
assert_eq!(r2.marker_moved, Some(42));
assert_eq!(r2.marker_drag_finished, None);
let (r3, _) = run_mode_frame(
&ctx,
&mut plot,
mode,
release_at(screen, c + egui::vec2(24.0, 3.0)),
);
assert_eq!(r3.marker_drag_finished, Some(42), "drag finish on release");
assert_eq!(r3.marker_drag_started, None);
assert_eq!(
r3.marker_moved, None,
"the release frame carries no markerMoving"
);
}
#[test]
fn roi_drag_cancelled_on_mid_drag_mode_switch() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.rois.push(ManagedRoi::new(crate::core::roi::Roi::Rect {
x: (1.0, 9.0),
y: (1.0, 9.0),
}));
let screen = egui::vec2(200.0, 200.0);
let (_r0, area) = run_mode_frame(
&ctx,
&mut plot,
PlotInteractionMode::Select,
screen_input(screen),
);
let c = area.center();
let a_px = c + egui::vec2(8.0, 8.0);
let b_px = c + egui::vec2(18.0, -2.0);
let _ = run_mode_frame(
&ctx,
&mut plot,
PlotInteractionMode::Select,
press_at(screen, c),
);
let _ = run_mode_frame(
&ctx,
&mut plot,
PlotInteractionMode::Select,
move_to(screen, a_px),
);
let before = match &plot.rois[0].roi {
crate::core::roi::Roi::Rect { x, y } => (*x, *y),
other => panic!("{other:?}"),
};
let (resp, _) = run_mode_frame(
&ctx,
&mut plot,
PlotInteractionMode::MaskDraw,
move_to(screen, b_px),
);
assert_eq!(
resp.roi_changed, None,
"ROI must not edit in a mode that does not grab ROI edges"
);
match &plot.rois[0].roi {
crate::core::roi::Roi::Rect { x, y } => {
assert_eq!((*x, *y), before, "rect unchanged after the mode switch")
}
other => panic!("{other:?}"),
}
let (resp2, _) = run_mode_frame(
&ctx,
&mut plot,
PlotInteractionMode::Select,
move_to(screen, c),
);
assert_eq!(
resp2.roi_changed, None,
"cancelled drag must not resume when the mode switches back"
);
match &plot.rois[0].roi {
crate::core::roi::Roi::Rect { x, y } => {
assert_eq!(
(*x, *y),
before,
"rect still unchanged after switching back"
)
}
other => panic!("{other:?}"),
}
}
#[test]
fn circle_perimeter_drag_resizes_end_to_end_under_inverted_y() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.y_inverted = true;
plot.rois
.push(ManagedRoi::new(crate::core::roi::Roi::Circle {
center: (5.0, 5.0),
radius: 3.0,
}));
let mode = PlotInteractionMode::Select;
let screen = egui::vec2(200.0, 200.0);
let (r0, _area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let t = r0.transform;
let handle_px = t.data_to_pixel(8.0, 5.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, handle_px));
let _ = run_mode_frame(
&ctx,
&mut plot,
mode,
move_to(screen, handle_px + egui::vec2(2.0, 0.0)),
);
let target_px = t.data_to_pixel(9.0, 5.0);
let (resp, _) = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, target_px));
assert_eq!(resp.roi_changed, Some(0), "perimeter grab edits the ROI");
match &plot.rois[0].roi {
crate::core::roi::Roi::Circle { center, radius } => {
assert!(
(center.0 - 5.0).abs() < 1e-6 && (center.1 - 5.0).abs() < 1e-6,
"center unchanged: {center:?}"
);
assert!(
(*radius - 4.0).abs() < 1e-6,
"radius grew to 4, got {radius}"
);
}
other => panic!("{other:?}"),
}
}
#[test]
fn rect_corner_drag_resizes_diagonally_end_to_end_under_inverted_y() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.y_inverted = true;
plot.rois.push(ManagedRoi::new(crate::core::roi::Roi::Rect {
x: (2.0, 7.0),
y: (2.0, 7.0),
}));
let mode = PlotInteractionMode::Select;
let screen = egui::vec2(200.0, 200.0);
let (r0, _area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let t = r0.transform;
let corner_px = t.data_to_pixel(7.0, 7.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, corner_px));
let _ = run_mode_frame(
&ctx,
&mut plot,
mode,
move_to(screen, corner_px + egui::vec2(2.0, 2.0)),
);
let target_px = t.data_to_pixel(9.0, 9.0);
let (resp, _) = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, target_px));
assert_eq!(resp.roi_changed, Some(0), "corner grab edits the ROI");
match &plot.rois[0].roi {
crate::core::roi::Roi::Rect { x, y } => {
assert!((x.0 - 2.0).abs() < 1e-6, "x.min fixed: {x:?}");
assert!((y.0 - 2.0).abs() < 1e-6, "y.min fixed: {y:?}");
assert!((x.1 - 9.0).abs() < 1e-6, "x.max followed cursor: {x:?}");
assert!((y.1 - 9.0).abs() < 1e-6, "y.max followed cursor: {y:?}");
}
other => panic!("{other:?}"),
}
}
#[test]
fn ellipse_axis_handle_drag_resizes_end_to_end_under_inverted_y() {
let ctx = egui::Context::default();
let mut plot = Plot::new(0);
plot.limits = (0.0, 10.0, 0.0, 10.0);
plot.y_inverted = true;
plot.rois
.push(ManagedRoi::new(crate::core::roi::Roi::Ellipse {
center: (5.0, 5.0),
radii: (3.0, 2.0),
orientation: 0.0,
}));
let mode = PlotInteractionMode::Select;
let screen = egui::vec2(200.0, 200.0);
let (r0, _area) = run_mode_frame(&ctx, &mut plot, mode, screen_input(screen));
let t = r0.transform;
let handle_px = t.data_to_pixel(8.0, 5.0);
let _ = run_mode_frame(&ctx, &mut plot, mode, press_at(screen, handle_px));
let _ = run_mode_frame(
&ctx,
&mut plot,
mode,
move_to(screen, handle_px + egui::vec2(2.0, 0.0)),
);
let target_px = t.data_to_pixel(9.0, 5.0);
let (resp, _) = run_mode_frame(&ctx, &mut plot, mode, move_to(screen, target_px));
assert_eq!(resp.roi_changed, Some(0), "axis-handle grab edits the ROI");
match &plot.rois[0].roi {
crate::core::roi::Roi::Ellipse {
center,
radii,
orientation,
} => {
assert!(
(center.0 - 5.0).abs() < 1e-6 && (center.1 - 5.0).abs() < 1e-6,
"center unchanged: {center:?}"
);
assert!(
(radii.0 - 4.0).abs() < 1e-6,
"axis0 semi-axis grew to 4: {radii:?}"
);
assert!(
(radii.1 - 2.0).abs() < 1e-6,
"axis1 semi-axis unchanged: {radii:?}"
);
assert!(
orientation.abs() < 1e-6,
"orientation stays 0 for an on-axis drag: {orientation}"
);
}
other => panic!("{other:?}"),
}
}
}