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

+4 -1
View File
@@ -81,7 +81,10 @@ run touched a GPU, so read it as an upper bound and a record of the software pat
- input parity across hosts: pinch-zoom and pan gestures on Qt6 and GTK4 drive the same
controller paths as mouse and wheel (touch parity), with keyboard panning
(arrow keys), Escape cancelling a drag or box-zoom, double-click re-fitting the axes,
and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only);
and modifier-masked box zoom (Shift = both axes, Ctrl = x only, Alt = y only), the area being
chosen framed as the drag runs -- a low-alpha fill under a light border, widened along the axis
the release will *not* zoom, so Ctrl's frame is the band it is about to apply rather than the
sliver a straight horizontal drag sweeps;
- DPI-consistent sizing: metrics are logical units converted once through
`Style::ui_scale`;
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
+18
View File
@@ -344,6 +344,21 @@ u32 PlotArea::pointer_row() const noexcept {
return row_in(row_rects_, pointer_.y);
}
void PlotArea::sync_selection_frames() noexcept {
const auto frame_for = [](input::Controller& c) -> Rect {
return c.drag_mode() == input::DragMode::BoxZoom ? c.selection_preview() : Rect{};
};
if (figure_ != nullptr) {
for (u32 i = 0; i < row_controllers_.size(); ++i) {
// An empty rect on the rows that are not choosing: `Rect` has no equality to compare
// with, and a store of the same value is what keeps this path free of anything else.
figure_->plot(i).set_selection_rect(frame_for(*row_controllers_[i]));
}
return;
}
plot_->set_selection_rect(frame_for(controller_));
}
void PlotArea::sync_figure_crosshair() noexcept {
if (row_rects_.empty() || row_controllers_.empty()) return;
const u32 row_count = static_cast<u32>(row_rects_.size());
@@ -534,6 +549,9 @@ void PlotArea::render() noexcept {
// `::resize` ratio, not `gtk_widget_get_scale_factor()` -- the latter is the
// ceiling under fractional scaling and would over-scale the chrome by up to 60%.
renderer_->style().ui_scale = mapping_.factor;
// After the scale and before the draw: the preview reads the row's layout, and a layout measured
// with the previous frame's scale is not the one being drawn.
sync_selection_frames();
renderer_->begin_frame(static_cast<u32>(viewport.w), static_cast<u32>(viewport.h), viewport);
if (figure_ != nullptr) {
renderer_->draw(*figure_);
+11
View File
@@ -397,6 +397,17 @@ class PlotArea {
/// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once
/// per frame, from the render callback, after this frame's row rects are cached.
void sync_figure_crosshair() noexcept;
/// Hands each row's plot the box-zoom frame its controller is showing, or takes it off
/// the ones whose gesture is over. Run once per frame from the render callback, like
/// the crosshair's spread and for a related reason: a frame has to go away when a drag
/// ends by *any* route -- the release, Escape, a row rebuilt mid-gesture -- and a
/// per-event hook would have to be repeated at every one of them, which is how a frame
/// ends up stuck on screen after a cancelled zoom.
///
/// Reads `selection_preview()`, which needs the row's layout, only for a controller
/// actually in `BoxZoom`: the cost is paid while a range is being chosen and never
/// otherwise.
void sync_selection_frames() noexcept;
/// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's
/// own rect to its controller. The figure-mode half of `refresh_viewport()` and of
/// `render()`, which must not drift apart.
+10
View File
@@ -192,6 +192,16 @@ without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hove
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
tooltip, a status line).
The frame an open box zoom is drawing is published the same way and for the same reason
(`Plot::set_selection_rect()`), and the plot still learns nothing about pointers: the host reads the
rect off the controller -- `Controller::selection_preview()` -- on every frame of the drag and takes it
off the plot when the gesture ends. The preview is deliberately not what the pointer swept. Shift
frames both axes and its preview is the swept box; Ctrl frames x only, so its preview is widened to the
full height of the axes, and Alt's to the full width. The swept sliver is the *gesture*; the widened
rect is what the *release* will apply, which is the one worth showing while it is still only a
gesture. Nothing is previewed until the drag has swept every axis the release will act on: an empty
rect widened is a band the width of the plot, which would claim a range that does not exist yet.
## Live follow
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
+12
View File
@@ -229,6 +229,18 @@ class Controller {
DragMode drag_mode() const noexcept { return mode_; }
/// The current box-zoom selection in pixels; meaningful while BoxZoom.
Rect selection_rect() const noexcept { return selection_; }
/// The rectangle a host should *draw* for the open box zoom: the swept area, widened to the full
/// axes along whichever axis the modifier's mask will not zoom. Ctrl frames the x range only, so its
/// preview is the band that spans the plot -- which is what the release will zoom -- where the area
/// a purely horizontal drag sweeps is a sliver a few pixels tall. Shift (and any other combo) frames
/// both, and gets the swept box itself.
///
/// Meaningful while `BoxZoom`, like `selection_rect()`; the zero rect otherwise.
///
/// Defined in the implementation: widening the preview to the axes needs the plot's layout, and
/// `Plot` is only forward-declared here.
Rect selection_preview() const noexcept;
/// The cursor under the pointer -- a vertical marker or a horizontal ruler, whichever
/// line the pointer is nearer -- or `kInvalid` when the pointer is outside the plot area
/// or no cursor is within a few pixels.
+27
View File
@@ -144,6 +144,13 @@ struct PlotLayout {
/// clamped against the rect it was measured for, so nothing of the readout can be drawn
/// over a label strip or into a figure's neighbouring row.
CrosshairLayout crosshair;
/// The box-zoom frame, when a modified drag has one open: the area the gesture is framing, in
/// viewport pixels, already widened along whichever axis the modifier will *not* zoom (see
/// `input::Controller::selection_preview()`, which is where that is decided). Here rather than
/// derived by the renderer, for the regions' reason -- the frame draws exactly these numbers -- and
/// left as the zero rect whenever no gesture is open, which is what both the fill and the four
/// border segments read as "draw nothing".
Rect selection;
};
/// What a pointer is on, as of the last pointer event: the cursor marker under it, the
@@ -753,6 +760,21 @@ class Plot {
/// calls this when the pointer leaves the axes.
void clear_crosshair() noexcept { crosshair_.active = false; }
/// The box-zoom frame the host is showing, in **viewport pixels**, or the zero rect for none.
///
/// Set by the host from `input::Controller::selection_preview()` while a modified left drag is
/// choosing a range, and taken off when it ends. It is the *host's* job and not the plot's because
/// the plot has no controller and learns nothing about pointer, button or modifier (see
/// `plot::input`): this is the receiving end of that boundary, exactly as `set_hot_handles()` is.
/// Drawn as a frame -- a slightly opaque fill under a light border -- over everything else inside
/// the axes, so the area being framed stays legible against the traces it covers.
void set_selection_rect(Rect rect) noexcept { selection_ = rect; }
/// Takes the frame off, leaving the same state as before any drag: the next one starts from nothing.
void clear_selection_rect() noexcept { selection_ = Rect{}; }
/// The frame as set -- what `layout()` measures into `PlotLayout::selection`, and what a host or a
/// test reads back without paying a layout.
Rect selection_rect() const noexcept { return selection_; }
/// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and
/// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants
/// the pointer's column marked but not a panel over its data sets this once; it is deliberately
@@ -969,6 +991,11 @@ class Plot {
/// The crosshair's state (see Crosshair): at most one per plot, placed by
/// `set_crosshair()` and read back by `crosshair()` and `layout()`.
Crosshair crosshair_{};
/// The host's box-zoom frame (`set_selection_rect()`): viewport pixels, the zero rect when no
/// modified drag is choosing a range. State rather than a `PlotLayout` field alone because a host
/// sets it on every drag motion and reads it back; `layout()` only carries it through.
Rect selection_{};
/// The samples `crosshair_value()` reads a span through: `Series::gather()` appends
/// `(x, y)` pairs and never clears, so the lookup clears this and refills it with the at
/// most two samples a span covers. Sized to hold them from construction -- four floats is
+5
View File
@@ -654,6 +654,11 @@ class Renderer {
/// pointer is on: a readout the legend covered would be the feature failing at its one job.
void draw_crosshair_readout(const Plot& plot, const PlotLayout& layout);
/// The host's box-zoom frame (`PlotLayout::selection`): a fill at `selection_fill` under a border at
/// `selection_edge`, or nothing at all when nothing was swept. Drawn last of the interior passes, so
/// the area being framed is legible over the traces, the regions and the readout alike.
void draw_selection_frame(const PlotLayout& layout);
/// One marker shape (`Marker`) at a viewport-pixel centre: `Cross` two
/// segments, `Circle` an 8-gon, `Square` four. Staged into the line batch in
/// pixel space, at `size_px` half-extent, so a marker's size is device pixels
+8
View File
@@ -55,6 +55,14 @@ struct Style {
Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020);
Color measure_fill = Color::hex(0xFFB020, 0.15f);
/// The box-zoom selection's frame, drawn while a modified left drag is choosing a range: a light
/// neutral near full alpha for the border and the same neutral at a low one for the fill. Neutral
/// and not a trace colour, on `invalid_fill`'s argument -- the frame says "this is the area", and a
/// tint a trace could have would read as data. The fill's alpha is chosen against the same floor:
/// low enough that what is under the frame stays readable, because the point is to show what is
/// being selected, not to hide it.
Color selection_fill = Color::hex(0xC8D0DC, 0.12f);
Color selection_edge = Color::hex(0xC8D0DC, 0.85f);
/// The shading over a span the host has declared invalid (`Plot::set_invalid_spans()`): the
/// axis furniture's own neutral grey at a low alpha, on the same model as `measure_fill`
/// above. Neutral and not a trace colour on purpose -- a band is the *absence* of data, and
+25
View File
@@ -751,6 +751,31 @@ void Controller::pan(const PointerEvent& e) {
}
}
Rect Controller::selection_preview() const noexcept {
// Nothing to preview off a box zoom: the mask bits outlive the gesture -- `end_drag()` clears the
// selection, not them -- so widening without this guard would hand out a full-band rect for a drag
// that finished minutes ago.
if (mode_ != DragMode::BoxZoom) return Rect{};
// And nothing has been framed until the drag has swept every axis the release will apply: at the
// press instant the selection is an empty rect at the pointer, and the *widened* form of that is a
// band the width of the plot, which is a claim about a range that does not exist yet. Ctrl's other
// axis is deliberately not required: a drag with no vertical movement at all is a band across the
// plot, which is exactly what Ctrl is for.
if ((box_zoom_x_ && !(selection_.w > 0.0f)) || (box_zoom_y_ && !(selection_.h > 0.0f))) return Rect{};
Rect preview = selection_;
if (box_zoom_x_ && box_zoom_y_) return preview;
const Rect& axes = plot_->layout().axes;
if (!box_zoom_x_) {
preview.x = axes.x;
preview.w = axes.w;
}
if (!box_zoom_y_) {
preview.y = axes.y;
preview.h = axes.h;
}
return preview;
}
void Controller::box_zoom_update(const PointerEvent& e) {
const f32 x = std::min(origin_x_, e.x);
const f32 y = std::min(origin_y_, e.y);
+4
View File
@@ -1101,6 +1101,10 @@ const PlotLayout& Plot::layout() {
// its rows are the sample values nearest a data x -- so it wants the transforms the
// settled layout pins, exactly as the legend and the regions do.
build_crosshair();
// The box-zoom frame is not measured, only carried: the host already has it in viewport pixels from
// the controller that produced it, and re-deriving it here would be a second rounding of the same
// rectangle -- and a second place for it to disagree with what the gesture is doing.
layout_.selection = selection_;
return layout_;
}
+24
View File
@@ -545,6 +545,9 @@ void Renderer::draw_plot(Plot& plot, const PlotLayout& layout) {
// panel is drawn over the legend and every other overlay rather than under them, and its
// text joins the frame's over flush with the tick labels and titles.
draw_crosshair_readout(plot, layout);
// The selection frame last of all: it is the gesture still in progress, and nothing inside the axes
// should be able to sit on top of the area being chosen.
draw_selection_frame(layout);
clear_clip();
draw_tick_labels(plot, layout);
draw_titles(plot, layout);
@@ -1960,6 +1963,27 @@ void Renderer::stage_marker(plot::Marker marker, Vec2 centre_px, f32 size_px) {
}
}
void Renderer::draw_selection_frame(const PlotLayout& layout) {
const Rect& frame = layout.selection;
// Two guards, not one: a drag swept flat in one direction has a real extent on one axis and none on
// the other, and a border on that is still the gesture's own answer -- the fill is what a zero
// extent has nothing of.
if (!(frame.w > 0.0f) && !(frame.h > 0.0f)) return;
if (frame.w > 0.0f && frame.h > 0.0f) fill_pixels(frame, style_.selection_fill);
// The border frames the pixels the fill covers and not the measured rect's own fractional edges:
// the readout panel's rounding and the readout panel's reason, since a hairline straddling a
// fractional edge is a frame bright on two sides and dim on the others.
const f32 left = std::ceil(frame.x - 0.5f) + 0.5f;
const f32 right = std::ceil(frame.right() - 0.5f) - 0.5f;
const f32 top = std::ceil(frame.y - 0.5f) + 0.5f;
const f32 bottom = std::ceil(frame.bottom() - 0.5f) - 0.5f;
lines_.add_segment({left, top}, {right, top});
lines_.add_segment({left, bottom}, {right, bottom});
lines_.add_segment({left, top}, {left, bottom});
lines_.add_segment({right, top}, {right, bottom});
flush_lines(pixel_mapping(), style_.selection_edge, style_.device(1.0f));
}
void Renderer::draw_readout_panel(const Rect& panel, Color border) {
if (!(panel.w > 0.0f) || !(panel.h > 0.0f)) return;
// The frame's own background at a partial alpha, which dims whatever it covers instead of erasing
+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();
}