From be1f31a04f3e83a26f9d9d20543cfe582294c461 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 11 Oct 2026 11:02:42 +0200 Subject: [PATCH] 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. --- README.md | 5 ++- adapters/gtk4/gtk_plot_area.cpp | 18 ++++++++++ adapters/gtk4/gtk_plot_area.hpp | 11 ++++++ docs/interaction.md | 10 ++++++ include/plot/input/controller.hpp | 12 +++++++ include/plot/plot/plot.hpp | 27 ++++++++++++++ include/plot/render/renderer.hpp | 5 +++ include/plot/render/style.hpp | 8 +++++ src/input/controller.cpp | 25 +++++++++++++ src/plot/plot.cpp | 4 +++ src/render/renderer.cpp | 24 +++++++++++++ tests/test_controller.cpp | 51 +++++++++++++++++++++++++++ tests/test_plot.cpp | 19 ++++++++++ tests/test_render_smoke.cpp | 58 +++++++++++++++++++++++++++++++ 14 files changed, 276 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 85635b2..d1ac2d0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/adapters/gtk4/gtk_plot_area.cpp b/adapters/gtk4/gtk_plot_area.cpp index 7b56c41..acf6621 100644 --- a/adapters/gtk4/gtk_plot_area.cpp +++ b/adapters/gtk4/gtk_plot_area.cpp @@ -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(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(viewport.w), static_cast(viewport.h), viewport); if (figure_ != nullptr) { renderer_->draw(*figure_); diff --git a/adapters/gtk4/gtk_plot_area.hpp b/adapters/gtk4/gtk_plot_area.hpp index bcacfb2..3596b71 100644 --- a/adapters/gtk4/gtk_plot_area.hpp +++ b/adapters/gtk4/gtk_plot_area.hpp @@ -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. diff --git a/docs/interaction.md b/docs/interaction.md index 0af7b22..9e55b37 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -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` diff --git a/include/plot/input/controller.hpp b/include/plot/input/controller.hpp index aee07e2..2527fd2 100644 --- a/include/plot/input/controller.hpp +++ b/include/plot/input/controller.hpp @@ -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. diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index 60e041a..f26394c 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -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 diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index a222c36..0594373 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -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 diff --git a/include/plot/render/style.hpp b/include/plot/render/style.hpp index 71a67c9..a62c643 100644 --- a/include/plot/render/style.hpp +++ b/include/plot/render/style.hpp @@ -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 diff --git a/src/input/controller.cpp b/src/input/controller.cpp index 9eaaa30..3306375 100644 --- a/src/input/controller.cpp +++ b/src/input/controller.cpp @@ -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); diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 9bf1d7c..cb1a971 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -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_; } diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index dcb6735..196d5cf 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -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 diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index 275f6c5..f95de77 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -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::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); +} diff --git a/tests/test_plot.cpp b/tests/test_plot.cpp index e4b3ac0..82def3f 100644 --- a/tests/test_plot.cpp +++ b/tests/test_plot.cpp @@ -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); +} diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index e6d42d2..a6ce23a 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -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(kW), static_cast(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(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(kW), static_cast(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(); +}