fix(input,examples): a linked figure's wheel anchor uses the group's live edge, and every demo verdict reaches its exit status

Review fix pass 1 on the task 7 work; four findings, all closed.

1. The Qt demo's region sat across the middle of the window, which is exactly
   where its own scripted input check pans: after the check's box zoom the press
   opened DragMode::MoveRegion, the x range never moved, and the check printed
   'input check pan FAIL' -- under an exit 0, because the verdict was printed and
   not returned. The region now rides the left sixth (as the GLFW demo's does),
   both checks pass, and the verdict is wired to the exit status. The same audit
   found two more printed-only verdicts -- the GTK demo's scripted-resize layout
   check and its screenshot write (and Qt's) -- now wired too. What stays
   printed-only is the screenshot line of the GLFW host and the ImGui widget,
   where the dump happens inside the adapter's frame loop.

2. A follow-ing link group was invisible to the input layer: a member's own axis
   must be XFollow::Off (link_x() refuses a Live member), so Controller's
   x_zoom_anchor() extrapolated a coordinate from the pointer instead of using
   the live edge and a wheel over a row's axis strip walked the window back by
   19% of its width (behind 0.000000 -> 0.285809 s). The group now publishes its
   own edge to every member (Axis::live_owner + Plot::set_x_live_owner, written
   by Figure::push_link, cleared when the group stops following or the row
   unlinks), and x_zoom_anchor() reads it. Public-header addition, called out.
   Measured after: behind 0.012992 s and the window's right edge bit-identical to
   the edge the group published at the gesture.

3. The multiplot summary printed x_link_count() -- a number of groups -- under
   'rows in it'. It now reports the members and the group count separately, and
   every row line carries its own group id.

4. Host::controller_at() promised to clamp a pixel in a row gap to the nearest
   row and only clamped below the last one, so a gap pixel went to row 0. It now
   selects by distance, and tests/test_glfw_host.cpp pins it with a 40 px gap --
   the first coverage of the host's figure routing, which needs no window.

New cases: tests/test_controller.cpp x2 (the anchor and the hint's lifecycle),
tests/test_glfw_host.cpp x3 (routing, the gap, the single-plot fallback): 229
cases, 0 failed, ASan clean. All five demos exit 0, the three self-checking
modes exit 0, and the amended assertions were confirmed failing against the
pre-fix behaviour.
This commit is contained in:
Martino Ferrari committed 2026-09-28 15:52:08 +02:00
1 parent a70c381a1e
commit daff60e3f0
14 files changed
+439 -53

No files matched your search

+14 -6
View File
@@ -12,6 +12,7 @@
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring> #include <cstring>
#include <limits>
namespace plot::glfw { namespace plot::glfw {
namespace { namespace {
@@ -422,15 +423,22 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
const FigureLayout& layout = figure_->layout(); const FigureLayout& layout = figure_->layout();
const std::vector<Rect>& rows = layout.rows; const std::vector<Rect>& rows = layout.rows;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
// The row the pixel is in, or -- when it is in none of them, which is the row gap a
// host has asked for with `Figure::set_row_gap()` or the figure's own margins -- the
// row whose rect is nearest. Selection by distance rather than by "the first that
// matches" is what makes a pixel in a gap belong to the row it is *near*: the rows
// tile the figure at the default gap of 0, so this is unreachable until a host asks
// for one, and a gesture in a visible gap must not be routed to row 0 by default.
u32 row = 0u; u32 row = 0u;
if (y >= rows.back().y + rows.back().h) { f32 best = std::numeric_limits<f32>::infinity();
row = static_cast<u32>(rows.size() - 1u); // below every row: the nearest one is the last
} else {
for (u32 i = 0; i < rows.size(); ++i) { for (u32 i = 0; i < rows.size(); ++i) {
if (y >= rows[i].y && y < rows[i].y + rows[i].h) { const f32 top = rows[i].y;
const f32 bottom = rows[i].y + rows[i].h;
const f32 distance = y < top ? top - y : (y >= bottom ? y - bottom : 0.0f);
if (distance < best) {
best = distance;
row = i; row = i;
break; if (distance == 0.0f) break; // inside this row: nothing can be nearer
}
} }
} }
(void)x; // the rows tile the figure vertically, so y alone picks one (void)x; // the rows tile the figure vertically, so y alone picks one
+13 -12
View File
@@ -117,6 +117,19 @@ class Host {
/// mode or a hover out of it (`controller()` is the single-plot loop's, and is /// mode or a hover out of it (`controller()` is the single-plot loop's, and is
/// not the one figure gestures go to). /// not the one figure gestures go to).
input::Controller* row_controller(u32 row) noexcept; input::Controller* row_controller(u32 row) noexcept;
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_` --
/// the single-plot loop -- while no figure is set, and the controller of the figure
/// row that pixel lands in otherwise. The row rects come from `Figure::layout()`, the
/// same ones the renderer draws, so a gesture lands on the row it was aimed at; a
/// pixel inside no row -- the row gap a host asked for with `Figure::set_row_gap()`,
/// or a margin -- is the *nearest* row rather than dropped or defaulted, so a drag
/// that leaves the data area still ends where it should.
///
/// It also hands that row its own viewport, which is what keeps the controller's
/// hit-test geometry in step with the layout between two frames. The event callbacks
/// route through it, and so can a host that wants to know where a synthetic gesture
/// would land.
input::Controller& controller_at(f32 x, f32 y) noexcept;
/// The gesture controller the host feeds. Valid for the host's whole life. /// The gesture controller the host feeds. Valid for the host's whole life.
input::Controller& controller() noexcept { return controller_; } input::Controller& controller() noexcept { return controller_; }
@@ -155,18 +168,6 @@ class Host {
void on_scroll(f64 dx, f64 dy) noexcept; void on_scroll(f64 dx, f64 dy) noexcept;
void on_key(int key, int action, int mods) noexcept; void on_key(int key, int action, int mods) noexcept;
/// The controller an event at framebuffer pixel (x, y) belongs to: `controller_`
/// -- the single-plot loop -- while no figure is set, and the controller of the
/// figure row that pixel lands in otherwise. The row rects come from
/// `Figure::layout()`, the same ones the renderer draws, so a gesture lands on the
/// row it was aimed at; a pixel outside every row (the row gap, or a chrome strip
/// below the last row) is clamped to the nearest row rather than dropped, so a
/// drag that leaves the data area still ends where it should.
///
/// It also hands that row its own viewport, which is what keeps the controller's
/// hit-test geometry in step with the layout between two frames.
input::Controller& controller_at(f32 x, f32 y) noexcept;
Plot plot_; Plot plot_;
input::Controller controller_; ///< forwards its viewport to `plot_` input::Controller controller_; ///< forwards its viewport to `plot_`
std::optional<Renderer> renderer_; std::optional<Renderer> renderer_;
+27 -1
View File
@@ -278,6 +278,12 @@ struct Demo {
/// and the teardown (scheduled as an idle source). /// and the teardown (scheduled as an idle source).
bool exit_started = false; bool exit_started = false;
bool torn_down = false; bool torn_down = false;
/// The exit path's verdicts, carried out of the run so `main` can return them: whether the
/// scripted resize produced a recomputed layout, and whether the screenshot the headless
/// mode exists for actually reached the disk. Both are properties a caller checks with the
/// exit status, so neither may live only in stdout.
bool layout_recomputed = false;
bool screenshot_written = true; ///< false only when a dump was asked for and failed
bool cursors_follow = true; bool cursors_follow = true;
Meter meter; Meter meter;
@@ -364,6 +370,7 @@ gboolean on_render_after(GtkGLArea*, GdkGLContext*, gpointer user_data) {
const plot::Rect viewport = demo.area->device_viewport(); const plot::Rect viewport = demo.area->device_viewport();
const bool written = dump_area_ppm(demo.dump_path, static_cast<int>(viewport.w), const bool written = dump_area_ppm(demo.dump_path, static_cast<int>(viewport.w),
static_cast<int>(viewport.h)); static_cast<int>(viewport.h));
demo.screenshot_written = written;
std::printf("[gtk_demo] screenshot %s (%dx%d GL px): %s\n", demo.dump_path, std::printf("[gtk_demo] screenshot %s (%dx%d GL px): %s\n", demo.dump_path,
static_cast<int>(viewport.w), static_cast<int>(viewport.h), static_cast<int>(viewport.w), static_cast<int>(viewport.h),
written ? "written" : "FAILED"); written ? "written" : "FAILED");
@@ -446,6 +453,12 @@ gboolean on_exit_timer(gpointer user_data) {
after.x_title_height)) <= 0.5f; after.x_title_height)) <= 0.5f;
const bool metric_strip = drift < std::fabs(after.axes.x - scaled_strip); const bool metric_strip = drift < std::fabs(after.axes.x - scaled_strip);
const bool recomputed = after.axes.w != before.axes.w && tiles && metric_strip; const bool recomputed = after.axes.w != before.axes.w && tiles && metric_strip;
// The verdict is carried out of the run, not only printed: a headless caller reads the
// exit status, and a check whose only output is a line of stdout is a check that passes
// whatever it finds. A run that cannot resize at all (a window manager or an X server
// that clamps the window to the screen) fails here, which is the honest answer -- the
// layout this run is supposed to prove is the layout it never got.
demo.layout_recomputed = recomputed;
std::printf("\n[gtk_demo] ---- exit frame ----\n"); std::printf("\n[gtk_demo] ---- exit frame ----\n");
std::printf("[gtk_demo] resize logical %dx%d -> %dx%d, device viewport %ux%u, scale %g\n", std::printf("[gtk_demo] resize logical %dx%d -> %dx%d, device viewport %ux%u, scale %g\n",
width_before, height_before, gtk_widget_get_width(demo.area->widget()), width_before, height_before, gtk_widget_get_width(demo.area->widget()),
@@ -663,5 +676,18 @@ int main(int argc, char** argv) {
" resources went with it)"); " resources went with it)");
g_object_unref(app); g_object_unref(app);
return app_result; if (app_result != 0) return app_result;
if (!demo.screenshot_written) {
std::fprintf(stderr, "plot_gtk_demo: the screenshot could not be written\n");
return 1;
}
if (demo.automated && !demo.layout_recomputed) {
std::fprintf(stderr,
"plot_gtk_demo: the scripted resize did not produce a recomputed layout (see the"
" 'exit frame' lines above; the window must be able to reach %dx%d on this"
" screen)\n",
kExitWidth, kExitHeight);
return 1;
}
return 0;
} }
+57 -26
View File
@@ -266,17 +266,15 @@ f64 newest_x(const Series& series) {
/// is what "new data keeps arriving at the right edge" means for a window the user has /// is what "new data keeps arriving at the right edge" means for a window the user has
/// moved away from the edge. /// moved away from the edge.
/// ///
/// One measurement is deliberately *not* checked, and the reason is worth stating because /// The zoom above rolls over the axis strip, outside `axes`, where the anchor is the live
/// the demo is where it shows: the zoom above rolls over the axis strip, outside `axes`, /// edge rather than the coordinate under the pointer. That is the one thing a *link group*
/// where a single `Live` axis anchors at its live edge and keeps the window ending at the /// used to lose: a member's axis is `XFollow::Off` by construction (the group owns the
/// newest sample. A row of a following link group holds `XFollow::Off` -- the group owns /// follow state, and `link_x()` refuses a `Live` member), so `Controller::x_zoom_anchor()`
/// the follow state, and `link_x()` refuses a `Live` member -- so `Controller`'s /// could not see that the window was live and fell back to the extent the pointer's x
/// `x_zoom_anchor()` cannot see that the window is live and falls back to the data /// extrapolates to -- the middle of the axes -- which walked the window back off the edge
/// coordinate the pointer's x extrapolates to (the middle of the axes for a strip-centre /// by ~19 % of its width. A following group now publishes its own edge to every member
/// pointer). The zoom still multiplies the width and keeps following, but it also walks /// (`Plot::set_x_live_owner`), the controller reads it, and `behind` after the zoom must
/// the window back from the edge. Measured here rather than assumed: `behind` after the /// still be ~0 -- which the check below asserts, so the fix cannot silently regress.
/// zoom is what it is, and the checks below tolerate it while pinning everything the
/// library does guarantee.
/// ///
/// The verdict is carried out of the run in `failed`, so `main()` exits non-zero on a /// The verdict is carried out of the run in `failed`, so `main()` exits non-zero on a
/// failed check -- a check whose result only stdout knows is not a check -- and also when /// failed check -- a check whose result only stdout knows is not a check -- and also when
@@ -293,6 +291,12 @@ struct SyncScript {
f64 span[4]{}; f64 span[4]{};
bool shared[4]{}; bool shared[4]{};
bool live[4]{}; bool live[4]{};
/// The live edge the group had published to its members when the zoom was made: the
/// coordinate the window's right edge must still be at afterwards, to the bit. Reading
/// it here rather than comparing against the newest sample is what makes the check
/// exact -- the edge moves while the frame runs, so "the window ends at the newest
/// sample" is true only to within the one frame the measurement takes.
f64 anchor_edge = 0.0;
static SyncScript from_env() { static SyncScript from_env() {
SyncScript s; SyncScript s;
@@ -314,10 +318,22 @@ struct SyncScript {
if (series[0]->size() < 65536u || figure.plot(0).text_backend() == nullptr) return; if (series[0]->size() < 65536u || figure.plot(0).text_backend() == nullptr) return;
record(0, figure, series); record(0, figure, series);
break; break;
case 1: case 1: {
// The edge the zoom must anchor on. A following group publishes it to every member
// (`Plot::set_x_live_owner`), which is what `Controller::x_zoom_anchor()` now reads
// for a gesture outside `axes` -- without it the group's live window is invisible to
// the input layer and the anchor is the extent the pointer's x extrapolates to.
f64 edge = 0.0;
if (!figure.plot(kRows - 1u).x_live_owner(0u, edge)) {
std::fprintf(stderr, "[multiplot] sync: no live edge was published to the rows\n");
failed = true;
break;
}
anchor_edge = edge;
notches(host, figure); notches(host, figure);
record(1, figure, series); record(1, figure, series);
break; break;
}
case 2: case 2:
pan(host, figure); pan(host, figure);
record(2, figure, series); record(2, figure, series);
@@ -396,8 +412,15 @@ struct SyncScript {
const bool following = live[0] && live[1] && live[2] && live[3]; const bool following = live[0] && live[1] && live[2] && live[3];
// (a) the steady state sits on the live edge. // (a) the steady state sits on the live edge.
const bool at_edge = std::fabs(behind[0]) <= kBatchS; const bool at_edge = std::fabs(behind[0]) <= kBatchS;
// (b) the zoom, made on the bottom row, narrowed the one shared window. // (b) the zoom, made on the bottom row, narrowed the one shared window -- and, because
// the anchor outside `axes` is the live edge, left the window *ending on that edge*,
// to the bit. `zoom_on_edge` is the same statement with the slack a frame of arriving
// data costs: the edge moves on while the frame runs, so the window is within 5 % of
// its own width of the newest sample -- where anchoring on the extrapolated coordinate
// left it 30 % behind.
const bool zoomed = span[1] < span[0] * 0.99; const bool zoomed = span[1] < span[0] * 0.99;
const bool anchor_kept = near(right[1], anchor_edge, 1e-9 * std::fabs(anchor_edge) + 1e-12);
const bool zoom_on_edge = behind[1] <= 0.05 * span[1];
// (c) the pan, made on the middle row, moved the window back and left the width alone. // (c) the pan, made on the middle row, moved the window back and left the width alone.
const bool panned_back = behind[2] > behind[1] + 1e-3; const bool panned_back = behind[2] > behind[1] + 1e-3;
const bool width_kept = near(span[2], span[1], 1e-9 * span[1] + 1e-12); const bool width_kept = near(span[2], span[1], 1e-9 * span[1] + 1e-12);
@@ -408,15 +431,18 @@ struct SyncScript {
const bool tracked = near((right[3] - right[2]) - (newest[3] - newest[2]), 0.0, kBatchS); const bool tracked = near((right[3] - right[2]) - (newest[3] - newest[2]), 0.0, kBatchS);
const bool behind_kept = near(behind[3], behind[2], kBatchS); const bool behind_kept = near(behind[3], behind[2], kBatchS);
const bool width_kept_after_slide = near(span[3], span[1], 1e-9 * span[1] + 1e-12); const bool width_kept_after_slide = near(span[3], span[1], 1e-9 * span[1] + 1e-12);
failed = !(one_range && following && at_edge && zoomed && panned_back && width_kept && slid && failed = !(one_range && following && at_edge && zoomed && anchor_kept && zoom_on_edge &&
tracked && behind_kept && width_kept_after_slide); panned_back && width_kept && slid && tracked && behind_kept &&
width_kept_after_slide);
completed = true; completed = true;
std::printf( std::printf(
"[multiplot] sync checks: one range across the rows %s; following armed %s; at the live" "[multiplot] sync checks: one range across the rows %s; following armed %s; at the live"
" edge %s; the zoom narrowed the shared window %s; the pan moved the window back %s;" " edge %s; the zoom narrowed the shared window %s; the zoom kept the window ending on the"
" the right edge tracked the newest sample %s\n", " live edge %s (to the bit %s, behind %.6f s); the pan moved the window back %s; the right"
" edge tracked the newest sample %s\n",
one_range ? "yes" : "NO", following ? "yes" : "NO", at_edge ? "yes" : "NO", one_range ? "yes" : "NO", following ? "yes" : "NO", at_edge ? "yes" : "NO",
zoomed ? "yes" : "NO", panned_back && width_kept ? "yes" : "NO", zoomed ? "yes" : "NO", zoom_on_edge ? "yes" : "NO", anchor_kept ? "yes" : "NO", behind[1],
panned_back && width_kept ? "yes" : "NO",
tracked && slid && behind_kept && width_kept_after_slide ? "yes" : "NO"); tracked && slid && behind_kept && width_kept_after_slide ? "yes" : "NO");
std::fflush(stdout); std::fflush(stdout);
} }
@@ -635,21 +661,26 @@ int main() {
static_cast<unsigned long long>(series[kSine]->size()), static_cast<unsigned long long>(series[kSine]->size()),
static_cast<f64>(series[kSine]->size()) * kDx, kSampleRate * 1.0e-3, yr0.min, static_cast<f64>(series[kSine]->size()) * kDx, kSampleRate * 1.0e-3, yr0.min,
yr0.max); yr0.max);
// `x_link_count()` counts link *groups* (excluding the unlinked group 0), so it is
// labelled as one and the members are listed separately: the readout a reader checks the
// link with must not say "rows" about a number of groups.
u32 members = 0;
for (u32 row = 0; row < kRows; ++row) members += figure.x_link(row) == link ? 1u : 0u;
std::printf("[multiplot] x link group %u: range [%.6f, %.6f] s (width %.6f), follow %s," std::printf("[multiplot] x link group %u: range [%.6f, %.6f] s (width %.6f), follow %s,"
" rows in it %u\n", " %u member row%s of %u link group%s\n",
link, shared.min, shared.max, shared.span(), figure.x_link_follow(link) == link, shared.min, shared.max, shared.span(),
plot::XFollow::Live figure.x_link_follow(link) == plot::XFollow::Live ? "Live" : "Off", members,
? "Live" members == 1u ? "" : "s", figure.x_link_count(),
: "Off", figure.x_link_count() == 1u ? "" : "s");
figure.x_link_count());
for (u32 row = 0; row < kRows; ++row) { for (u32 row = 0; row < kRows; ++row) {
const plot::Plot& p = figure.plot(row); const plot::Plot& p = figure.plot(row);
const plot::Rect ra = p.layout().axes; const plot::Rect ra = p.layout().axes;
std::printf("[multiplot] row %u x [%.6f, %.6f] s, axes %.0fx%.0f at (%.0f, %.0f)," std::printf("[multiplot] row %u x [%.6f, %.6f] s, axes %.0fx%.0f at (%.0f, %.0f),"
" traces %u, legend rows %u\n", " traces %u, legend rows %u, in link group %u\n",
row, p.x_range(0).min, p.x_range(0).max, static_cast<f64>(ra.w), row, p.x_range(0).min, p.x_range(0).max, static_cast<f64>(ra.w),
static_cast<f64>(ra.h), static_cast<f64>(ra.x), static_cast<f64>(ra.y), static_cast<f64>(ra.h), static_cast<f64>(ra.x), static_cast<f64>(ra.y),
p.trace_count(), static_cast<u32>(p.layout().legend.rows.size())); p.trace_count(), static_cast<u32>(p.layout().legend.rows.size()),
figure.x_link(row));
} }
std::printf("[multiplot] region %u: x [%g, %g] s, n %llu, mean %.4f, rms %.4f, min" std::printf("[multiplot] region %u: x [%g, %g] s, n %llu, mean %.4f, rms %.4f, min"
" %.4f, max %.4f, integral %.4f\n", " %.4f, max %.4f, integral %.4f\n",
+21 -2
View File
@@ -316,6 +316,11 @@ int main(int argc, char** argv) {
const auto keep_focus = [widget] { widget->setFocus(); }; const auto keep_focus = [widget] { widget->setFocus(); };
Meter meter; Meter meter;
/// The scripted input check's verdict, read after `exec()`: a FAIL that is only printed
/// is a check no caller can act on -- the exit status is what a headless run reads. Same
/// for the exit frame's screenshot: the headless mode exists to produce that file.
bool inputs_ok = true;
bool shot_ok = true;
// The cursors ride the live window until the user takes over: the first // The cursors ride the live window until the user takes over: the first
// ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale, // ctrl+drag on a cursor, or a pan/zoom that clears the x axis' auto scale,
// stops them where the user put them. The region's bounds ride the same window. // stops them where the user put them. The region's bounds ride the same window.
@@ -343,7 +348,12 @@ int main(int argc, char** argv) {
const plot::Range xr = p.x_range(axis_x); const plot::Range xr = p.x_range(axis_x);
p.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span()); p.set_cursor_x(cursor_a, xr.min + 0.35 * xr.span());
p.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span()); p.set_cursor_x(cursor_b, xr.min + 0.65 * xr.span());
p.set_region(region, xr.min + 0.40 * xr.span(), xr.min + 0.60 * xr.span()); // The left sixth of the window, not its middle: a region is a grabbable handle, so
// one laid across the centre turns the pan this demo's scripted input check makes
// (and the pan a user makes) into a region drag -- the same trap the GLFW demo hit.
// The cursors are not within a grab radius of the centre either, which is what
// keeps the check's press a pan.
p.set_region(region, xr.min + 0.06 * xr.span(), xr.min + 0.18 * xr.span());
} }
} }
@@ -460,7 +470,9 @@ int main(int argc, char** argv) {
} }
if (screenshot != nullptr && screenshot[0] != '\0') { if (screenshot != nullptr && screenshot[0] != '\0') {
const bool written = dump_widget_ppm(image, screenshot); const bool written = dump_widget_ppm(image, screenshot);
shot_ok = written;
std::printf("[qt_demo] screenshot %s: %s\n", screenshot, written ? "written" : "FAILED"); std::printf("[qt_demo] screenshot %s: %s\n", screenshot, written ? "written" : "FAILED");
if (!written) std::fprintf(stderr, "plot_qt_demo: the screenshot could not be written\n");
} }
std::printf("[qt_demo] init %s: %s\n", plot::to_string(widget->init_status().code), std::printf("[qt_demo] init %s: %s\n", plot::to_string(widget->init_status().code),
widget->init_status().message); widget->init_status().message);
@@ -534,9 +546,15 @@ int main(int argc, char** argv) {
static_cast<double>(check_axes.w), static_cast<double>(check_axes.h)); static_cast<double>(check_axes.w), static_cast<double>(check_axes.h));
std::printf("[qt_demo] input check box-zoom %s expected x [%.9f, %.9f], got [%.9f, %.9f]\n", std::printf("[qt_demo] input check box-zoom %s expected x [%.9f, %.9f], got [%.9f, %.9f]\n",
box_ok ? "PASS" : "FAIL", box_expect_min, box_expect_max, box_got.min, box_got.max); box_ok ? "PASS" : "FAIL", box_expect_min, box_expect_max, box_got.min, box_got.max);
inputs_ok = box_ok && drag_ok;
std::printf("[qt_demo] input check pan %s expected x [%.9f, %.9f], got [%.9f, %.9f]\n", std::printf("[qt_demo] input check pan %s expected x [%.9f, %.9f], got [%.9f, %.9f]\n",
drag_ok ? "PASS" : "FAIL", drag_expect_min, drag_expect_min + before_drag.span(), drag_ok ? "PASS" : "FAIL", drag_expect_min, drag_expect_min + before_drag.span(),
drag_got.min, drag_got.max); drag_got.min, drag_got.max);
if (!inputs_ok) {
std::fprintf(stderr,
"plot_qt_demo: the scripted input check failed (see the 'input check' lines"
" above)\n");
}
// The two gestures above left the plot zoomed and panned (that is what makes // The two gestures above left the plot zoomed and panned (that is what makes
// them checks). Put the live framing back before the run summary reads the // them checks). Put the live framing back before the run summary reads the
@@ -594,5 +612,6 @@ int main(int argc, char** argv) {
// (so the plot's GL resources are released while their context is still alive, // (so the plot's GL resources are released while their context is still alive,
// and before `graph`, which the widget points at), then `graph`, then the // and before `graph`, which the widget points at), then `graph`, then the
// already-joined `producer`, then `stream` itself. // already-joined `producer`, then `stream` itself.
return app_result; if (app_result != 0) return app_result;
return inputs_ok && shot_ok ? 0 : 1;
} }
+13
View File
@@ -63,6 +63,19 @@ struct Axis {
/// directly, and `Plot::layout()`'s placement, which re-clamps it against the /// directly, and `Plot::layout()`'s placement, which re-clamps it against the
/// retained window every frame. `Plot::x_offset()` reads it back. /// retained window every frame. `Plot::x_offset()` reads it back.
f64 offset = 0.0; f64 offset = 0.0;
/// Set by an *owner* that is following this axis' window on the plot's behalf: a
/// `Figure` link group, while it follows, writes its own live edge here every frame.
/// A member's own `follow` must be `Off` (`Figure::link_x()` refuses a `Live` member
/// -- the group owns the shared window and its follow state), so without this the
/// input layer could not see that the axis is following: the controller's wheel
/// anchor would extrapolate a coordinate from the pointer instead of using the live
/// edge, and a zoom made from the axis strip would walk the window back off the edge.
///
/// `live_owner_newest` is the *owner's* edge, not this plot's: rows may be fed at
/// different rates, and a group's edge is the union of its members'. Cleared when the
/// group stops following or the row leaves it.
bool live_owner = false;
f64 live_owner_newest = 0.0;
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors. /// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
std::vector<Tick> ticks; std::vector<Tick> ticks;
+6
View File
@@ -213,6 +213,12 @@ class Figure {
f64 offset = 0.0; f64 offset = 0.0;
XFollow follow = XFollow::Off; XFollow follow = XFollow::Off;
bool has_range = false; bool has_range = false;
/// The group's live edge, as the last `place_live_window()` found it, and whether it
/// found one at all. Published to every member by `push_link()` (`Plot::set_x_live_owner`)
/// so the input layer can see that the member's window is following even though the
/// member's own axis must be `Off`.
f64 live_newest = 0.0;
bool has_live = false;
std::vector<u32> rows; std::vector<u32> rows;
std::vector<Range> pushed; std::vector<Range> pushed;
}; };
+8
View File
@@ -325,6 +325,14 @@ class Plot {
/// zoom made from outside `axes`. False -- and `newest` untouched -- when the axis /// zoom made from outside `axes`. False -- and `newest` untouched -- when the axis
/// has no such trace, i.e. when there is no live edge to follow. /// has no such trace, i.e. when there is no live edge to follow.
bool x_live_edge(u32 axis, f64& newest) const; bool x_live_edge(u32 axis, f64& newest) const;
/// Publishes -- or clears -- the live edge of an owner that is following this axis'
/// window on the plot's behalf (a `Figure` link group while it follows; see
/// `Axis::live_owner`). A no-op for an axis that does not exist.
void set_x_live_owner(u32 axis, bool live, f64 newest) noexcept;
/// Whether an owner is following this axis' window, and the edge it is following: the
/// answer `set_x_live_owner()` last wrote. False -- and `newest` untouched -- for an
/// axis no owner has claimed, and for an axis that does not exist.
bool x_live_owner(u32 axis, f64& newest) const noexcept;
/// Data -> pixels for one axis, built from the axis' range, log flag and the /// Data -> pixels for one axis, built from the axis' range, log flag and the
/// current layout. Always invertible: a collapsed range or a collapsed axes /// current layout. Always invertible: a collapsed range or a collapsed axes
+11 -3
View File
@@ -564,10 +564,18 @@ f64 Controller::x_zoom_anchor(u32 axis, f32 px, f32 py) const {
// off the plot keep the window ending at the newest sample instead of drifting // off the plot keep the window ending at the newest sample instead of drifting
// away from it. Any other axis keeps the extrapolated coordinate, and `Off` is // away from it. Any other axis keeps the extrapolated coordinate, and `Off` is
// the default, so nothing changes for a plot that is not following. // the default, so nothing changes for a plot that is not following.
if (!std::as_const(*plot_).layout().axes.contains(px, py) && //
plot_->x_follow(axis) == XFollow::Live) { // A figure row's axis is `Off` even while its window follows, because a link group
// owns the shared window *and* the follow state (`Figure::link_x()` refuses a `Live`
// member) -- so the axis' own follow flag is not the whole answer. A following group
// publishes its edge to every member (`Plot::x_live_owner()`), and that edge is read
// second: it is the group's -- the union of its members' newest x -- which is the
// coordinate the window actually ends at, and the member's own series would be a
// different answer in a figure whose rows are fed at different rates.
if (!std::as_const(*plot_).layout().axes.contains(px, py)) {
f64 live = 0.0; f64 live = 0.0;
if (plot_->x_live_edge(axis, live)) return live; if (plot_->x_follow(axis) == XFollow::Live && plot_->x_live_edge(axis, live)) return live;
if (plot_->x_live_owner(axis, live)) return live;
} }
return plot_->x_transform(axis).to_data_x(static_cast<f64>(px)); return plot_->x_transform(axis).to_data_x(static_cast<f64>(px));
} }
+26 -3
View File
@@ -67,6 +67,10 @@ u32 Figure::x_link(u32 row) const noexcept {
} }
void Figure::unlink_row(u32 row) noexcept { void Figure::unlink_row(u32 row) noexcept {
// A row that leaves its group stops following anything, so the hint a following group
// published to it goes with the membership: leaving `has_live` set behind would leave a
// former member anchoring a zoom on an edge it no longer follows.
if (row < plots_.size()) plots_[row].set_x_live_owner(0u, false, 0.0);
for (XLink& group : links_) { for (XLink& group : links_) {
for (std::size_t i = 0; i < group.rows.size(); ++i) { for (std::size_t i = 0; i < group.rows.size(); ++i) {
if (group.rows[i] != row) continue; if (group.rows[i] != row) continue;
@@ -172,8 +176,12 @@ Status Figure::set_x_link_follow(u32 group, XFollow follow) {
XLink& g = links_[group - 1u]; XLink& g = links_[group - 1u];
g.follow = follow; g.follow = follow;
if (follow != XFollow::Live) { if (follow != XFollow::Live) {
// Disarming leaves the range exactly where it is, as it does on a single axis: // Disarming leaves the range exactly where it is, as it does on a single axis: the
// the last live window becomes an ordinary stored range. // last live window becomes an ordinary stored range. The live edge goes with the
// follow state -- nothing may anchor on an edge no group is following any more -- and
// this path does not go through `push_link()`, which is the other place the hint is
// written, so it is cleared here.
for (const u32 row : g.rows) plots_[row].set_x_live_owner(0u, false, 0.0);
return Status{}; return Status{};
} }
// Arming starts from the span the group already holds -- as `Plot::set_x_follow()` // Arming starts from the span the group already holds -- as `Plot::set_x_follow()`
@@ -220,6 +228,10 @@ bool Figure::live_edge(const XLink& group, Range& out) const {
} }
bool Figure::place_live_window(XLink& group) { bool Figure::place_live_window(XLink& group) {
// Every early return leaves the group with no live edge published: a group that is not
// following, one whose members hold nothing streaming, and one whose span is not a span
// all have no edge for a member's controller to anchor on.
group.has_live = false;
if (group.follow != XFollow::Live) return false; if (group.follow != XFollow::Live) return false;
Range live{}; Range live{};
if (!live_edge(group, live)) return false; if (!live_edge(group, live)) return false;
@@ -234,13 +246,24 @@ bool Figure::place_live_window(XLink& group) {
// An offset past that would put the window's right edge behind the oldest sample // An offset past that would put the window's right edge behind the oldest sample
// left, i.e. over data that is gone. // left, i.e. over data that is gone.
group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min)); group.offset = std::clamp(group.offset, 0.0, std::max(0.0, live.max - live.min));
group.live_newest = live.max;
group.has_live = true;
group.range = Range{live.max - group.offset - group.span, live.max - group.offset}; group.range = Range{live.max - group.offset - group.span, live.max - group.offset};
return true; return true;
} }
void Figure::push_link(XLink& group) { void Figure::push_link(XLink& group) {
// The member's window is the group's and the group is what follows, so the edge is
// published with it: a gesture that asks where the live edge is (the controller's wheel
// anchor, outside `axes`) reads the group's edge rather than extrapolating from the
// pointer, which is the difference between a zoom that keeps the window on the newest
// sample and one that walks it back. A group that is not following publishes "no owner",
// which is what clears the hint the frame after `set_x_link_follow(g, Off)`.
const bool owned = group.follow == XFollow::Live && group.has_live;
for (std::size_t i = 0; i < group.rows.size(); ++i) { for (std::size_t i = 0; i < group.rows.size(); ++i) {
plots_[group.rows[i]].set_x_range(0u, group.range); Plot& p = plots_[group.rows[i]];
p.set_x_range(0u, group.range);
p.set_x_live_owner(0u, owned, group.live_newest);
group.pushed[i] = group.range; group.pushed[i] = group.range;
} }
} }
+12
View File
@@ -327,6 +327,18 @@ bool Plot::x_live_edge(u32 axis, f64& newest) const {
return true; return true;
} }
void Plot::set_x_live_owner(u32 axis, bool live, f64 newest) noexcept {
if (axis >= x_axes_.size()) return;
x_axes_[axis].live_owner = live;
x_axes_[axis].live_owner_newest = live ? newest : 0.0;
}
bool Plot::x_live_owner(u32 axis, f64& newest) const noexcept {
if (axis >= x_axes_.size() || !x_axes_[axis].live_owner) return false;
newest = x_axes_[axis].live_owner_newest;
return true;
}
bool Plot::live_x_bounds(u32 axis, Range& out) const { bool Plot::live_x_bounds(u32 axis, Range& out) const {
bool any = false; bool any = false;
for (const Trace& t : traces_) { for (const Trace& t : traces_) {
+9
View File
@@ -52,6 +52,15 @@ if(TARGET plot::imgui)
target_link_libraries(plot_tests PRIVATE plot::imgui imgui) target_link_libraries(plot_tests PRIVATE plot::imgui imgui)
endif() endif()
# The GLFW host's figure-mode routing -- which row's controller a pointer pixel reaches --
# is this project's logic too, and it needs no window: the cases drive `controller_at()`
# against the rects `Figure::layout()` computes. Guarded on the adapter's target for the
# same reason as the ImGui block above.
if(TARGET plot::glfw)
target_sources(plot_tests PRIVATE test_glfw_host.cpp)
target_link_libraries(plot_tests PRIVATE plot::glfw)
endif()
# The Qt adapter's pointer anchor is deliberately toolkit-free (see # The Qt adapter's pointer anchor is deliberately toolkit-free (see
# adapters/qt6/qt_pointer.hpp), so this one piece of that adapter's translation is # adapters/qt6/qt_pointer.hpp), so this one piece of that adapter's translation is
# tested without linking Qt -- and without `PLOT_BUILD_QT6`, since no Qt target has # tested without linking Qt -- and without `PLOT_BUILD_QT6`, since no Qt target has
+121
View File
@@ -1388,3 +1388,124 @@ PLOT_TEST(a_middle_drag_on_a_figures_bottom_row_zooms_the_shared_x_axis) {
PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).span(), shared_before.span(), 1e-12); PLOT_CHECK_NEAR(figure.plot(0u).x_range(0u).span(), shared_before.span(), 1e-12);
PLOT_CHECK_NEAR(figure.x_link_range(group).span(), shared_before.span(), 1e-12); PLOT_CHECK_NEAR(figure.x_link_range(group).span(), shared_before.span(), 1e-12);
} }
// The live edge a *link group* follows, seen by the input layer.
//
// A figure row's x axis is `XFollow::Off` by construction -- the group owns the shared
// window and its follow state, and `link_x()` refuses a `Live` member -- so before a
// following group published its edge, `Controller::x_zoom_anchor()` could not see that the
// axis was following at all. A wheel over the row's axis strip then anchored on the
// coordinate the pointer's x extrapolates to (the middle of the axes for a strip-centre
// pointer) rather than on the live edge, and the window walked back off the newest sample
// by a fifth of its width. These two cases pin the fix from the two ends: the gesture keeps
// the edge, and the hint that makes it possible exists exactly while a group follows.
PLOT_TEST(a_wheel_on_a_following_figures_axis_strip_keeps_the_live_edge) {
constexpr u32 kFigureRows = 3u;
constexpr f64 kWheelDx = 1.0 / 1000.0;
plot::data::StreamSeries series(1u << 12, 0.0, kWheelDx);
plot::f32 samples[512];
for (u32 i = 0; i < 512u; ++i) samples[i] = static_cast<plot::f32>(i % 7u);
series.push(samples, 512u);
series.acquire();
plot::Figure figure(kFigureRows);
figure.set_viewport({0.0f, 0.0f, 800.0f, 600.0f});
for (u32 row = 0; row < kFigureRows; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
PLOT_CHECK(p.trace_count() == 1u);
}
const u32 group = figure.add_x_link();
for (u32 row = 0; row < kFigureRows; ++row) PLOT_CHECK(figure.link_x(row, group));
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.25}));
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
figure.layout();
const f64 newest = series.x_bounds().max;
PLOT_CHECK_NEAR(figure.x_link_range(group).max, newest, 1e-12);
for (u32 row = 0; row < kFigureRows; ++row) {
f64 edge = 0.0;
PLOT_CHECK(figure.plot(row).x_live_owner(0u, edge));
PLOT_CHECK_NEAR(edge, newest, 1e-12);
// The member's own axis is still Off: the group is what follows.
PLOT_CHECK(figure.plot(row).x_follow(0u) == plot::XFollow::Off);
}
const plot::PlotLayout& bottom = figure.plot(kFigureRows - 1u).layout();
const f32 strip_x = bottom.axes.x + 0.5f * bottom.axes.w;
const f32 strip_y = bottom.x_label_area.y + 0.5f * bottom.x_label_area.h;
PLOT_CHECK(bottom.x_label_area.contains(strip_x, strip_y));
const plot::Range before = figure.x_link_range(group);
plot::input::Controller c(figure.plot(kFigureRows - 1u));
c.set_viewport(figure.viewport());
const plot::input::WheelEvent wheel{strip_x, strip_y, 0.0f, 1.0f, 0u};
for (u32 i = 0; i < 3u; ++i) c.on_wheel(wheel);
figure.layout();
const plot::Range after = figure.x_link_range(group);
// The zoom reached the group's one window and narrowed it...
PLOT_CHECK(after.span() < before.span() * 0.99);
// ... and left it ending on the live edge, which is the anchor a gesture outside `axes`
// must use. Pre-fix this is `newest - 0.2 span` or so: the extrapolated anchor.
PLOT_CHECK_NEAR(after.max, newest, 1e-12);
for (u32 row = 0; row < kFigureRows; ++row) {
PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).max, after.max, 1e-12);
PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).min, after.min, 1e-12);
}
}
PLOT_TEST(a_figures_live_edge_hint_is_published_only_while_the_group_follows) {
constexpr u32 kFigureRows = 2u;
plot::data::StreamSeries series(1u << 10, 0.0, 1.0 / 100.0);
plot::f32 samples[64];
for (u32 i = 0; i < 64u; ++i) samples[i] = 1.0f;
series.push(samples, 64u);
series.acquire();
plot::Figure figure(kFigureRows);
figure.set_viewport({0.0f, 0.0f, 400.0f, 400.0f});
for (u32 row = 0; row < kFigureRows; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
}
const u32 group = figure.add_x_link();
for (u32 row = 0; row < kFigureRows; ++row) PLOT_CHECK(figure.link_x(row, group));
PLOT_CHECK(figure.set_x_link_range(group, {0.0, 0.5}));
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
figure.layout();
f64 edge = 0.0;
PLOT_CHECK(figure.plot(0u).x_live_owner(0u, edge));
PLOT_CHECK_NEAR(edge, series.x_bounds().max, 1e-12);
// Disarming takes the edge away at once -- nothing may anchor on an edge no group is
// following -- and leaves the range exactly where it was.
const plot::Range held = figure.x_link_range(group);
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Off));
PLOT_CHECK(!figure.plot(0u).x_live_owner(0u, edge));
PLOT_CHECK_NEAR(figure.x_link_range(group).max, held.max, 1e-12);
// Arming publishes it again, and a row that leaves the group loses it.
PLOT_CHECK(figure.set_x_link_follow(group, plot::XFollow::Live));
figure.layout();
PLOT_CHECK(figure.plot(1u).x_live_owner(0u, edge));
PLOT_CHECK(figure.link_x(1u, 0u)); // back to the unlinked group
PLOT_CHECK(!figure.plot(1u).x_live_owner(0u, edge));
PLOT_CHECK(figure.plot(0u).x_live_owner(0u, edge)); // row 0 is still in the group
// A plot no figure owns is never claimed, `Live` or not: the hint is an owner's, and a
// single plot anchors on its own edge through `x_live_edge()`, which is unchanged.
plot::Plot solo;
solo.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
solo.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
solo.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, 0u, 0u, "s"});
solo.set_x_follow(0u, plot::XFollow::Live);
solo.layout();
PLOT_CHECK(solo.x_follow(0u) == plot::XFollow::Live);
PLOT_CHECK(!solo.x_live_owner(0u, edge));
}
+101
View File
@@ -0,0 +1,101 @@
// The GLFW host's event routing in figure mode: which row's controller a pointer pixel
// reaches.
//
// This is the adapter's own logic and not GLFW's -- a figure is several plots stacked in
// one framebuffer, and the host has to decide which of them a gesture was aimed at -- so it
// is tested here rather than through a window. Nothing below calls `run()`: the case needs
// no GL context, no display and no window, only the rects `Figure::layout()` computes and
// the controllers the host builds from them.
#include "harness.hpp"
#include <glfw/glfw_host.hpp>
#include <plot/plot/figure.hpp>
namespace {
/// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so
/// each row lays out as a plot rather than as an empty rect), handed to a host.
struct Routed {
explicit Routed(plot::u32 rows, plot::f32 gap = 0.0f) : figure(rows) {
figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f});
figure.set_row_gap(gap);
for (plot::u32 row = 0; row < rows; ++row) {
plot::Plot& p = figure.plot(row);
p.add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false});
p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
}
host.set_figure(&figure);
figure.layout();
}
plot::Figure figure;
plot::glfw::Host host;
};
} // namespace
PLOT_TEST(the_host_routes_a_pixel_to_the_row_it_is_in) {
Routed r(3u);
const std::vector<plot::Rect> rows = r.figure.layout().rows;
PLOT_CHECK(rows.size() == 3u);
for (plot::u32 row = 0; row < 3u; ++row) {
PLOT_CHECK(r.host.row_controller(row) != nullptr);
const plot::f32 x = rows[row].x + 0.5f * rows[row].w;
const plot::f32 y = rows[row].y + 0.5f * rows[row].h;
PLOT_CHECK(&r.host.controller_at(x, y) == r.host.row_controller(row));
}
// Above the first row and below the last one (the figure's own margins) are still the
// nearest row rather than a dropped event: a drag that leaves the data area must end
// where it should.
PLOT_CHECK(&r.host.controller_at(150.0f, 0.0f) == r.host.row_controller(0u));
PLOT_CHECK(&r.host.controller_at(150.0f, 599.0f) == r.host.row_controller(2u));
}
PLOT_TEST(the_host_routes_a_pixel_in_a_row_gap_to_the_nearest_row) {
// A gap is what `set_row_gap()` asks for. Rows tile the figure at the default of 0, so
// this is the only way a pixel can land in none of them -- and the routing must then
// answer with the row it is *near*, not with row 0, which is what a "first row that
// matches, else whatever `row` started at" loop would do.
Routed r(3u, 40.0f);
const std::vector<plot::Rect> rows = r.figure.layout().rows;
PLOT_CHECK(rows.size() == 3u);
for (plot::u32 row = 0; row + 1u < 3u; ++row) {
const plot::f32 gap = rows[row + 1u].y - rows[row].bottom();
PLOT_CHECK(gap > 0.0f);
const plot::f32 middle = rows[row].bottom() + 0.5f * gap;
// Just past a row's bottom edge the gap pixel is that row's...
PLOT_CHECK(&r.host.controller_at(150.0f, rows[row].bottom() + 1.0f) ==
r.host.row_controller(row));
// ... and just above the next row's top edge it is the next row's.
PLOT_CHECK(&r.host.controller_at(150.0f, rows[row + 1u].y - 1.0f) ==
r.host.row_controller(row + 1u));
// The middle of the gap goes to the nearer of the two rows -- and, whatever it picks,
// to a row of *this* figure rather than to a default: this is the assertion the
// "first match, else whatever `row` started at" loop failed, since it answered row 0
// for every gap pixel.
plot::input::Controller& in_gap = r.host.controller_at(150.0f, middle);
PLOT_CHECK(&in_gap == r.host.row_controller(row) || &in_gap == r.host.row_controller(row + 1u));
PLOT_CHECK(&in_gap != r.host.row_controller(2u) || row + 1u == 2u);
}
}
PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) {
plot::glfw::Host host;
PLOT_CHECK(host.figure() == nullptr);
PLOT_CHECK(host.row_controller(0u) == nullptr);
// With no figure the routing is the single-plot loop's: one controller for every pixel.
PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller());
PLOT_CHECK(&host.controller_at(1000.0f, 1000.0f) == &host.controller());
plot::Figure figure(2u);
figure.set_viewport({0.0f, 0.0f, 300.0f, 600.0f});
host.set_figure(&figure);
PLOT_CHECK(host.figure() == &figure);
PLOT_CHECK(host.row_controller(0u) != nullptr);
PLOT_CHECK(host.row_controller(2u) == nullptr); // a row that does not exist
// And clearing it goes back to the single-plot answer.
host.set_figure(nullptr);
PLOT_CHECK(host.figure() == nullptr);
PLOT_CHECK(host.row_controller(0u) == nullptr);
PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller());
}