fix(plot,glfw): layer the crosshair under the handles, sync it across a figure, and harden the lookup

This commit is contained in:
Martino Ferrari committed 2026-09-28 19:22:29 +02:00
1 parent 8e27e22ebe
commit 29824b6919
5 files changed
+183 -63

No files matched your search

+64 -9
View File
@@ -215,6 +215,12 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
for (u32 i = 0; i < row_controllers_.size(); ++i) {
row_controllers_[i]->set_viewport(figure_->layout().rows[i]);
}
// One hover, every row (spec §15): the crosshair the row controller under the pointer
// placed is applied to the whole figure here, after the pointers of this frame have been
// handled and before anything lays the rows out -- so every row's readout lists its own
// signals at the one x the pointer is over. In the run loop rather than in a callback, so a
// scripted run that injects its events straight into a row controller is synced too.
sync_figure_crosshair();
} else {
controller_.set_viewport(viewport); // also Plot::set_viewport, then layout()
}
@@ -406,6 +412,10 @@ void Host::set_figure(Figure* figure) noexcept {
figure_ = figure;
row_controllers_.clear();
last_row_ = 0u;
// A new figure holds no crosshair this host has spread: the comparison in
// `sync_figure_crosshair()` starts from "nothing spread", which is the state its rows are in.
spread_active_ = false;
spread_x_ = 0.0;
if (figure_ == nullptr) return;
const u32 rows = figure_->row_count();
row_controllers_.reserve(rows);
@@ -420,9 +430,20 @@ input::Controller* Host::row_controller(u32 row) noexcept {
input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return controller_;
const FigureLayout& layout = figure_->layout();
const std::vector<Rect>& rows = layout.rows;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
const u32 row = row_at(x, y);
if (row >= row_controllers_.size()) return controller_;
last_row_ = row;
input::Controller& controller = *row_controllers_[row];
// The row's own viewport, so the geometry this controller hit-tests is the geometry
// the last frame drew: the same rect `Figure::layout()` gave the row's plot.
controller.set_viewport(figure_->layout().rows[row]);
return controller;
}
u32 Host::row_at(f32 x, f32 y) const noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return 0u;
const std::vector<Rect>& rows = figure_->layout().rows;
if (rows.empty() || rows.size() != row_controllers_.size()) 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
@@ -442,12 +463,46 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
}
}
(void)x; // the rows tile the figure vertically, so y alone picks one
last_row_ = row;
input::Controller& controller = *row_controllers_[row];
// The row's own viewport, so the geometry this controller hit-tests is the geometry
// the last frame drew: the same rect `Figure::layout()` gave the row's plot.
controller.set_viewport(rows[row]);
return controller;
return row;
}
void Host::sync_figure_crosshair() noexcept {
const u32 rows = figure_ != nullptr ? figure_->row_count() : 0u;
if (rows == 0u || row_controllers_.empty()) return;
// Which row's crosshair is the hover. With a pointer of the host's own it is the row that pointer
// is over -- the same rule events are routed by. A scripted run injects its events straight into
// a row controller and the host never sees a pointer, so there the hover is the row whose
// crosshair is *not* the one this host spread last: the figure writes one value to every row, so
// any row holding something else was changed from outside, and that is the one the run touched.
// Nothing differing means every row still holds the last spread -- no new hover -- and the figure
// keeps it.
u32 hovered = rows;
if (have_pointer_) {
hovered = row_at(last_pointer_x_, last_pointer_y_);
} else {
for (u32 i = 0; i < rows; ++i) {
const Crosshair& candidate = figure_->plot(i).crosshair();
if (candidate.active != spread_active_ || (candidate.active && candidate.x != spread_x_)) {
hovered = i;
break;
}
}
if (hovered >= rows) return;
}
// Applied to the whole figure: one x, every row. A row whose controller has *no* crosshair (the
// pointer left its axes, which is 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) {
figure_->set_crosshair(row.crosshair().x, true);
spread_x_ = row.crosshair().x;
spread_active_ = true;
} else {
figure_->clear_crosshair();
spread_active_ = false;
}
}
} // namespace plot::glfw
+35
View File
@@ -107,6 +107,12 @@ class Host {
/// `Renderer::draw(Figure&)`. The frame callback is handed the figure's **row 0**
/// as its `Plot&`, so `plot()` and the frame argument agree.
///
/// The figure's **crosshair** follows the pointer as one crosshair and not one per row: the run
/// loop reads the hover out of the row under the pointer and applies it to the whole figure
/// (`sync_figure_crosshair()`), so every row's readout lists its own signals at the one x the
/// pointer is over. That is what "synced if multiple plots available" means, and the host is the
/// layer that can do it -- it is the one that knows which row the pointer is in.
///
/// The figure is borrowed and must outlive the run. Passing null restores the
/// single-plot loop, which is what every other caller uses; the host's own
/// `plot_` is then untouched by any of this.
@@ -168,6 +174,28 @@ class Host {
void on_scroll(f64 dx, f64 dy) noexcept;
void on_key(int key, int action, int mods) noexcept;
/// The index of the figure row a framebuffer pixel belongs to, by the rule
/// `controller_at()` routes by: the row the pixel is in, or the nearest row when it is in none
/// (a row gap, or a margin outside the figure). 0 while no figure is set.
u32 row_at(f32 x, f32 y) const noexcept;
/// Spreads the hover over a whole `Figure` (spec §15).
///
/// A figure's rows share one x, so the crosshair one row's controller placed is read back here and
/// applied to every row: the controller turns a pointer position into a data coordinate -- which
/// is its job, and the same one it does for a single plot -- and the figure spreads that one
/// coordinate over its rows. Reading it back rather than deriving a second coordinate here is what
/// keeps the hover the row shows and the hover the figure shows the same number.
///
/// The row it reads is the one under the host's pointer, or -- with no pointer of the host's own,
/// which is how a scripted run injects its events -- the row whose crosshair is not the value this
/// host spread last (`spread_x_`/`spread_active_`), i.e. the row something outside changed. A row
/// whose controller has no crosshair clears the figure's.
///
/// Called once per frame from the run loop, so a scripted run that bypasses the GLFW callbacks
/// gets the same synced figure a real pointer does.
void sync_figure_crosshair() noexcept;
Plot plot_;
input::Controller controller_; ///< forwards its viewport to `plot_`
std::optional<Renderer> renderer_;
@@ -183,6 +211,13 @@ class Host {
/// no position of its own, and the row the pointer is over is the one the user is
/// looking at.
u32 last_row_ = 0u;
/// The crosshair this host last spread over the figure (see
/// `sync_figure_crosshair()`): what a row is compared against to find the one a
/// scripted run touched, 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;
bool spread_active_ = false;
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
u32 fb_height_ = 0;