feat: an open box zoom draws the frame the release will apply

The controller has always tracked its selection; nothing drew it, so a Ctrl or
Shift drag framed a range with no feedback until the release.

The preview is the area the release will change, not what the pointer swept:
Ctrl frames the x range only, so a band across the plot is what it will zoom,
where the sliver a straight horizontal drag sweeps is only the gesture. The
plot carries the rect a host hands it -- it learns nothing about pointers --
and the renderer draws a low-alpha fill under a light border over everything
else inside the axes.
This commit is contained in:
Martino Ferrari committed 2026-10-11 11:07:55 +02:00
1 parent 1fc97d818f
commit be1f31a04f
14 files changed
+276 -1

No files matched your search

+51
View File
@@ -2403,3 +2403,54 @@ PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) {
PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12);
PLOT_CHECK(c.grabbed_cursor() == normal);
}
PLOT_TEST(a_ctrl_box_zoom_previews_the_band_it_will_zoom) {
// What a host draws for an open box zoom is not always the area the pointer swept. Ctrl frames the
// x range only, so what its release will change is a *band* across the plot -- and a pointer dragged
// straight across sweeps a sliver a couple of pixels tall, which would be the frame showing the
// gesture instead of its effect. Shift frames both axes and gets the swept box itself; Alt is the
// other single-axis mask, and gets the other frame.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
p.add_x_axis({"t", {0.0, 800.0}, true, plot::AxisPlace::Bottom, false});
const plot::Rect axes = p.layout().axes;
constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
plot::input::Controller c(p);
c.set_viewport({0, 0, 800, 400});
c.on_pointer({100, 200, 0, 0, plot::input::Button::Left, kCtrl, true, true, false});
// Nothing framed until something was swept: the press alone is an empty rect at the pointer, and a
// full-width band derived from it would claim a range that does not exist yet.
PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6);
c.on_pointer({300, 202, 200, 2, plot::input::Button::Left, kCtrl, true, false, false});
const plot::Rect ctrl = c.selection_preview();
PLOT_CHECK_NEAR(ctrl.x, 100.0, 1e-6);
PLOT_CHECK_NEAR(ctrl.w, 200.0, 1e-6);
PLOT_CHECK_NEAR(ctrl.y, axes.y, 1e-6);
PLOT_CHECK_NEAR(ctrl.h, axes.h, 1e-6);
// The gesture's own geometry is untouched: the widening happens in the preview, so the range the
// release computes is still the one the pointer described.
PLOT_CHECK_NEAR(c.selection_rect().h, 2.0, 1e-6);
c.on_pointer({300, 202, 0, 0, plot::input::Button::Left, kCtrl, false, false, true});
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kShift, true, true, false});
c.on_pointer({400, 150, 300, 100, plot::input::Button::Left, kShift, true, false, false});
const plot::Rect both = c.selection_preview();
PLOT_CHECK_NEAR(both.x, 100.0, 1e-6);
PLOT_CHECK_NEAR(both.y, 50.0, 1e-6);
PLOT_CHECK_NEAR(both.w, 300.0, 1e-6);
PLOT_CHECK_NEAR(both.h, 100.0, 1e-6);
c.on_pointer({400, 150, 0, 0, plot::input::Button::Left, kShift, false, false, true});
c.on_pointer({100, 50, 0, 0, plot::input::Button::Left, kAlt, true, true, false});
c.on_pointer({110, 150, 10, 100, plot::input::Button::Left, kAlt, true, false, false});
const plot::Rect alt = c.selection_preview();
PLOT_CHECK_NEAR(alt.x, axes.x, 1e-6);
PLOT_CHECK_NEAR(alt.w, axes.w, 1e-6);
PLOT_CHECK_NEAR(alt.y, 50.0, 1e-6);
PLOT_CHECK_NEAR(alt.h, 100.0, 1e-6);
c.on_pointer({110, 150, 0, 0, plot::input::Button::Left, kAlt, false, false, true});
// Closed: a frame on a finished gesture would claim the old selection is still open.
PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None);
PLOT_CHECK_NEAR(c.selection_preview().w, 0.0, 1e-6);
}
+19
View File
@@ -2789,3 +2789,22 @@ PLOT_TEST(an_annotation_can_be_moved_by_id) {
p.set_annotation(42u, other);
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
}
PLOT_TEST(the_selection_frame_is_carried_and_taken_off) {
// The box-zoom frame is the host's to set -- the plot has no controller and learns nothing about
// pointers -- so what it owes is to carry it: out through `layout()` unchanged, because it is
// already in viewport pixels and a second derivation would be a second rounding of the same
// rectangle, and gone when the host takes it off, because a frame left behind claims the selection
// is still open.
plot::Plot p;
p.set_viewport({0, 0, 800, 400});
PLOT_CHECK(p.layout().selection.w == 0.0f && p.layout().selection.h == 0.0f); // nothing swept
p.set_selection_rect({10.0f, 20.0f, 30.0f, 40.0f});
PLOT_CHECK_NEAR(p.selection_rect().x, 10.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.y, 20.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.w, 30.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.h, 40.0, 1e-6);
p.clear_selection_rect();
PLOT_CHECK_NEAR(p.selection_rect().w, 0.0, 1e-6);
PLOT_CHECK_NEAR(p.layout().selection.h, 0.0, 1e-6);
}
+58
View File
@@ -7461,3 +7461,61 @@ PLOT_TEST(a_repaired_invalid_span_is_fainter_than_a_missing_one) {
missing_ink, m_lo, m_hi, repaired_ink, r_lo, r_hi);
ctx.destroy();
}
PLOT_TEST(the_box_zoom_frame_lands_exactly_where_it_says) {
// The frame a host sets has to reach the framebuffer and cover exactly the rect it names. Both halves
// are read back rather than trusted, and the two renders are compared: what is under the frame is a
// trace, the grid and the axis chrome, so the assertions are differential -- a predicate guessing
// those colours would be testing the guess.
plottest::HeadlessContext ctx(kW, kH);
plot::Status st;
PLOT_CHECK(ctx.create(&st));
PLOT_CHECK(plot::gl::load(nullptr));
plot::Renderer r;
PLOT_CHECK(r.init(&st));
plot::data::ArraySeries s(sine_samples(), 0.0, 1.0);
plot::Plot p;
p.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
const plot::u32 ax = p.add_x_axis({"t", s.x_bounds(), true, plot::AxisPlace::Bottom, false});
const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, true, plot::AxisPlace::Left, false});
p.add_trace({&s, plot::Color::hex(0x40C0FF), 1.5f, ax, ay});
// The *floor* of a pixel rather than its luminance: the fill is the axis furniture's light neutral at
// a low alpha, so over a saturated trace it can lower a channel while it raises the floor, and the
// floor is what says "something light was laid over this".
const auto floor_of = [&](plot::u32 x, plot::u32 y) {
plot::u8 px[4] = {};
PLOT_CHECK(ctx.read_pixel(x, ctx.height - 1u - y, px));
return static_cast<int>(std::min(px[0], std::min(px[1], px[2])));
};
// The renderer does not clear -- that is the host's -- and a second draw over the first would double
// every trace and change every pixel for reasons that have nothing to do with the frame.
const auto draw = [&](bool framed) {
if (framed) p.set_selection_rect({200.0f, 100.0f, 200.0f, 120.0f});
plot::gl::glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
plot::gl::glClear(plot::gl::GL_COLOR_BUFFER_BIT);
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
r.draw(p);
r.end_frame();
};
draw(false);
const int border_before = floor_of(200, 160); // the frame's left border, mid-height
const int inside_before = floor_of(300, 160); // the middle of the framed area
const int outside_before = floor_of(500, 160); // to the right of it
draw(true);
const int border_after = floor_of(200, 160);
const int inside_after = floor_of(300, 160);
const int outside_after = floor_of(500, 160);
// A frame, not a wash: the border is nearly opaque, the fill is not.
PLOT_CHECK(border_after > inside_after);
PLOT_CHECK(border_after > border_before + 60);
// A fill, not a panel: it lightens what it covers and leaves it readable.
PLOT_CHECK(inside_after > inside_before + 10);
// Exactly the rect, and not a pixel beyond it: this is the assertion that the frame matches the area
// the gesture is choosing rather than merely being somewhere on screen.
PLOT_CHECK(outside_after == outside_before);
p.clear_selection_rect();
}