diff --git a/adapters/glfw/glfw_host.cpp b/adapters/glfw/glfw_host.cpp index 6e7d6e4..e63fdbc 100644 --- a/adapters/glfw/glfw_host.cpp +++ b/adapters/glfw/glfw_host.cpp @@ -455,9 +455,11 @@ void Host::set_figure(Figure* figure) noexcept { row_rects_.clear(); // no figure, no rows: the handlers fall back to `controller_` return; } - const u32 rows = figure_->row_count(); - row_controllers_.reserve(rows); - for (u32 i = 0; i < rows; ++i) { + // One controller per **plot** (a row of several cells has one for each), indexed by the + // flattened plot index the figure uses. + const u32 plots = figure_->plot_count(); + row_controllers_.reserve(plots); + for (u32 i = 0; i < plots; ++i) { row_controllers_.push_back(std::make_unique(figure_->plot(i))); } // The new figure's rects go into the cache now, not at the next frame: an event @@ -469,8 +471,10 @@ void Host::set_figure(Figure* figure) noexcept { void Host::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 (R7). - row_rects_ = figure_ != nullptr ? figure_->layout().rows : std::vector{}; + // instead of paying this again, which is the whole point of the cache (R7). The rects + // are the figure's **cells** (one per plot, flattened), which for a figure with one + // cell per row are exactly its row rects. + row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector{}; } input::Controller* Host::row_controller(u32 row) noexcept { @@ -486,8 +490,7 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept { // frame stale, which is the documented worst case. const std::vector& rows = row_rects_; if (rows.empty() || rows.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(rows, y); + const u32 row = row_in(rows, x, y); last_row_ = row; input::Controller& controller = *row_controllers_[row]; // The row's own viewport, so the geometry this controller hit-tests is the geometry @@ -506,7 +509,6 @@ void Host::feed_pointer_event(const input::PointerEvent& e) noexcept { input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool pressed, bool released) noexcept { - (void)x; // the rows tile the figure vertically, so y alone picks one -- see row_in() if (figure_ == nullptr || row_controllers_.empty()) return controller_; const std::vector& rows = row_rects_; if (rows.empty() || rows.size() != row_controllers_.size()) return controller_; @@ -523,7 +525,7 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool // drag, and its press must not steal the capture to the row it landed in. if (released && button == captured_button_) captured_ = false; } else { - row = row_in(rows, y); + row = row_in(rows, x, y); if (pressed) { captured_ = true; captured_row_ = row; @@ -541,27 +543,29 @@ input::Controller& Host::pointer_target(f32 x, f32 y, input::Button button, bool u32 Host::pointer_row(const std::vector& rows) noexcept { if (captured_ && captured_row_ < rows.size()) return captured_row_; - return row_in(rows, last_pointer_y_); + return row_in(rows, last_pointer_x_, last_pointer_y_); } -u32 Host::row_in(const std::vector& rows, f32 y) noexcept { +u32 Host::row_in(const std::vector& rows, f32 x, f32 y) noexcept { if (rows.empty()) return 0u; - // 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. + // The cell the pixel is in, or -- when it is in none of them, which is the row gap or + // column gap a host has asked for with `Figure::set_row_gap()`/`set_col_gap()` or the + // figure's own margins -- the cell whose rect is nearest (squared distance from the + // pixel to the rect). Selection by distance rather than by "the first that matches" is + // what makes a pixel in a gap belong to the cell it is *near*, and a gesture in a + // visible gap must not be routed to plot 0 by default. For a figure with one cell per + // row every cell spans the figure's width, so this reduces to the nearest row by y. u32 row = 0u; 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); + const Rect& r = rows[i]; + const f32 dx = x < r.x ? r.x - x : (x >= r.right() ? x - r.right() : 0.0f); + const f32 dy = y < r.y ? r.y - y : (y >= r.bottom() ? y - r.bottom() : 0.0f); + const f32 distance = dx * dx + dy * dy; if (distance < best) { best = distance; row = i; - if (distance == 0.0f) break; // inside this row: nothing can be nearer + if (distance == 0.0f) break; // inside this cell: nothing can be nearer } } return row; diff --git a/adapters/glfw/glfw_host.hpp b/adapters/glfw/glfw_host.hpp index 266202b..05dbb2f 100644 --- a/adapters/glfw/glfw_host.hpp +++ b/adapters/glfw/glfw_host.hpp @@ -121,18 +121,21 @@ class Host { /// `plot_` is then untouched by any of this. void set_figure(Figure* figure) noexcept; Figure* figure() 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 + /// One figure plot's controller -- the one a gesture on that plot reaches -- or null + /// while no figure is set or it does not exist. `row` is the **flattened plot index** + /// (`Figure::plot_count()` of them: the row count when no row holds several cells). A host reads a drag /// mode or a hover out of it (`controller()` is the single-plot loop's, and is /// not the one figure gestures go to). 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 the cache + /// plot (cell) that pixel lands in otherwise -- x and y both pick it, as a row may hold + /// several cells side by side. The cell rects come from the cache /// `refresh_row_rects()` fills -- the same ones the renderer draws -- so a gesture /// lands on the row it was aimed at and no event lays the figure out; 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 + /// inside no cell -- the row or column gap a host asked for with + /// `Figure::set_row_gap()`/`set_col_gap()`, or a margin -- is the *nearest* cell 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 @@ -217,11 +220,12 @@ class Host { void on_scroll(f64 dx, f64 dy) noexcept; void on_key(int key, int action, int mods) noexcept; - /// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest - /// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event - /// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller - /// that has just laid the figure out does not make it lay out again for this. - static u32 row_in(const std::vector& rows, f32 y) noexcept; + /// The index of the cell of `rows` (the figure's flattened cell rects) a framebuffer pixel + /// belongs to: the cell it is in, or the nearest cell when it is in none (a row or column gap, + /// a margin outside the figure). One rule, shared by the event routing and the crosshair sync + /// -- and taking the rects rather than reading them, so a caller that has just laid the figure + /// out does not make it lay out again for this. + static u32 row_in(const std::vector& rows, f32 x, f32 y) noexcept; /// The controller a pointer event at (x, y) belongs to, applying the button capture: /// a press claims the row it landed in (`captured_row_` + `captured_button_`) and @@ -231,10 +235,10 @@ class Host { input::Controller& pointer_target(f32 x, f32 y, input::Button button, bool pressed, bool released) noexcept; - /// The row the pointer's events currently go to: the captured row while a drag owns - /// the button, the row under the pointer otherwise. The run loop uses it to clear - /// every *other* row's hot handles, so a row the pointer left cannot keep drawing a - /// hover or a grab it last published. + /// The plot (flattened index) the pointer's events currently go to: the captured one + /// while a drag owns the button, the one under the pointer otherwise. The run loop uses + /// it to clear every *other* plot's hot handles, so one the pointer left cannot keep + /// drawing a hover or a grab it last published. u32 pointer_row(const std::vector& rows) noexcept; /// Spreads the hover over a whole `Figure` (spec §15). @@ -292,7 +296,7 @@ class Host { u32 captured_row_ = 0u; input::Button captured_button_ = input::Button::Left; - /// The figure's row rects as of the last `refresh_row_rects()` -- the cache the + /// The figure's cell rects (one per plot, flattened; the row rects when no row is split) as of the last `refresh_row_rects()` -- the cache the /// event routing reads instead of laying the figure out per event (R7). Filled by /// `set_figure()` and once per frame by the run loop; empty while no figure is /// set, which is the state `controller_at()`/`pointer_target()` fall back to the diff --git a/adapters/gtk4/gtk_plot_area.cpp b/adapters/gtk4/gtk_plot_area.cpp index acf6621..83e58ff 100644 --- a/adapters/gtk4/gtk_plot_area.cpp +++ b/adapters/gtk4/gtk_plot_area.cpp @@ -245,9 +245,11 @@ void PlotArea::set_figure(Figure* figure) noexcept { 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) { + // One controller per **plot** (a row of several cells has one for each), indexed by the + // flattened plot index the figure uses. + const u32 plots = figure_->plot_count(); + row_controllers_.reserve(plots); + for (u32 i = 0; i < plots; ++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 @@ -269,8 +271,10 @@ void PlotArea::lay_figure_out(Rect px) noexcept { 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{}; + // instead of paying this again, which is the whole point of the cache. The rects are + // the figure's **cells** (one per plot, flattened), which for a figure with one cell + // per row are exactly its row rects. + row_rects_ = figure_ != nullptr ? figure_->layout().cells : std::vector{}; } input::Controller* PlotArea::row_controller(u32 row) noexcept { @@ -283,8 +287,7 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept { // -- 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); + const u32 row = row_in(row_rects_, x, y); input::Controller& controller = *row_controllers_[row]; controller.set_viewport(row_rects_[row]); return controller; @@ -292,11 +295,11 @@ input::Controller& PlotArea::controller_at(f32 x, f32 y) noexcept { 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); + const Vec2 p = to_device(x, y); + return row_in(row_rects_, p.x, p.y); } -input::Controller& PlotArea::target_controller(f32 y) noexcept { +input::Controller& PlotArea::target_controller(f32 x, 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 @@ -304,7 +307,7 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept { // 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); + u32 row = press_open_ ? press_row_ : row_in(row_rects_, x, 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; @@ -314,23 +317,25 @@ input::Controller& PlotArea::target_controller(f32 y) noexcept { return controller; } -u32 PlotArea::row_in(const std::vector& rows, f32 y) noexcept { +u32 PlotArea::row_in(const std::vector& rows, f32 x, 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. + // The cell the pixel is in, or -- when it is in none of them, which is the row or + // column gap a host asked for with `Figure::set_row_gap()`/`set_col_gap()` or the + // figure's own margins -- the cell whose rect is nearest (squared distance from the + // pixel to the rect). By distance rather than by "the first that matches": a gesture + // in a visible gap must not default to plot 0. For a figure with one cell per row + // every cell spans the figure's width, so this reduces to the nearest row by y. 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); + const Rect& r = rows[i]; + const f32 dx = x < r.x ? r.x - x : (x >= r.right() ? x - r.right() : 0.0f); + const f32 dy = y < r.y ? r.y - y : (y >= r.bottom() ? y - r.bottom() : 0.0f); + const f32 distance = dx * dx + dy * dy; if (distance < best) { best = distance; row = i; - if (distance == 0.0f) break; // inside this row: nothing can be nearer + if (distance == 0.0f) break; // inside this cell: nothing can be nearer } } return row; @@ -341,7 +346,7 @@ u32 PlotArea::pointer_row() const noexcept { // 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); + return row_in(row_rects_, pointer_.x, pointer_.y); } void PlotArea::sync_selection_frames() noexcept { @@ -616,7 +621,7 @@ void PlotArea::pointer_press(input::Button button, u32 mods, double x, double 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_row_ = figure_ != nullptr ? row_in(row_rects_, p.x, p.y) : 0; press_open_ = true; press_button_ = button; press_mods_ = mods; @@ -634,7 +639,7 @@ 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 - target_controller(p.y).on_pointer(e); + target_controller(p.x, p.y).on_pointer(e); gtk_gl_area_queue_render(GTK_GL_AREA(widget_)); } @@ -643,7 +648,7 @@ void PlotArea::pointer_release(Vec2 position, u32 time_ms) noexcept { // 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); + input::Controller& target = target_controller(position.x, position.y); press_open_ = false; input::PointerEvent e; @@ -748,7 +753,7 @@ void PlotArea::on_drag_update(GtkGestureDrag* gesture, double offset_x, double o gtk_event_controller_get_current_event_time(GTK_EVENT_CONTROLLER(gesture))); // 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); + area->target_controller(sample.position.x, sample.position.y).on_pointer(e); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); } @@ -795,7 +800,7 @@ void PlotArea::on_zoom_scale_changed(GtkGestureZoom* gesture, double scale, static_cast(scale), Vec2{static_cast(cx), static_cast(cy)}, area->mapping_); // 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); + area->target_controller(g.x, g.y).on_gesture(g); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); } @@ -834,7 +839,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->target_controller(at.y).on_wheel(e); + area->target_controller(at.x, at.y).on_wheel(e); gtk_gl_area_queue_render(GTK_GL_AREA(area->widget_)); return TRUE; // consumed: the plot zoomed } @@ -858,7 +863,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->target_controller(p.y).on_pointer(e); + area->target_controller(p.x, p.y).on_pointer(e); } // --- keyboard ------------------------------------------------------------- diff --git a/adapters/gtk4/gtk_plot_area.hpp b/adapters/gtk4/gtk_plot_area.hpp index 3596b71..d46003b 100644 --- a/adapters/gtk4/gtk_plot_area.hpp +++ b/adapters/gtk4/gtk_plot_area.hpp @@ -260,15 +260,17 @@ class PlotArea { 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 + /// One figure plot's controller -- the one a gesture on that plot reaches -- or null + /// while no figure is set or it does not exist. `row` is the **flattened plot index** + /// (`Figure::plot_count()` of them: the row count when no row holds several cells). 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 + /// while no figure is set, and otherwise the controller of the figure plot (cell) + /// that pixel lands in -- x and y both pick it, as a row may hold several cells side + /// by side. A pixel inside no cell (a `Figure::set_row_gap()`/`set_col_gap()` gap, a + /// margin) is the *nearest* cell 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. @@ -277,10 +279,10 @@ class PlotArea { /// 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 + /// The flattened plot index 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. + /// context menu can name the plot a pointer is over without converting anything. The + /// same nearest-cell rule (x and y) 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 -- @@ -376,20 +378,21 @@ 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. + /// The controller one event of this area's goes to, given the event's device (x, 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 + /// between gestures (x and y pick the cell). 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; + input::Controller& target_controller(f32 x, f32 y) noexcept; + /// The cell of `row_rects_` (the figure's flattened cell rects) a device pixel belongs + /// to: the cell it is in, or the nearest one when it is in none (a row or column 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 cell it is *near* + /// instead of to plot 0. + static u32 row_in(const std::vector& rows, f32 x, 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. @@ -457,7 +460,8 @@ class PlotArea { /// `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 + /// The figure's cell rects (one per plot, flattened; the row rects when no row is split) + /// 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_; diff --git a/include/plot/plot/figure.hpp b/include/plot/plot/figure.hpp index 295866d..6d746cd 100644 --- a/include/plot/plot/figure.hpp +++ b/include/plot/plot/figure.hpp @@ -33,7 +33,9 @@ struct FigureLayout { /// cells, the cells' widths summing to the row's less the gaps. std::vector cells; /// 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 + /// `PlotLayout::x_label_area` (for a bottom row split into several cells, only the **last + /// cell's** strip -- the others' strips are their own `plot(i).layout().x_label_area`, which + /// is where the controller's axis-zoom hit test reads each cell's gestures from), 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 @@ -236,17 +238,18 @@ class Figure { /// is not a number; such a call therefore leaves every row's crosshair as it was. void set_crosshair(f64 x, bool active); - /// Places the crosshair with the pointer's **row** and that row's local y (spec §17): the - /// shared x goes to every row -- vertical lines everywhere, as the plain overload does -- and - /// the horizontal line is drawn **only in `hovered_row`**, where its y is a value of *that* - /// row's own scale. It is the fix for the reported defect -- a figure showing no horizontal + /// Places the crosshair with the pointer's **plot** and that plot's local y (spec §17): the + /// shared x goes to every plot -- vertical lines everywhere, as the plain overload does -- and + /// the horizontal line is drawn **only in `hovered_row`** -- the **flattened plot index**, not + /// the figure-row index; they differ when a row holds several cells -- where its y is a value of + /// *that* plot's own scale. It is the fix for the reported defect -- a figure showing no horizontal /// line at all -- without inventing a figure-wide y, which would mean a different value in /// every row: the line appears where the pointer is, and no row ever shows another row's y. /// /// `y_in_row` is a data coordinate on `hovered_row`'s y axis 0, exactly as `Crosshair::y`'s /// two-coordinate placement is; a host reads it from the row's own y transform at the - /// pointer's pixel row. `hovered_row` outside `[0, row_count())` is treated as "no row under - /// the pointer": every row gets the vertical line and none gets a horizontal one, the same + /// pointer's pixel row. `hovered_row` outside `[0, plot_count())` is treated as "no plot under + /// the pointer": every plot gets the vertical line and none gets a horizontal one, the same /// state the single-coordinate overload produces. /// /// Refused, changing nothing, when `x` is not a number -- matching `set_crosshair(x, active)` @@ -267,9 +270,10 @@ class Figure { /// How many groups there are, excluding the unlinked group 0. u32 x_link_count() const noexcept { return static_cast(links_.size()); } - /// Moves one row into a link group and applies that group's range to it at once, - /// so the row is on the group's window from this call rather than from the next - /// frame. `group` 0 takes the row back out of its group, leaving it with the range + /// Moves one plot into a link group and applies that group's range to it at once; `row` + /// is the **flattened plot index** (see `plot_count()`), which is the row index only for a + /// figure with one cell per row. The plot is on the group's window from this call rather + /// than from the next frame. `group` 0 takes the plot back out of its group, leaving it with the range /// it holds now. /// /// A group with no range yet -- one that no row and no `set_x_link_range()` has @@ -283,7 +287,7 @@ class Figure { /// per-row window. `XFollow::Off` is the group's to set /// (`set_x_link_follow()`), not the axis'. Status link_x(u32 row, u32 group); - /// The group a row is in, or 0 when it is in none. + /// The group the plot with flattened index `row` is in, or 0 when it is in none. u32 x_link(u32 row) const noexcept; /// Sets a group's shared range and pushes it to every member. diff --git a/tests/test_glfw_host.cpp b/tests/test_glfw_host.cpp index 4976c49..6e5e928 100644 --- a/tests/test_glfw_host.cpp +++ b/tests/test_glfw_host.cpp @@ -14,6 +14,9 @@ #include #include +#include +#include + namespace { /// A figure of `rows` rows over a known rect, with an x and a y axis in every row (so @@ -315,3 +318,31 @@ PLOT_TEST(the_host_keeps_its_own_controller_while_no_figure_is_set) { PLOT_CHECK(host.row_controller(0u) == nullptr); PLOT_CHECK(&host.controller_at(10.0f, 10.0f) == &host.controller()); } + +PLOT_TEST(glfw_host_routes_by_cell) { + // A figure of two rows, the first of one cell and the second of two side by side: the + // pointer's x as well as its y picks the plot, and the controllers are indexed by the + // flattened plot. + std::vector cells{1, 2}; + plot::Figure figure{std::span(cells)}; + figure.set_viewport(plot::Rect{0, 0, 300, 600}); + figure.set_col_gap(6.0f); + for (plot::u32 i = 0; i < 3; ++i) { + figure.plot(i).add_x_axis({"t", {0.0, 1.0}, false, plot::AxisPlace::Bottom, false}); + figure.plot(i).add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false}); + } + plot::glfw::Host host; + host.set_figure(&figure); + const std::vector c = figure.layout().cells; + PLOT_CHECK(c.size() == 3u); + for (plot::u32 i = 0; i < 3; ++i) { + PLOT_CHECK(host.row_controller(i) != nullptr); + const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5; + PLOT_CHECK(&host.controller_at(static_cast(cx), static_cast(cy)) == + host.row_controller(i)); + } + PLOT_CHECK(host.row_controller(3u) == nullptr); + // in the column gap: the nearest cell wins + PLOT_CHECK(&host.controller_at(c[1].right() + 1.0f, c[1].y + 5.0f) == host.row_controller(1)); + PLOT_CHECK(&host.controller_at(c[2].x - 1.0f, c[2].y + 5.0f) == host.row_controller(2)); +}