From 98cfc5805b63f8d86edfa1e50c0f1ef73a719995 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Thu, 1 Oct 2026 15:32:05 +0200 Subject: [PATCH] Fixing minor issues in multifigure plots --- adapters/gtk4/gtk_plot_area.cpp | 249 ++++++++++++++++++++++++++++++-- adapters/gtk4/gtk_plot_area.hpp | 137 +++++++++++++++++- include/plot/plot/figure.hpp | 16 +- src/plot/figure.cpp | 6 +- 4 files changed, 394 insertions(+), 14 deletions(-) diff --git a/adapters/gtk4/gtk_plot_area.cpp b/adapters/gtk4/gtk_plot_area.cpp index 9e0ee08..7b56c41 100644 --- a/adapters/gtk4/gtk_plot_area.cpp +++ b/adapters/gtk4/gtk_plot_area.cpp @@ -18,6 +18,8 @@ #include #include #include +#include +#include // The mirrored constants in gtk_input.hpp are pinned to GDK's here: a GDK that // renumbered the scroll units or moved a modifier bit fails to compile rather @@ -217,9 +219,187 @@ void PlotArea::refresh_viewport() noexcept { // chrome out with it) and refreshes the layout. `last_device_viewport()` is // deliberately not assigned here -- it records the region a *frame* was drawn // into, which is `render()`'s to set. + if (figure_ != nullptr) { + lay_figure_out(device_viewport()); + return; + } controller_.set_viewport(device_viewport()); } +// --- figure mode ---------------------------------------------------------- + +void PlotArea::set_figure(Figure* figure) noexcept { + figure_ = figure; + row_controllers_.clear(); + last_row_ = 0; + // A new figure holds no crosshair this area has spread, and no captured press row: + // the controllers are rebuilt below, so an open press would name one that is gone. + spread_active_ = false; + spread_x_ = 0.0; + spread_y_ = 0.0; + press_open_ = false; + press_row_ = 0; + drag_.have = false; + zoom_.end(); + if (figure_ == nullptr) { + row_rects_.clear(); // no figure, no rows: the routing falls back to `controller_` + return; + } + const u32 rows = figure_->row_count(); + row_controllers_.reserve(rows); + for (u32 i = 0; i < rows; ++i) { + row_controllers_.push_back(std::make_unique(figure_->plot(i))); + } + // The new figure's geometry goes into the cache now, not at the next frame: an event + // arriving between `set_figure()` and the first render -- which is every event a + // headless probe injects -- must be routed against the figure it was just handed. + lay_figure_out(device_viewport()); +} + +void PlotArea::lay_figure_out(Rect px) noexcept { + figure_->set_viewport(px); + refresh_row_rects(); + for (u32 i = 0; i < row_controllers_.size() && i < row_rects_.size(); ++i) { + // Each row's own rect, which is the viewport `Figure::layout()` just gave that + // row's `Plot`: the controller and the plot must agree on it or a gesture lands on + // the wrong pixels. Set here rather than by the controller, which owns no viewport. + row_controllers_[i]->set_viewport(row_rects_[i]); + } +} + +void PlotArea::refresh_row_rects() noexcept { + // The one `Figure::layout()` per call: every event handler reads `row_rects_` + // instead of paying this again, which is the whole point of the cache. + row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector{}; +} + +input::Controller* PlotArea::row_controller(u32 row) noexcept { + return row < row_controllers_.size() ? row_controllers_[row].get() : nullptr; +} + +input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept { + if (figure_ == nullptr || row_controllers_.empty()) return controller_; + // The cached rects `refresh_row_rects()` filled -- the same ones the last frame drew + // -- and not a fresh `Figure::layout()`: an event pays no layout, or a burst of + // pointer motion over a multi-row figure would re-lay every row out per move. + if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_; + (void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in() + const u32 row = row_in(row_rects_, y); + input::Controller& controller = *row_controllers_[row]; + controller.set_viewport(row_rects_[row]); + return controller; +} + +u32 PlotArea::row_at(double x, double y) const noexcept { + if (figure_ == nullptr || row_rects_.empty()) return 0; + (void)x; + return row_in(row_rects_, to_device(x, y).y); +} + +input::Controller& PlotArea::target_controller(f32 y) noexcept { + if (figure_ == nullptr || row_controllers_.empty()) return controller_; + if (row_rects_.empty() || row_rects_.size() != row_controllers_.size()) return controller_; + // The capture: while a press is open the gesture belongs to the row that press + // landed in, however far the pointer has travelled since. Handing a `drag-update` or + // the release to the row the pointer is over *now* would leave the first row's drag + // open for ever -- its controller never sees the release that ends it -- and let a + // later gesture resume it from the stale anchor. + u32 row = press_open_ ? press_row_ : row_in(row_rects_, y); + // Re-read against this frame's rects: the widget can be resized mid-drag, and the + // guard keeps a stale index from reaching past the controller list. + if (row >= row_controllers_.size()) row = 0; + last_row_ = row; + input::Controller& controller = *row_controllers_[row]; + controller.set_viewport(row_rects_[row]); + return controller; +} + +u32 PlotArea::row_in(const std::vector& rows, f32 y) noexcept { + if (rows.empty()) return 0; + // The row the pixel is in, or -- when it is in none of them, which is the row gap a + // host asked for with `Figure::set_row_gap()` or the figure's own margins -- the row + // whose rect is nearest. By distance rather than by "the first that matches": the + // rows tile the figure at the default gap of 0, so this is unreachable until a host + // asks for a gap, and a gesture in a visible gap must not default to row 0. + u32 row = 0; + f32 best = std::numeric_limits::infinity(); + for (u32 i = 0; i < rows.size(); ++i) { + 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; + if (distance == 0.0f) break; // inside this row: nothing can be nearer + } + } + return row; +} + +u32 PlotArea::pointer_row() const noexcept { + if (row_rects_.empty()) return 0; + // The captured row while a press owns the gesture, so an in-flight drag keeps its + // highlight even after the pointer has left the row it began in. + if (press_open_ && press_row_ < row_rects_.size()) return press_row_; + return row_in(row_rects_, pointer_.y); +} + +void PlotArea::sync_figure_crosshair() noexcept { + if (row_rects_.empty() || row_controllers_.empty()) return; + const u32 row_count = static_cast(row_rects_.size()); + + // Which row's crosshair is the hover. With a pointer of this area's own it is the row + // that pointer's events go to -- the same rule the routing uses, so the hover follows + // the gesture it began. A headless probe injects its events straight into a row + // controller and the area never sees a pointer, so there the hover is the row whose + // crosshair is *not* the one last spread: the figure writes one value to every row, + // so a row holding anything else was changed from outside. Nothing differing means no + // new hover, and the figure keeps the one it has. + u32 hovered = row_count; + if (have_pointer_) { + hovered = pointer_row(); + } else { + for (u32 i = 0; i < row_count; ++i) { + const Crosshair& candidate = figure_->plot(i).crosshair(); + // A row differs when its active state, its x, or the y of *its own* horizontal + // line differs from what was spread: a pointer moving to another row at the same + // x is a new hover and must move the one horizontal line, not leave the old one. + const bool y_differs = candidate.horizontal && candidate.y != spread_y_; + if (candidate.active != spread_active_ || + (candidate.active && (candidate.x != spread_x_ || y_differs))) { + hovered = i; + break; + } + } + if (hovered >= row_count) return; + } + + // One x to every row, and the hovered row's **own** y only to that row (spec §17): + // the rows of a figure share x and not y -- row 0 may be volts and row 1 degrees -- + // so a horizontal line spread across the figure would mean a different value in each. + // A row whose controller has *no* crosshair (the pointer left its axes, the only + // thing that clears one) clears the figure's, so the rows cannot be left showing a + // hover the pointer has abandoned. + const Plot& row = figure_->plot(hovered); + if (row.crosshair().active) { + if (row.crosshair().horizontal) { + figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true); + } else { + // Vertical-only: a caller placed it through the single-coordinate + // `Figure::set_crosshair()`. The four-argument overload would turn the horizontal + // line on at the stored (meaningless) y == 0, clamped to the axes edge and made + // permanent, so only the vertical line is promoted. + figure_->set_crosshair(row.crosshair().x, true); + } + spread_x_ = row.crosshair().x; + spread_y_ = row.crosshair().y; + spread_active_ = true; + } else { + figure_->clear_crosshair(); + spread_active_ = false; + } +} + // --- the GL signals ------------------------------------------------------- void PlotArea::realize() noexcept { @@ -317,12 +497,36 @@ void PlotArea::render() noexcept { // chrome out with it) and refreshes the layout, so every frame is laid out for // the geometry of *this* frame. A resize changes the layout; it never scales a // stale one. - controller_.set_viewport(viewport); + if (figure_ != nullptr) { + // The figure's one layout for this frame: it hands every row its rect (and so + // every row's `Plot` its viewport) and fills the rect cache the routing of + // whatever GTK delivers before the next frame answers from. + lay_figure_out(viewport); + // One hover, every row: the crosshair the row under the pointer placed is applied + // to the whole figure here, after this frame's events have been handled and before + // anything lays the rows out again. Here rather than in a handler, so a headless + // probe that injects straight into a row controller is synced too. + sync_figure_crosshair(); + // Only the row the pointer's events go to republishes its own hot handles, so a row + // the pointer has left would keep the `HotHandles` it last published and go on + // drawing a hovered marker or cursor grab as if the pointer were still on it. Clear + // every *other* row here, where that row is known. + if (have_pointer_ && !row_rects_.empty()) { + const u32 active = pointer_row(); + for (u32 i = 0; i < row_rects_.size() && i < row_controllers_.size(); ++i) { + if (i != active) figure_->plot(i).set_hot_handles(HotHandles{}); + } + } + } else { + controller_.set_viewport(viewport); + } const f64 started = now_s(); const f64 dt = last_render_s_ >= 0.0 ? started - last_render_s_ : 0.0; last_render_s_ = started; - if (frame_) frame_(*plot_, dt); + // A figure's rows take the pointer, but the frame callback is still given a `Plot&`: + // row 0 is the one it gets, so the callback argument and `figure()->plot(0)` agree. + if (frame_) frame_(figure_ != nullptr ? figure_->plot(0u) : *plot_, dt); // The mapping's factor is the display's scale, so the renderer draws its logical // style metrics at the same ratio the viewport and every forwarded coordinate are @@ -331,7 +535,11 @@ void PlotArea::render() noexcept { // ceiling under fractional scaling and would over-scale the chrome by up to 60%. renderer_->style().ui_scale = mapping_.factor; renderer_->begin_frame(static_cast(viewport.w), static_cast(viewport.h), viewport); - renderer_->draw(*plot_); + if (figure_ != nullptr) { + renderer_->draw(*figure_); + } else { + renderer_->draw(*plot_); + } renderer_->end_frame(); const f32 frame_ms = static_cast((now_s() - started) * 1.0e3); @@ -385,11 +593,16 @@ void PlotArea::on_resize(GtkGLArea*, int width, int height, void* self) noexcept void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y, u32 time_ms) noexcept { if (press_open_) return; // one press per button-down sequence: see drag_begin + + const Vec2 p = to_device(x, y); + // The figure row this gesture belongs to for its whole life, claimed from the press's + // own position *before* the event is dispatched (see `target_controller()`). Harmless + // and unread while no figure is set. + press_row_ = figure_ != nullptr ? row_in(row_rects_, p.y) : 0; press_open_ = true; press_button_ = button; press_mods_ = mods; - const Vec2 p = to_device(x, y); pointer_ = p; have_pointer_ = true; @@ -403,12 +616,16 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double y, e.down = true; e.pressed = true; e.time_s = pointer_time_s(time_ms); // the double-click detector differences these - controller_.on_pointer(e); + target_controller(p.y).on_pointer(e); gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); } void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept { if (!press_open_) return; + // Resolved while the press is still open, so the release reaches the row the press + // claimed rather than the row the pointer ended over -- the controller that owns the + // drag is the only one that can close it. + input::Controller& target = target_controller(position.y); press_open_ = false; input::PointerEvent e; @@ -421,7 +638,7 @@ void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept { e.down = false; e.released = true; e.time_s = pointer_time_s(time_ms); - controller_.on_pointer(e); + target.on_pointer(e); gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); } @@ -511,7 +728,9 @@ void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double o e.down = true; e.time_s = area->pointer_time_s( gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture))); - area->controller_.on_pointer(e); + // The press's row, not the row the pointer has reached: `press_open_` is true here, + // so a drag that crosses a row boundary stays with the controller that began it. + area->target_controller(sample.position.y).on_pointer(e); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); } @@ -556,7 +775,9 @@ void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale, // nothing rather than zooming from a made-up anchor. const input::GestureEvent g = area->zoom_.update( static_cast(scale), Vec2{static_cast(cx), static_cast(cy)}, area->mapping_); - area->controller_.on_gesture(g); + // The folded event carries the focal point, which is the position this pinch belongs + // to; while a press is open the capture overrides it, as it does for a drag. + area->target_controller(g.y).on_gesture(g); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); } @@ -595,7 +816,7 @@ int PlotArea::on_scroll(GtkEventControllerScroll* scroll, double dx, double dy, e.dx = static_cast(scroll_notches(dx, unit)); e.dy = static_cast(notches); e.mods = gesture_mods(GTK_EVENT_CONTROLLER(scroll)); - area->controller_.on_wheel(e); + area->target_controller(at.y).on_wheel(e); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); return TRUE; // consumed: the plot zoomed } @@ -619,7 +840,7 @@ void PlotArea::on_motion(GtkEventControllerMotion* motion, double x, double y, v e.down = false; e.time_s = PlotArea::pointer_time_s( gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(motion))); - area->controller_.on_pointer(e); + area->target_controller(p.y).on_pointer(e); } // --- keyboard ------------------------------------------------------------- @@ -644,7 +865,13 @@ int PlotArea::on_key_pressed(GtkEventControllerKey* keys, unsigned int keyval, // GTK reports a held key as repeated presses and sends no release the controller // would act on anyway (its actions are press-only), so the press is the event. e.down = true; - area->controller_.on_key(e); + // A key has no position of its own, so it goes to the row the last event was routed + // to -- the row the user is looking at. + input::Controller& controller = + (area->figure_ == nullptr || area->last_row_ >= area->row_controllers_.size()) + ? area->controller_ + : *area->row_controllers_[area->last_row_]; + controller.on_key(e); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); return TRUE; // consumed: the plot acted on it } diff --git a/adapters/gtk4/gtk_plot_area.hpp b/adapters/gtk4/gtk_plot_area.hpp index a353a38..bcacfb2 100644 --- a/adapters/gtk4/gtk_plot_area.hpp +++ b/adapters/gtk4/gtk_plot_area.hpp @@ -4,14 +4,17 @@ #pragma once #include +#include #include #include +#include #include #include #include #include #include +#include #include #include @@ -171,6 +174,42 @@ using FrameFn = std::function; /// visible without waiting for the host's next frame, and the frame rate stays the /// host's: the demo pumps `g_timeout_add(16, gtk_gl_area_queue_render)`. /// +/// ## Figure mode +/// +/// `set_figure()` makes the area draw a whole `Figure` -- several rows sharing one +/// x axis -- instead of its own `Plot`, exactly as `glfw::Host::set_figure()` does +/// for the reference host, and for the same reason: the multi-row shape is one +/// `Figure`, not N widgets, because the x link groups, the shared y gutter and the +/// one shared x axis strip all live inside it and cannot be reassembled from +/// separate areas. The GTK plumbing above is unchanged by it; what changes is where +/// an event goes and what the renderer is handed. +/// +/// A `Controller` is bound to a plot at construction, so figure mode holds **one +/// controller per row**, created by `set_figure()`, and routes each event to the row +/// it lands in (`controller_at()`). The row rects come from a cache +/// (`refresh_row_rects()`) that the render callback fills once per frame, so a burst +/// of pointer motion costs no layouts; between a resize and the next frame the cache +/// is one frame stale, which is the documented worst case. +/// +/// A gesture belongs to the row its **press** landed in for its whole life: the +/// press claims the row, and every `drag-update`, pinch, wheel and the release that +/// follow while that press is open go there, however far the pointer travels. A drag +/// that crosses into the row below is therefore still the drag it began as, instead +/// of being handed to a controller that never saw the press -- which would leave the +/// first row's drag open for ever and let a later gesture resume it from the stale +/// anchor. The single open press `press_open_` already enforces (see the input +/// section) is that capture; this adapter needs no separate button bookkeeping the +/// way the GLFW host does, because GTK's two gestures share one sequence. +/// +/// The **crosshair** is one crosshair over the figure and not one per row: the row +/// under the pointer places it, and `sync_figure_crosshair()` reads it back and +/// applies it to every row, so each row's readout lists its own traces at the one x +/// the pointer is over. Reading it back rather than deriving a second coordinate +/// here is what keeps the row's hover and the figure's the same number. +/// +/// A key event has no position, so it goes to the row the last event was routed to, +/// which is the row the user is looking at. +/// /// ## Headless use /// /// A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a @@ -202,10 +241,56 @@ class PlotArea { /// The plot this area renders. Valid for the widget's whole life. Plot& plot() noexcept { return *plot_; } const Plot& plot() const noexcept { return *plot_; } - /// The controller this area feeds, bound to `plot()`. + /// The controller this area feeds, bound to `plot()`. **Not** the one a figure's + /// gestures reach: see `row_controller()`. input::Controller& controller() noexcept { return controller_; } const input::Controller& controller() const noexcept { return controller_; } + /// Draws `figure` instead of this area's own plot, with one `input::Controller` per + /// row and every event routed to the row it lands in (see "Figure mode" above). + /// The figure is borrowed and must outlive the widget. + /// + /// The frame callback is still handed a `Plot&`, and in figure mode it is **row + /// 0**, so the callback's argument and `figure()->plot(0)` are the same plot -- + /// matching `glfw::Host`. A host that needs the figure itself captures it. + /// + /// Passing null restores the single-plot path; `plot()` is untouched throughout, + /// so the area goes back to drawing exactly what it drew before. + void set_figure(Figure* figure) noexcept; + Figure* figure() noexcept { return figure_; } + const Figure* figure() const noexcept { return figure_; } + + /// One figure row's controller -- the one a gesture on that row reaches -- or null + /// while no figure is set or the row does not exist. A host reads a drag mode or a + /// hover out of it. + input::Controller* row_controller(u32 row) noexcept; + + /// The controller an event at device pixel `(x, y)` belongs to: `controller()` + /// while no figure is set, and otherwise the controller of the figure row that + /// pixel lands in -- a pixel inside no row (a `Figure::set_row_gap()` gap, a + /// margin) is 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. + /// + /// The pure query: it reports what a gesture *would* land on and never claims the + /// press capture, so a host may call it freely. + input::Controller& controller_at(f32 x, f32 y) noexcept; + + /// The figure row a point in the widget's **logical** coordinates lands in -- the + /// coordinates every GTK event controller reports, so a host's own drop target or + /// context menu can name the row a pointer is over without converting anything. The + /// same nearest-row rule as `controller_at()`, and 0 while no figure is set. + u32 row_at(double x, double y) const noexcept; + + /// Re-reads the figure's row rects into the cache the event routing answers from -- + /// the one `Figure::layout()` the routing costs between two frames. The render + /// callback runs it once per frame, right after the figure's viewport is set, so + /// the rects the handlers use are the rects that frame draws. A driver that injects + /// events without rendering (a headless probe) calls it after re-configuring the + /// figure; a no-op with no figure set. + void refresh_row_rects() noexcept; + /// The renderer, or null until a successful `::realize`. Valid from the first /// render onwards, and after the widget's context exists. Renderer* renderer() noexcept { return renderer_ ? &*renderer_ : nullptr; } @@ -291,6 +376,32 @@ class PlotArea { /// into device pixels. The one place that mapping exists. Vec2 to_device(double x, double y) const noexcept; + /// The controller one event of this area's goes to, given the event's device y. + /// `controller_` while no figure is set; otherwise the row the gesture *belongs* + /// to: the row the open press claimed while a press is open, and the row under `y` + /// between gestures. That is the capture `controller_at()` deliberately omits, and + /// it is why this -- not `controller_at()` -- is what every handler below calls. + /// + /// Hands the chosen row its own viewport, so the geometry it hit-tests is the + /// geometry the last frame drew. + input::Controller& target_controller(f32 y) noexcept; + /// The row of `row_rects_` a device y belongs to: the row it is in, or the nearest + /// one when it is in none (a row gap, a margin outside the figure). Selection by + /// distance rather than "the first that matches" is what makes a pixel in a visible + /// gap belong to the row it is *near* instead of to row 0. + static u32 row_in(const std::vector& rows, f32 y) noexcept; + /// The row this area's events currently go to: the press's row while one is open, + /// so an in-flight gesture keeps its highlight, and the row under the last pointer + /// position otherwise. 0 with no rows. + u32 pointer_row() const noexcept; + /// Spreads one row's crosshair over the whole figure (see "Figure mode"). Run once + /// per frame, from the render callback, after this frame's row rects are cached. + void sync_figure_crosshair() noexcept; + /// `figure_->set_viewport(px)`, one `layout()` into the rect cache, and each row's + /// own rect to its controller. The figure-mode half of `refresh_viewport()` and of + /// `render()`, which must not drift apart. + void lay_figure_out(Rect px) noexcept; + static void on_realize(GtkGLArea* area, void* self) noexcept; static void on_unrealize(GtkGLArea* area, void* self) noexcept; /// `gboolean` is `int`; a GtkGLArea render handler ignores the context. @@ -329,6 +440,26 @@ class PlotArea { Plot* plot_ = nullptr; ///< declared first: the controller is built over it input::Controller controller_; + /// The figure this area draws instead of `plot_`, and one controller per row for it + /// -- one per `Plot`, because a `Controller` is bound to a plot at construction and + /// this is what makes a gesture on any row reach that row. Both are empty until + /// `set_figure()` is called, and the single-plot path reads neither. + Figure* figure_ = nullptr; + std::vector> row_controllers_; + /// The figure's row rects as of the last `refresh_row_rects()`: the cache the event + /// routing reads instead of laying the figure out per event. Empty while no figure + /// is set, which is the state the routing falls back to `controller_` on. + std::vector row_rects_; + /// The row the last event was routed to, which is where a key event goes: a key has + /// no position of its own. + u32 last_row_ = 0; + /// The crosshair last spread over the figure (see `sync_figure_crosshair()`): what a + /// row is compared against to find the one something outside changed, since the + /// figure writes one value to every row. `spread_active_` false means "spread + /// nothing" -- the state before the first sync, and the one a clear leaves. + f64 spread_x_ = 0.0; + f64 spread_y_ = 0.0; + bool spread_active_ = false; GtkWidget* widget_ = nullptr; std::optional renderer_; FrameFn frame_; @@ -340,6 +471,10 @@ class PlotArea { bool press_open_ = false; input::Button press_button_ = input::Button::Left; u32 press_mods_ = 0; + /// The figure row the open press landed in -- the gesture's capture (see + /// `target_controller()`). Meaningless while `press_open_` is false and while no + /// figure is set. + u32 press_row_ = 0; /// The drag gesture's anchor: its press point and last offset, in *logical* /// pixels. Logical on purpose: a factor change between two samples of a drag /// cannot then turn into a phantom movement (see `DragAnchor`). The offset is diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index b91c145..54028c2 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -126,6 +126,18 @@ class Figure { void set_row_gap(f32 device_px) noexcept { row_gap_ = device_px > 0.0f ? device_px : 0.0f; } f32 row_gap() const noexcept { return row_gap_; } + /// A floor under the figure's common left gutter, in device pixels. The gutter + /// `layout()` imposes is the maximum over the rows' *measured* needs, so it follows + /// the tick labels: a `TickFormat::Si` axis whose range moves from `1.5` to `150` to + /// `1.5k` changes the width of its labels, and with it `axes.x` and `axes.w` of every + /// row. A caller that needs a layout which does not depend on content reserves the + /// widest label its formatter can produce once, through this, and the figure takes the + /// larger of the two. Negative values are clamped to 0 (the default: measure only). + void set_min_y_gutter(f32 device_px) noexcept { + min_y_gutter_ = device_px > 0.0f ? device_px : 0.0f; + } + f32 min_y_gutter() const noexcept { return min_y_gutter_; } + /// Whether the shared x axis is the figure's: every row reserves no x label strip /// and one shared axis is drawn under the last row (the default), or every row /// draws its own labels and the figure draws nothing extra. @@ -143,7 +155,8 @@ class Figure { /// It is also the one place the rows' left chrome can be made to agree (spec §16): /// each row is laid out once to learn the gutter its own labels and title column /// need, the figure takes the maximum and imposes it on every row through - /// `Plot::set_min_y_gutter()` before laying the rows out once more -- so every row's + /// `Plot::set_min_y_gutter()` -- never below `set_min_y_gutter()`'s own floor -- before + /// laying the rows out once more, so every row's /// `axes.x` and `axes.w` are equal and a vertical feature lands in the same pixel /// column in each. Two passes per row per call, not a loop: the gutter is a maximum /// over rows and a row's own strip width does not depend on it. @@ -312,6 +325,7 @@ class Figure { std::vector links_; FigureLayout layout_{}; f32 row_gap_ = 0.0f; + f32 min_y_gutter_ = 0.0f; bool labels_bottom_only_ = true; }; diff --git a/src/plot/figure.cpp b/src/plot/figure.cpp index dff188a..c702624 100644 --- a/src/plot/figure.cpp +++ b/src/plot/figure.cpp @@ -467,7 +467,11 @@ const FigureLayout& Figure::layout() { // per row settles the answer exactly. Measuring with a floor from the previous frame // would be the only reason to iterate, and clearing it before the measurement pass is // what removes it. - f32 gutter = 0.0f; + // + // `set_min_y_gutter()` seeds the maximum rather than being imposed on a row before the + // measurement, so a reserved floor cannot widen what a row reports and then be widened + // by its own reservation on the next frame. + f32 gutter = min_y_gutter_; for (u32 row = 0; row < rows; ++row) { plots_[row].set_min_y_gutter(0.0f); const PlotLayout& row_layout = plots_[row].layout();