From 770da4c2b2382c71be19c23996367501df5d2da6 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Mon, 28 Sep 2026 15:03:19 +0200 Subject: [PATCH] fix(plot): a figure row's strip shows labels and title independently, and the shared axis is the bottom row's strip --- include/plot/plot/figure.hpp | 27 +++-- include/plot/plot/plot.hpp | 64 ++++++---- include/plot/render/renderer.hpp | 16 +-- src/plot/figure.cpp | 64 +++++----- src/plot/plot.cpp | 23 ++-- src/render/renderer.cpp | 60 ++++------ tests/test_controller.cpp | 82 +++++++++++++ tests/test_figure.cpp | 57 +++++---- tests/test_render_smoke.cpp | 197 +++++++++++++++++++++++++------ 9 files changed, 415 insertions(+), 175 deletions(-) diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index d097064..35bba35 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -24,12 +24,18 @@ struct FigureLayout { /// strips and axes come from its own `Plot::layout()` against this rect, so a row /// with a y title keeps its column and no two rows can disagree about chrome. std::vector rows; - /// The strip the figure's one shared x axis is drawn in: the full width of the - /// figure, directly under the last row, and zero-sized when the figure draws no - /// shared axis (`set_x_labels_only_on_bottom(false)`, or no rows). It is outside - /// every row rect, because the strip is the figure's -- the rows gave it up - /// (`Plot::set_x_label_strip()`), which is what keeps the labels from appearing - /// once per row. + /// The strip the figure's one shared x axis is drawn in: the bottom row's own + /// `PlotLayout::x_label_area`, at the bottom of the bottom row's rect and so as + /// wide as that row's axes rather than the whole figure. Read back from the row + /// rather than derived here, so a host and the frame cannot disagree about where + /// the axis is. It is nonzero whenever the figure has rows and the bottom row + /// reserves a label strip -- including the case where every row labels itself, in + /// which case it is simply the bottom row's own strip, one of several. + /// + /// It is the rect the controller's axis-zoom hit test reads (`x_label_area` is what + /// a middle drag in a label strip enters `DragMode::AxisZoomX` from), which is why + /// the shared axis lives in a row rather than in a band of its own: a strip outside + /// every row would be a zero-height rect no press can ever land in. Rect x_axis_strip; /// The vertical gap between two rows, in device pixels. 0 when the rows tile the /// figure edge to edge, which is the default; it is dropped for a figure too @@ -50,9 +56,12 @@ struct FigureLayout { /// **The shared x axis.** Row heights are an equal division of the figure by /// default, `set_row_weights()` gives a row more of it, and every row keeps a floor /// height so chrome can never erase a plot. When `set_x_labels_only_on_bottom()` is -/// on -- the default -- the rows reserve no x label strip of their own and the -/// figure draws one shared axis under the last row, from the last row's own ticks -/// and transform: one set of labels, one baseline, no duplication. +/// on -- the default -- the figure's one x axis is the **bottom row's own**: the rows +/// above reserve no x chrome at all, and the bottom row keeps its own label and title +/// strips, so those labels and that title are drawn once, from the bottom row's own +/// ticks and transform. That places the shared axis *inside* a row, where the +/// controller's axis-zoom hit test can reach it, instead of in a band outside every +/// row that no gesture could land in. /// /// **The x link.** `plot(i).set_x_range()` and a controller gesture both reach a /// member's x range the same way, so a figure cannot see either of them happen. diff --git a/include/plot/plot/plot.hpp b/include/plot/plot/plot.hpp index a709081..5e7136c 100644 --- a/include/plot/plot/plot.hpp +++ b/include/plot/plot/plot.hpp @@ -17,6 +17,34 @@ namespace plot { +/// Which parts of a plot's bottom x chrome `layout()` reserves. +/// +/// The bottom chrome is two strips -- the *label* strip the tick labels live in and +/// the *title* strip the axis titles live in -- and they are one enum rather than two +/// flags because a strip means little on its own: a title with no labels needs a row +/// of its own below where the labels would have been, and a figure's interior rows want +/// neither. Naming the combinations keeps a caller from switching one off and silently +/// taking the other with it, which is what a boolean would do. +/// +/// The one caller that selects a mode is `Figure`: an interior row of a figure that +/// draws its x labels on the bottom only reserves no x chrome at all (`None`), so its +/// `axes` reaches its rect's bottom edge and the figure's one x axis -- the bottom row's +/// own, whose strip the shared axis occupies -- is the figure's only one. A plot drawn +/// on its own is `Both`, which is what it has always been; `LabelsOnly` and `TitleOnly` +/// are for a host that wants one without the other. +enum class XStrip { Both, LabelsOnly, TitleOnly, None }; + +/// Whether a strip mode reserves the label strip. One rule, read by `Plot::layout()` +/// when it sizes the strip and by the renderer when it decides whether to stage the +/// labels -- a row that reserves none would otherwise stage them over its own data. +constexpr bool x_strip_has_labels(XStrip mode) noexcept { + return mode == XStrip::Both || mode == XStrip::LabelsOnly; +} +/// Whether a strip mode reserves the title strip, the same one rule for the title. +constexpr bool x_strip_has_title(XStrip mode) noexcept { + return mode == XStrip::Both || mode == XStrip::TitleOnly; +} + /// Pixel geometry of a plot's chrome, all of it in the same pixel space as /// `Rect` (origin top-left) and therefore in **device** pixels: everything here is /// what the renderer draws with, already converted from the logical metrics in @@ -43,9 +71,8 @@ struct PlotLayout { /// Left strip width. The default is used when no text backend has been set. f32 y_axis_width = 56.0f; /// Bottom strip height. The default is used when no text backend has been set, and - /// it is 0.0f when the strip is not reserved at all -- see - /// `Plot::set_x_label_strip()`, which is how a row of a `Figure` hands the band to - /// the figure's one shared axis. + /// it is 0.0f when the label strip is not reserved at all -- see `XStrip`, which is + /// how a row of a `Figure` hands the band to the figure's one shared x axis. f32 x_axis_height = 22.0f; /// Width of `y_title_area` and height of `x_title_area`, both 0.0f when nothing /// is reserved. Every titled axis of a direction gets its own text line inside @@ -134,23 +161,18 @@ class Plot { /// The scale the last layout was computed at. 1.0 until a host says otherwise. f32 ui_scale() const noexcept { return style_.ui_scale; } - /// Whether `layout()` reserves the bottom x label strip -- and, with it, the x - /// title strip: a title is the labels' caption and goes where they do. + /// Which parts of the bottom x chrome `layout()` reserves; `XStrip::Both` by + /// default, which is what a plot drawn on its own has always been. /// - /// True by default, which is what a plot drawn on its own has always been. A - /// `Figure` turns it off on every row while it draws one shared x axis under its - /// last row (see `Figure::set_x_labels_only_on_bottom()`): the strip is not - /// merely left blank, it is not reserved at all, so such a row's `axes` reaches - /// the row rect's bottom edge and the row gives the band to the figure's shared - /// axis instead of growing a second, duplicate one. Nothing else about the row - /// changes -- its ticks still refresh and its grid still builds, because the - /// tick list is what the shared axis and the grid are drawn from, not the strip. - /// - /// The plot it belongs to draws no x tick labels while it is off (their strip - /// has no height left to hold them); whoever turned it off is the one that puts - /// them somewhere -- `Renderer::draw(Figure&)` draws them in the figure's strip. - void set_x_label_strip(bool on) noexcept { x_label_strip_ = on; } - bool x_label_strip() const noexcept { return x_label_strip_; } + /// A part that is not reserved costs nothing: the strip has no height (or the title + /// no row), `axes` grows into the band, and the renderer stages no run for it. A + /// figure's interior rows are the case that asked for it -- they sit under one shared + /// x axis, so they reserve no x chrome at all -- and `Figure::set_x_labels_only_on_bottom()` + /// is what selects their mode. Nothing else about such a row changes: its ticks still + /// refresh and its grid still builds, because the tick list is what a grid and a + /// shared axis are drawn from, not the strip. + void set_x_strip_mode(XStrip mode) noexcept { x_strip_ = mode; } + XStrip x_strip_mode() const noexcept { return x_strip_; } /// The font layout() measures tick labels with. Borrowed, not owned, and may be /// null -- then the label areas fall back to PlotLayout's defaults and no string @@ -555,8 +577,8 @@ class Plot { render::Style style_{}; const render::TextBackend* text_ = nullptr; Legend legend_{}; - /// Whether the bottom x label strip is reserved at all; see set_x_label_strip(). - bool x_label_strip_ = true; + /// Which parts of the bottom x chrome are reserved at all; see set_x_strip_mode(). + XStrip x_strip_ = XStrip::Both; /// Which handles a pointer is on, published by the input layer (see HotHandles). HotHandles hot_{}; diff --git a/include/plot/render/renderer.hpp b/include/plot/render/renderer.hpp index 9605153..2c44fc9 100644 --- a/include/plot/render/renderer.hpp +++ b/include/plot/render/renderer.hpp @@ -140,11 +140,12 @@ class Renderer { /// exactly as a stand-alone plot is, and `draw(Plot&)` itself is unchanged for /// single-plot hosts. - /// The shared axis is the frame's last text: when the figure says labels belong on - /// the bottom only (the default), the rows reserved no x label strip of their own - /// and the labels of the last row's x axes are staged once, in the strip - /// `FigureLayout::x_axis_strip` measured, under the last row. With that switched - /// off, every row draws its own labels and this draws no shared axis at all. + /// The figure's one shared x axis is the **bottom row's own**, so it is drawn by + /// that row's normal path: when the figure says labels belong on the bottom only + /// (the default), the rows above reserve no x chrome and the bottom row stages its + /// own labels and title once, inside its own strip -- the rect + /// `FigureLayout::x_axis_strip` reads back. With that switched off, every row keeps + /// its own chrome and each draws its own labels; there is then no shared axis. /// Styling, the text backend and `ui_scale` are installed on **every** row before /// the figure computes its layout, so the strips the rows measure and the strokes @@ -363,11 +364,6 @@ class Renderer { /// ticks, the strips and the grid, so it is computed once, in `draw()`, and handed /// down -- never re-derived per helper. void draw_tick_labels(const Plot& plot, const PlotLayout& layout); - /// The x tick labels of every x axis of `plot`, centred on their ticks in `strip`: - /// what `draw_tick_labels()` uses with the plot's own `x_label_area`, and what - /// `draw(Figure&)` uses with the figure's shared strip -- one rule for a plot's - /// labels and a figure's, so the two can never place a label differently. - void draw_x_tick_labels(const Plot& plot, const Rect& strip); void draw_titles(const Plot& plot, const PlotLayout& layout); /// The data helpers take it too; they read the axes rect and the transforms it /// pins. diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index e386c75..7959f0f 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -299,28 +299,13 @@ const FigureLayout& Figure::layout() { } const f32 figure_h = std::max(0.0f, viewport_.h); - // The shared strip's height is what one of these rows' own x label strip would ask - // for, and only a plot can measure that: it is the text backend's line height plus - // the label padding, clamped against the area it has. So the last row is laid out - // against the whole figure once, with its own strip switched back on, and its - // answer is what the figure reserves. The probe's viewport is the figure's rather - // than the row's on purpose -- the strip belongs to the figure, so the row's own - // budget must not shrink it. - // - // That probe is also why the strip cannot eat the rows, and why there is no second - // clamp here to say so: the plot's own chrome cap (`Plot::layout()`'s kMinAxesShare) - // already holds a label strip to half of the viewport it was laid out against, and - // the viewport it was laid out against is the figure. A figure therefore always has - // at least half its height to divide between the rows. - f32 strip_h = 0.0f; - if (labels_bottom_only_) { - Plot& probe = plots_.back(); - probe.set_viewport(viewport_); - probe.set_x_label_strip(true); - strip_h = probe.layout().x_axis_height; - } - - const f32 content_h = figure_h - strip_h; + // The rows tile the figure itself: the shared x axis is the *bottom row's own*, so + // nothing is taken off the figure for it. That is what keeps the axis reachable -- + // its strip is a real `PlotLayout::x_label_area` inside the bottom row's rect, which + // is the rect the controller's axis-zoom hit test reads and the strip an x title is + // drawn in -- and it is why an interior row reserves no x chrome at all instead of + // leaving a band no one owns. + const f32 content_h = figure_h; // The gap is dropped, rather than squeezing the rows, when the figure cannot hold // it: a figure one pixel tall with four rows still gives every row a positive // height. @@ -332,11 +317,11 @@ const FigureLayout& Figure::layout() { } // ... and the height to divide is clamped non-negative here, so every height below // is a share of a non-negative total and no row rect can come out negative: a row - // rect is handed to a plot, and a plot laid out against a negative one is not a - // case that layout arithmetic is defined for. The clamp cannot fire -- the strip is - // at most half the figure (above), so there is always at least half a figure of - // height to divide -- and it is what keeps that reasoning in the arithmetic rather - // than in a comment. + // rect is handed to a plot, and a plot laid out against a negative one is not a case + // that layout arithmetic is defined for. Nothing above can make it negative -- the + // figure's height is clamped at zero and a gap that would eat it is dropped, both + // just above -- so the clamp is the arithmetic carrying its own invariant rather + // than a comment asking the reader to carry it. usable = std::max(0.0f, usable); // The floor, then the weights' share of what is left. Giving every row the floor @@ -363,14 +348,23 @@ const FigureLayout& Figure::layout() { consumed = end; y = layout_.rows[row].bottom() + gap; } + // The gaps are only *between* rows: the rows advance by `gap`, but they divide + // `usable`, which is already the figure less every gap, so the last row lands on the + // figure's bottom edge for any `row_gap_` -- the figure is tiled, not padded at the + // end. `row_gap` reports the gap actually left, which is 0 when the figure was too + // short to hold the one asked for. layout_.row_gap = gap; + const u32 bottom_row = rows - 1u; for (u32 row = 0; row < rows; ++row) { Plot& p = plots_[row]; p.set_viewport(layout_.rows[row]); - // The rows give their x label strip to the figure's one shared axis; with the - // shared axis switched off each row keeps its own and draws its own labels. - p.set_x_label_strip(!labels_bottom_only_); + // One x axis for the whole figure, and it is the bottom row's: every row keeps its + // x chrome -- labels, title and the strip the controller's axis zoom is found in -- + // and every other row gives all of it to that one, so the labels and the title are + // not drawn once per row. A figure told to label every row keeps them everywhere, + // and then there is no shared axis to speak of: each row has its own. + p.set_x_strip_mode(labels_bottom_only_ && row != bottom_row ? XStrip::None : XStrip::Both); } // After the viewports, so a row's ticks and grid -- built from its axes rect -- and // its transforms describe this frame's rects; and before the rows lay themselves @@ -383,9 +377,13 @@ const FigureLayout& Figure::layout() { // is the one that computes its own. for (u32 row = 0; row < rows; ++row) plots_[row].layout(); - // The strip hugs the figure's bottom, directly under the last row (a gap, if any, - // separates the two exactly as it separates two rows). - layout_.x_axis_strip = Rect{viewport_.x, viewport_.bottom() - strip_h, viewport_.w, strip_h}; + // The figure's one x axis is the bottom row's, so the strip it is drawn in is that + // row's own label area -- the rect the controller hit-tests and the renderer stages + // its labels in, read back here rather than derived again, so a host and the frame + // cannot disagree about where the shared axis is. It is zero-sized only when there + // are no rows at all: with `set_x_labels_only_on_bottom(false)` it is simply the last + // row's own strip, one of several. + layout_.x_axis_strip = plots_.back().layout().x_label_area; return layout_; } diff --git a/src/plot/plot.cpp b/src/plot/plot.cpp index 007a9b0..a6c7c00 100644 --- a/src/plot/plot.cpp +++ b/src/plot/plot.cpp @@ -661,12 +661,13 @@ const PlotLayout& Plot::layout() { y_width = y_axes_.empty() ? 0.0f : 2.0f * pad; x_height = text_->height() + 2.0f * pad; } - // A row of a `Figure` gives its x strip to the figure's one shared axis, so it - // reserves none: the seed and the fixed point below both start from zero, and - // `axes` then reaches the viewport's bottom edge. Set here rather than in the - // loop, because the loop's `need_x` is what the strip wants and this overrides - // the want, not the measurement. - if (!x_label_strip_) x_height = 0.0f; + // A row of a `Figure` gives its x strip to the figure's one shared axis -- the + // bottom row's own strip, where that axis lives -- so it reserves none: the seed and + // the fixed point below both start from zero, and `axes` then reaches the viewport's + // bottom edge. Set here rather than in the loop, because the loop's `need_x` is what + // the strip wants and this overrides the want, not the measurement. + const bool x_labels = x_strip_has_labels(x_strip_); + if (!x_labels) x_height = 0.0f; // The title strips, though, are needed before the loop and never change, unlike // the label strips: a strip holds exactly one text line per titled axis and no // tick label can change that. Nothing at all is reserved without a text backend @@ -676,7 +677,10 @@ const PlotLayout& Plot::layout() { // per titled y axis *wide*, because the y title is drawn rotated and so spends // its length along the axes' height and takes a column, not a row, per titled // axis (see y_title_strip_width). - const u32 x_titles = x_label_strip_ ? titled_x_axis_count() : 0u; + // The title strip is its own part of the mode: a mode that reserves the labels and + // no title reserves no row for a title either, and one that reserves the title and no + // labels still gets its row -- below where the labels would have been. + const u32 x_titles = x_strip_has_title(x_strip_) ? titled_x_axis_count() : 0u; const f32 y_title_w = text_ != nullptr ? y_title_strip_width() : 0.0f; const f32 x_title_h = text_ != nullptr && x_titles != 0u ? static_cast(x_titles) * text_->height() + 2.0f * pad @@ -719,9 +723,8 @@ const PlotLayout& Plot::layout() { // Overridden, not merely unmeasured: the strip is not reserved, so the fixed // point has to be zero for a row that gave its strip away. const f32 need_x = - x_label_strip_ - ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget) - : 0.0f; + x_labels ? std::min(std::clamp(text_->height() + 2.0f * pad, 0.0f, viewport_.h), x_budget) + : 0.0f; if (need_y == y_width && need_x == x_height) break; // strips are a fixed point y_width = need_y; x_height = need_x; diff --git a/src/render/renderer.cpp b/src/render/renderer.cpp index d258331..fbc4826 100644 --- a/src/render/renderer.cpp +++ b/src/render/renderer.cpp @@ -364,27 +364,22 @@ void Renderer::draw(Figure& figure) { for (u32 row = 0; row < rows; ++row) prepare_plot(figure.plot(row)); // The figure's one layout for the frame. It hands every row its rect and its - // x-label policy, advances the link groups, and lays every row out against its own + // x-strip mode, advances the link groups, and lays every row out against its own // rect -- which is why the rows below are drawn with the layout it produced rather // than laying each row out a second time. Nothing draws before this: a row's // viewport is the figure's to set. - const FigureLayout& layout = figure.layout(); + // + // The figure's one x axis needs no pass of its own: it is the bottom row's own, so + // that row stages it in its own strip through `draw_tick_labels()` like any other + // plot's axis -- and every row above it reserved no x chrome, which is what keeps + // the labels from appearing once per row without a second staging here. + figure.layout(); for (u32 row = 0; row < rows; ++row) { Plot& plot = figure.plot(row); // The layout the figure just computed, read through the non-recomputing // overload -- the one layout of this frame, not a second derivation of it. draw_plot(plot, static_cast(plot).layout()); } - - // The figure's one shared x axis, staged last so it joins the frame's single text - // flush: the labels of the last row's x axes, in the strip the figure measured - // under it. The rows reserved no strip of their own for them (see - // `Plot::set_x_label_strip()`), which is what keeps one set of labels from being - // drawn once per row; with the figure set to label every row, there is no shared - // strip and each row drew its own above. - if (rows != 0u && figure.x_labels_only_on_bottom() && layout.x_axis_strip.h > 0.0f) { - draw_x_tick_labels(figure.plot(rows - 1u), layout.x_axis_strip); - } flush_text(); } @@ -654,32 +649,29 @@ void Renderer::draw_axis_lines(const Plot& plot) { } } -void Renderer::draw_x_tick_labels(const Plot& plot, const Rect& strip) { - // Centred under their tick, on the strip's single text line -- the one rule for a - // plot's own label strip and for the strip a figure shares out under its last row. - // A plot that reserved no strip of its own hands its labels to the figure, which - // is why this takes the rect rather than reading the layout it came from. - const f32 line_h = font_backend_.height(); - for (u32 i = 0; i < plot.x_axis_count(); ++i) { - const Transform transform = plot.x_transform(i); - for (const Tick& tick : plot.x_axis(i).ticks) { - if (!tick.major || tick.label.empty()) continue; - const f32 width = font_backend_.measure(tick.label.c_str()); - stage_text({static_cast(transform.to_screen_x(tick.value)) - 0.5f * width, - strip.y + 0.5f * (strip.h - line_h)}, - tick.label.c_str()); - } - } -} - void Renderer::draw_tick_labels(const Plot& plot, const PlotLayout& layout) { const f32 line_h = font_backend_.height(); // X labels: unless the plot gave its x label strip away -- a row of a `Figure` - // whose figure drew one shared axis instead -- in which case it has no strip to - // put them in and their absence is the point. A plot with no strip would otherwise - // stage its labels just above `axes`' bottom edge, i.e. over its own data. - if (plot.x_label_strip()) draw_x_tick_labels(plot, layout.x_label_area); + // that reserves no x chrome, because the figure draws one shared axis in the bottom + // row's own strip instead -- in which case it has no strip to put them in and their + // absence is the point. A plot with no strip would otherwise stage its labels just + // above `axes`' bottom edge, i.e. over its own data. Centred under their tick on the + // strip's single text line, which is the one rule for a plot's labels and a figure's + // shared axis alike -- the figure's axis is drawn by this same pass, in the bottom + // row's own `x_label_area`. + if (x_strip_has_labels(plot.x_strip_mode())) { + for (u32 i = 0; i < plot.x_axis_count(); ++i) { + const Transform transform = plot.x_transform(i); + for (const Tick& tick : plot.x_axis(i).ticks) { + if (!tick.major || tick.label.empty()) continue; + const f32 width = font_backend_.measure(tick.label.c_str()); + stage_text({static_cast(transform.to_screen_x(tick.value)) - 0.5f * width, + layout.x_label_area.y + 0.5f * (layout.x_label_area.h - line_h)}, + tick.label.c_str()); + } + } + } // Y labels: right-aligned in their strip, centred on the tick and pulled back // inside the strip at the two extremes (a label centred on the top tick would diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp index 1803f4c..57bba76 100644 --- a/tests/test_controller.cpp +++ b/tests/test_controller.cpp @@ -1,6 +1,7 @@ #include "harness.hpp" #include +#include #include #include #include @@ -1306,3 +1307,84 @@ PLOT_TEST(hot_handles_follow_a_gesture_that_moves_the_geometry) { PLOT_CHECK(f.plot.hot_handles().zone == f.controller.region_grab(rid)); PLOT_CHECK(f.plot.hot_handles().zone != plot::RegionGrab::Left); } + +/// A `Figure`'s shared x axis is the **bottom row's own**, so the axis zoom is +/// reachable there through the one hit test the controller has always used: the press +/// that lands in the bottom row's x label strip starts `AxisZoomX` exactly as it does on +/// a stand-alone plot, and because the rows share one x link the zoom moves all of them. +/// +/// A figure that put its shared axis *outside* every row would leave this gesture +/// unreachable on every row at once: `x_label_area` would be zero-height, `contains()` +/// would never be true, and a middle drag would fall through to `DragMode::None`. +/// +/// The other half is what makes that a statement about *which* row: the same drag on an +/// interior row -- at the point a label strip would land if that row had one -- is +/// deliberately not the gesture. The row reserved no x chrome, so there is no strip for +/// the press to be inside, and the zoom the bottom row's strip can start cannot be +/// started from a row that has none. If that were not so the shared axis would be one +/// strip among several, each of which could zoom the one range they all share. +PLOT_TEST(a_middle_drag_on_a_figures_bottom_row_zooms_the_shared_x_axis) { + plot::Figure figure(3u); + figure.set_viewport({0, 0, 800, 600}); + for (u32 row = 0; row < 3u; ++row) { + plot::Plot& p = figure.plot(row); + p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false}); + p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + } + const u32 group = figure.add_x_link(); + for (u32 row = 0; row < 3u; ++row) PLOT_CHECK(figure.link_x(row, group)); + const plot::Range before = figure.x_link_range(group); + + figure.layout(); + const plot::PlotLayout& bottom = figure.plot(2u).layout(); + PLOT_CHECK(bottom.x_label_area.h > 0.0f); + 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)); + + plot::input::Controller c(figure.plot(2u)); + c.set_viewport(figure.viewport()); + c.on_pointer({strip_x, strip_y, 0, 0, plot::input::Button::Middle, 0, true, true, false}); + PLOT_CHECK(c.drag_mode() == plot::input::DragMode::AxisZoomX); + // Same sign and magnitude as on a single plot: a positive dy zooms in, 20 px being a + // tenth of a decade. + c.on_pointer({strip_x, strip_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false}); + c.on_pointer({strip_x, strip_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true}); + PLOT_CHECK(c.drag_mode() == plot::input::DragMode::None); + + // The gesture reached the row's own axis... + PLOT_CHECK_NEAR(figure.plot(2u).x_range(0u).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), 1e-6); + // ... and, through the link, every other row with it: one x axis, zoomed once. + figure.layout(); + for (u32 row = 0; row < 3u; ++row) { + PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).span(), figure.x_link_range(group).span(), 1e-9); + PLOT_CHECK_NEAR(figure.plot(row).x_range(0u).span(), 100.0 * std::pow(10.0, -20.0 / 200.0), + 1e-6); + } + PLOT_CHECK(figure.x_link_range(group).span() < before.span()); + + // An interior row is not the same gesture. A press at the point a label strip *would* + // land in -- the band this row's chrome would occupy if the figure gave it one -- is + // not inside anything: the row reserved no x chrome, so its `x_label_area` is + // zero-height and no press can be inside it. The middle drag therefore falls through + // to `DragMode::None`, and because `update_drag()` does nothing in that mode the + // group's window -- the one range every row shares -- is untouched by it. + const plot::PlotLayout& interior = figure.plot(0u).layout(); + PLOT_CHECK(interior.x_label_area.h == 0.0f); + const f32 interior_x = interior.axes.x + 0.5f * interior.axes.w; + const f32 interior_y = figure.layout().rows[0].bottom() - 0.5f * bottom.x_label_area.h; + PLOT_CHECK(!interior.x_label_area.contains(interior_x, interior_y)); + const plot::Range shared_before = figure.x_link_range(group); + + plot::input::Controller interior_controller(figure.plot(0u)); + interior_controller.on_pointer( + {interior_x, interior_y, 0, 0, plot::input::Button::Middle, 0, true, true, false}); + PLOT_CHECK(interior_controller.drag_mode() == plot::input::DragMode::None); + interior_controller.on_pointer( + {interior_x, interior_y + 20, 0, 20, plot::input::Button::Middle, 0, true, false, false}); + interior_controller.on_pointer( + {interior_x, interior_y + 20, 0, 0, plot::input::Button::Middle, 0, false, false, true}); + PLOT_CHECK(interior_controller.drag_mode() == plot::input::DragMode::None); + 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); +} diff --git a/tests/test_figure.cpp b/tests/test_figure.cpp index ec39d05..71a2e90 100644 --- a/tests/test_figure.cpp +++ b/tests/test_figure.cpp @@ -22,8 +22,9 @@ constexpr plot::f32 kH = 480.0f; plot::Rect whole_figure() { return plot::Rect{0.0f, 0.0f, kW, kH}; } /// A figure whose every row has a y axis and an x axis, so a row is a plot in the -/// shape the layout tests care about: it loses width to a y strip and height to -/// nothing else (the x strip belongs to the figure under `labels_bottom_only`). +/// shape the layout tests care about: it loses width to a y strip and, on the bottom +/// row alone, height to the shared x strip -- which lives inside that row rather than +/// in a band of its own. plot::Figure figure_with_axes(plot::u32 rows) { plot::Figure figure(rows); figure.set_viewport(whole_figure()); @@ -65,21 +66,32 @@ PLOT_TEST(row_rects_tile_the_figure_without_overlap) { PLOT_CHECK(row.h > 0.0f); } // Top to bottom, in row order, touching: each row starts where the one above it - // ended, and the last one ends on the rows' region's bottom edge (the figure's - // height less the shared strip beneath it). + // ended, and the last one ends on the figure's bottom edge -- the rows tile the + // whole figure, because the shared x axis is the bottom row's own rather than a band + // taken off the rows. const plot::FigureLayout layout = figure.layout(); PLOT_CHECK_NEAR(static_cast(rows[0].y), 0.0, 1e-6); for (std::size_t i = 1; i < rows.size(); ++i) { PLOT_CHECK_NEAR(static_cast(rows[i].y), static_cast(rows[i - 1].bottom()), 1e-6); } - PLOT_CHECK_NEAR(static_cast(rows.back().bottom()), - static_cast(layout.x_axis_strip.y), 1e-6); - PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.bottom()), static_cast(kH), - 1e-6); - // ... and the strip shares the figure's width with the rows above it. - PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.w), static_cast(kW), 1e-6); + PLOT_CHECK_NEAR(static_cast(rows.back().bottom()), static_cast(kH), 1e-6); + // The shared strip *is* the bottom row's own x label area: inside that row, below the + // row's axes and above the row's title strip, and as wide as its axes -- which is what + // puts it where the controller can hit-test it and where the renderer stages the one + // set of labels. + const plot::PlotLayout& bottom = figure.plot(2u).layout(); PLOT_CHECK(layout.x_axis_strip.h > 0.0f); + PLOT_CHECK(layout.x_axis_strip.y >= layout.rows.back().y); + PLOT_CHECK(layout.x_axis_strip.bottom() <= layout.rows.back().bottom() + 1e-6f); + PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.h), + static_cast(bottom.x_label_area.h), 1e-6); + PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.x), + static_cast(bottom.x_label_area.x), 1e-6); + PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.y), + static_cast(bottom.x_label_area.y), 1e-6); + PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.w), + static_cast(bottom.x_label_area.w), 1e-6); for (std::size_t i = 0; i < rows.size(); ++i) { for (std::size_t j = i + 1; j < rows.size(); ++j) { @@ -95,8 +107,11 @@ PLOT_TEST(row_rects_tile_the_figure_without_overlap) { PLOT_CHECK_NEAR(static_cast(spaced[1].y - spaced[0].bottom()), 10.0, 1e-6); PLOT_CHECK_NEAR(static_cast(spaced[2].y - spaced[1].bottom()), 10.0, 1e-6); PLOT_CHECK(spaced[0].h < rows[0].h); // the gaps came out of the rows - PLOT_CHECK_NEAR(static_cast(spaced.back().bottom()), - static_cast(figure.layout().x_axis_strip.y), 1e-6); + // The last spaced row still ends on the figure's bottom edge -- the gaps came out of + // the rows' share of the figure, not out of the figure -- and so does the strip. + PLOT_CHECK_NEAR(static_cast(spaced.back().bottom()), static_cast(kH), 1e-6); + PLOT_CHECK_NEAR(static_cast(figure.layout().x_axis_strip.bottom()), + static_cast(kH), 1e-6); // A figure with no rows has no geometry -- and no row to reach for while computing // it, which is the case that would read past the end of its own plot list. @@ -110,8 +125,9 @@ PLOT_TEST(row_rects_tile_the_figure_without_overlap) { /// Weights change how the figure's height is divided -- a taller main plot, shorter /// rows under it -- and the rows still account for all of it: the sum of the heights -/// plus the gaps is the figure less the shared strip, so no weight can leave a band -/// unclaimed or push a row past the bottom. +/// plus the gaps is the whole figure (the shared strip lives inside the bottom row, so +/// it costs the division nothing), so no weight can leave a band unclaimed or push a +/// row past the bottom. PLOT_TEST(row_weights_change_relative_heights_and_sum_to_the_viewport) { plot::Figure figure = figure_with_axes(3u); const std::vector equal = rows_of(figure); @@ -145,7 +161,7 @@ PLOT_TEST(row_weights_change_relative_heights_and_sum_to_the_viewport) { plot::f32 total = 0.0f; for (const plot::Rect& row : rows) total += row.h; - total += layout.row_gap * 2.0f + layout.x_axis_strip.h; + total += layout.row_gap * 2.0f; PLOT_CHECK_NEAR(static_cast(total), static_cast(kH), 1e-3); // Any positive scale is the same division: normalised, not absolute. @@ -206,8 +222,8 @@ PLOT_TEST(degenerate_row_weights_are_rejected) { /// Every row keeps a positive height, whatever the figure's size and whatever the /// weights say -- a row that is not there is a plot that is not there, silently. The -/// shared strip yields to the rows for the same reason: chrome may shrink a figure, -/// never erase one. +/// shared strip cannot take a row's height for the same reason: it is the bottom row's +/// own, so chrome may shrink one row's *axes*, never erase a row. PLOT_TEST(every_row_keeps_a_positive_height) { // Five rows in a figure shorter than the strip one row would ask for. plot::Figure tiny(5u); @@ -220,9 +236,10 @@ PLOT_TEST(every_row_keeps_a_positive_height) { PLOT_CHECK(row.h > 0.0f); total += row.h; } - // The rows took what they needed from the gap: a gap that does not fit is dropped, - // so what is left of the figure is the rows' and the strip's, not the gap's. - PLOT_CHECK_NEAR(static_cast(total + layout.x_axis_strip.h), 12.0, 1e-3); + // The rows took the whole figure: a gap that does not fit is dropped, and the shared + // strip is inside the bottom row rather than a band beside it, so it costs the + // division nothing. + PLOT_CHECK_NEAR(static_cast(total), 12.0, 1e-3); // A wildly unbalanced weight set still cannot squeeze a row out. The tiny weights // here are small enough that a row's *share* of the leftover underflows to zero diff --git a/tests/test_render_smoke.cpp b/tests/test_render_smoke.cpp index 4a54e19..86e41de 100644 --- a/tests/test_render_smoke.cpp +++ b/tests/test_render_smoke.cpp @@ -3530,11 +3530,15 @@ PLOT_TEST(two_rows_render_distinct_content) { predicate_pixels(frame, kW, row1, magenta_pixel)); // Each trace is drawn inside its row's *axes*, not merely somewhere in the row: the - // row's y strip is the other half of the rect and holds no data. + // row's y strip is the other half of the rect and holds no data. The top row is + // interior and reserves no x chrome; the bottom row reserves its own label and title + // strips, because the figure's one shared x axis is that row's. const plot::PlotLayout& layout0 = figure.plot(0u).layout(); const plot::PlotLayout& layout1 = figure.plot(1u).layout(); PLOT_CHECK_NEAR(static_cast(layout0.axes.h), static_cast(row0.h), 1e-3); - PLOT_CHECK_NEAR(static_cast(layout1.axes.h), static_cast(row1.h), 1e-3); + PLOT_CHECK_NEAR(static_cast(layout1.axes.bottom() + layout1.x_label_area.h + + layout1.x_title_height), + static_cast(row1.bottom()), 1e-3); PLOT_CHECK(predicate_pixels(frame, kW, layout0.axes, trace_pixel) > 0u); PLOT_CHECK(predicate_pixels(frame, kW, layout1.axes, magenta_pixel) > 0u); // The rows are stacked, not overlapped: the second starts below the first. @@ -3544,12 +3548,13 @@ PLOT_TEST(two_rows_render_distinct_content) { } /// The figure draws *one* x axis. With labels on the bottom only -- the default -- -/// the rows reserve no x label strip of their own and their labels appear once, in -/// the strip the figure measured under the last row; the interior row's labels -/// are not in the frame at all. Switching the figure to label every row puts them in -/// every row, so the difference between the two frames is exactly the ink the shared -/// axis exists to prevent -- which is what makes the count below a statement rather -/// than a scan of somewhere nothing is ever drawn. +/// the rows above the bottom reserve no x chrome and their labels appear once, in the +/// **bottom row's own** strip, which is where the figure's one x axis lives; the +/// interior rows' labels are not in the frame at all. Switching the figure to label +/// every row puts them in every row, and the count below -- of x-label ink inside the +/// rows' own strips, so it compares x chrome with x chrome rather than the whole frames, +/// whose row heights differ between the two modes -- says which happened: one row's +/// worth against three. PLOT_TEST(shared_x_axis_is_drawn_once) { plottest::HeadlessContext ctx(kW, kH); plot::Status st; @@ -3577,25 +3582,40 @@ PLOT_TEST(shared_x_axis_is_drawn_once) { const plot::Color ink = r.style().axis_text; // --- labels on the bottom only: one set, and it is the figure's ----------------- - const std::vector shared = draw_frame(); + draw_frame(); dump_if_requested(r, "figure_shared_x.ppm"); const plot::FigureLayout layout = figure.layout(); PLOT_CHECK(layout.rows.size() == 3u); PLOT_CHECK(layout.x_axis_strip.h > 0.0f); - // The strip is the figure's, under the last row: the rows end where it starts, and - // it ends on the figure's bottom edge. + // The strip is the *bottom row's own* label area, inside that row and at the bottom + // of it: the rows tile the figure to its bottom edge, and the shared axis is the + // last band of the last row -- the rect the controller hit-tests and the renderer + // stages the one set of labels in. PLOT_CHECK_NEAR(static_cast(layout.rows.back().bottom()), - static_cast(layout.x_axis_strip.y), 1e-3); - PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.bottom()), static_cast(kH), 1e-3); + // The shared strip *is* the bottom row's x label area, to the pixel -- the rect the + // controller hit-tests and the renderer stages the one set of labels in -- and it + // sits inside that row, above the row's own x title strip. + const plot::PlotLayout& bottom_row_layout = figure.plot(2u).layout(); + PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.x), + static_cast(bottom_row_layout.x_label_area.x), 1e-3); + PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.y), + static_cast(bottom_row_layout.x_label_area.y), 1e-3); + PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.w), + static_cast(bottom_row_layout.x_label_area.w), 1e-3); + PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.h), + static_cast(bottom_row_layout.x_label_area.h), 1e-3); + PLOT_CHECK(layout.x_axis_strip.bottom() <= layout.rows.back().bottom() + 1e-3f); const plot::u32 strip_ink = scan_rect_ink(ctx, layout.x_axis_strip, ink).pixels; PLOT_CHECK(strip_ink > 0u); - // Every row gave its x label strip away: a row's axes reaches its rect's bottom edge - // and the row has no band inside itself for a second set of labels to be drawn in. - for (plot::u32 row = 0; row < 3u; ++row) { + // Every row *above* the bottom gave its x label strip away: such a row's axes + // reaches its rect's bottom edge and the row has no band inside itself for a second + // set of labels to be drawn in. The bottom row is the exception -- it keeps its own + // strip, which is where the figure's one x axis is -- so its strip is not zero. + for (plot::u32 row = 0; row + 1u < 3u; ++row) { const plot::PlotLayout& row_layout = figure.plot(row).layout(); - PLOT_CHECK(!figure.plot(row).x_label_strip()); + PLOT_CHECK(!plot::x_strip_has_labels(figure.plot(row).x_strip_mode())); PLOT_CHECK_NEAR(static_cast(row_layout.x_axis_height), 0.0, 1e-6); PLOT_CHECK_NEAR(static_cast(row_layout.x_label_area.h), 0.0, 1e-6); PLOT_CHECK_NEAR(static_cast(row_layout.axes.bottom()), @@ -3617,31 +3637,42 @@ PLOT_TEST(shared_x_axis_is_drawn_once) { PLOT_CHECK(band.h > 0.0f); PLOT_CHECK(scan_rect_ink(ctx, band, ink).pixels == 0u); } - const plot::u32 shared_ink = frame_text_ink(shared, kW, kH, ink); - // --- every row labels itself: the same labels, in three places ----------------- figure.set_x_labels_only_on_bottom(false); - const std::vector per_row = draw_frame(); + draw_frame(); const plot::FigureLayout per_row_layout = figure.layout(); - PLOT_CHECK(per_row_layout.x_axis_strip.h == 0.0f); // no shared strip any more + // With every row labelling itself there is no shared axis to speak of: the strip + // reads back the bottom row's own label area again, as one of three rather than as + // the figure's one -- the figure added no band outside its rows either way. + PLOT_CHECK_NEAR(static_cast(per_row_layout.x_axis_strip.h), + static_cast(figure.plot(2u).layout().x_label_area.h), 1e-3); PLOT_CHECK(per_row_layout.rows.size() == 3u); for (plot::u32 row = 0; row < 3u; ++row) { const plot::PlotLayout& own = figure.plot(row).layout(); - PLOT_CHECK(figure.plot(row).x_label_strip()); + PLOT_CHECK(plot::x_strip_has_labels(figure.plot(row).x_strip_mode())); PLOT_CHECK(own.x_label_area.h > 0.0f); // A row's own labels are in its own strip, inside its own rect. PLOT_CHECK(own.x_label_area.y >= per_row_layout.rows[row].y); PLOT_CHECK(own.x_label_area.bottom() <= per_row_layout.rows[row].bottom() + 1e-3f); PLOT_CHECK(scan_rect_ink(ctx, own.x_label_area, ink).pixels > 0u); } - const plot::u32 per_row_ink = frame_text_ink(per_row, kW, kH, ink); - - // The two interior row's labels are the difference -- they are what the shared axis - // replaced, and in the default frame they are simply not there. The y labels, the - // chrome and the traces are in both frames, and the shared strip's labels are one - // row's worth, so a frame that drew the x labels twice per row cannot land here. - PLOT_CHECK(per_row_ink > shared_ink); - PLOT_CHECK(per_row_ink - shared_ink >= strip_ink / 2u); + // The two interior rows' labels are the difference -- they are what the shared axis + // replaced, and in the default frame they are simply not there. Counted inside the + // rows' own x label strips, which is the only like-for-like count here: the two frames + // do not give the rows the same heights, because a shared frame lets its interior rows + // keep the band the per-row frame spends on their x strips (the shared axis costs them + // nothing), so a whole-frame text count compares y chrome against y chrome and decides + // nothing. The bottom row's strip holds one row's worth of x labels in both frames -- + // the same rect carrying the same labels -- while the interior rows' strips hold none + // in the shared frame and a row's worth each in the per-row one, so the per-row strips + // together hold three times the shared one's. A frame that drew the interior rows' + // labels but only one set of them, or that spent no strip on the bottom row, cannot + // land here. + plot::u32 per_row_x_ink = 0u; + for (plot::u32 row = 0; row < 3u; ++row) { + per_row_x_ink += scan_rect_ink(ctx, figure.plot(row).layout().x_label_area, ink).pixels; + } + PLOT_CHECK(per_row_x_ink >= 2u * strip_ink); ctx.destroy(); } @@ -3694,20 +3725,24 @@ PLOT_TEST(a_row_resize_recomputes_rather_than_rescaling_a_stale_layout) { const plot::Rect& row1 = layout.rows[1]; // The rows are the *new* figure's: twice the first frame's height, to the pixel, - // and the second row reaches the shared strip rather than stopping where the short - // frame's strip was. + // and the second row reaches the figure's bottom edge rather than stopping where + // the short frame's shared strip was. PLOT_CHECK(row1.h > 1.5f * short_layout.rows[1].h); - PLOT_CHECK_NEAR(static_cast(row1.bottom()), - static_cast(layout.x_axis_strip.y), 1e-3); - PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.bottom()), - static_cast(kH), 1e-3); + PLOT_CHECK_NEAR(static_cast(row1.bottom()), static_cast(kH), 1e-3); PLOT_CHECK(row1.y > short_layout.rows[1].y); - // The transforms the frame drew with are the new rects': the second row's axes - // spans its rect exactly, and its trace is in there -- not at the old coordinates. + // The transforms the frame drew with are the new rects': the second row's own chrome + // stacks inside its rect -- the axes, then the shared-axis label strip, then the + // row's title strip, the three ending on the row's bottom edge -- and its trace is in + // the axes, not at the old coordinates. const plot::PlotLayout& row1_layout = figure.plot(1u).layout(); PLOT_CHECK_NEAR(static_cast(row1_layout.axes.y), static_cast(row1.y), 1e-3); + PLOT_CHECK_NEAR(static_cast(layout.x_axis_strip.y), + static_cast(row1_layout.x_label_area.y), 1e-3); PLOT_CHECK_NEAR(static_cast(row1_layout.axes.bottom()), + static_cast(row1_layout.x_label_area.y), 1e-3); + PLOT_CHECK_NEAR(static_cast(row1_layout.x_label_area.bottom() + + row1_layout.x_title_height), static_cast(row1.bottom()), 1e-3); PLOT_CHECK(predicate_pixels(frame, kW, row1_layout.axes, magenta_pixel) > 0u); // Nothing of the second row's trace is above it, where the short frame's row 1 was: @@ -3724,3 +3759,89 @@ PLOT_TEST(a_row_resize_recomputes_rather_than_rescaling_a_stale_layout) { ctx.destroy(); } + +/// A `Figure`'s x title is drawn where a figure's x axis is: the bottom row's own +/// title strip, under that row's own labels. An interior row reserves no x chrome at +/// all -- its `axes` reaches its rect's bottom edge, because the figure has one x axis +/// and the bottom row owns it -- so a title on an interior row has nowhere to go and +/// is not drawn. The second half of the frame pair is what makes that a statement: +/// told to label every row, the same figure gives every row its own strip and the +/// interior rows' titles appear, so the ink the default frame withholds is exactly the +/// ink the interior rows' x chrome would have spent. +PLOT_TEST(figure_x_title_is_drawn_on_the_bottom_row_only) { + 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)); + + const std::vector samples = wave(640u, 0.0f); + plot::data::ArraySeries series(samples, 0.0, 1.0); + + plot::Figure figure(3u); + figure.set_viewport({0, 0, static_cast(kW), static_cast(kH)}); + for (plot::u32 row = 0; row < 3u; ++row) { + plot::Plot& p = figure.plot(row); + // Every row gets a title, and a title of its own, so which strip the ink is in says + // which row drew it. + const plot::u32 ax = + p.add_x_axis({row + 1u == 3u ? "bottom time" : "row time", + series.x_bounds(), false, plot::AxisPlace::Bottom, false}); + const plot::u32 ay = p.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + p.add_trace({&series, kRowBlue, 1.5f, ax, ay}); + } + + const auto draw_frame = [&] { + r.begin_frame(kW, kH, {0, 0, static_cast(kW), static_cast(kH)}); + r.draw(figure); + r.end_frame(); + PLOT_CHECK(!r.last_frame_had_gl_error()); + return read_frame(ctx); + }; + + const plot::Color ink = r.style().axis_text; + const std::vector bottom_only = draw_frame(); + dump_if_requested(r, "figure_x_title.ppm"); + const plot::FigureLayout layout = figure.layout(); + + // The bottom row keeps a real title strip, inside its own rect, and its title is in + // it -- so the title feature still works on a figure. + const plot::PlotLayout& above = figure.plot(2u).layout(); + PLOT_CHECK(above.x_title_area.h > 0.0f); + PLOT_CHECK(above.x_title_area.y >= above.x_label_area.bottom() - 1e-3f); + PLOT_CHECK(above.x_title_area.bottom() <= layout.rows[2].bottom() + 1e-3f); + PLOT_CHECK(scan_rect_ink(ctx, above.x_title_area, ink).pixels > 0u); + // The labels are above it and the data above them: the three strips stack, they do + // not overlap. + PLOT_CHECK(above.axes.bottom() <= above.x_label_area.y + 1e-3f); + PLOT_CHECK(above.x_label_area.bottom() <= above.x_title_area.y + 1e-3f); + + // An interior row reserves none of that: no label strip, no title strip, and its + // axes takes the whole rect. + for (plot::u32 row = 0; row + 1u < 3u; ++row) { + const plot::PlotLayout& interior = figure.plot(row).layout(); + PLOT_CHECK(interior.x_label_area.h == 0.0f); + PLOT_CHECK(interior.x_title_area.h == 0.0f); + PLOT_CHECK_NEAR(static_cast(interior.axes.bottom()), + static_cast(layout.rows[row].bottom()), 1e-3); + } + const plot::u32 withheld = frame_text_ink(bottom_only, kW, kH, ink); + + // Told to label every row, the same figure puts every row's x chrome back -- label + // strip and title strip, one per row, each inside its own rect -- and the interior + // rows' titles are drawn where the default frame drew nothing. + figure.set_x_labels_only_on_bottom(false); + const std::vector every_row = draw_frame(); + const plot::FigureLayout per_row = figure.layout(); + for (plot::u32 row = 0; row < 3u; ++row) { + const plot::PlotLayout& own = figure.plot(row).layout(); + PLOT_CHECK(own.x_title_area.h > 0.0f); + PLOT_CHECK(own.x_title_area.bottom() <= per_row.rows[row].bottom() + 1e-3f); + PLOT_CHECK(scan_rect_ink(ctx, own.x_title_area, ink).pixels > 0u); + PLOT_CHECK(scan_rect_ink(ctx, own.x_label_area, ink).pixels > 0u); + } + PLOT_CHECK(frame_text_ink(every_row, kW, kH, ink) > withheld); + + ctx.destroy(); +}