fix(plot,render,glfw): a hue-neutral crosshair, an em-dash placeholder, one layout per figure event, and a usable readout switch

This commit is contained in:
Martino Ferrari committed 2026-09-28 19:38:30 +02:00
1 parent 10d5129fbf
commit a14f333dbe
10 files changed
+175 -70

No files matched your search

+20 -17
View File
@@ -212,15 +212,16 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
// controller_at() or injected by a scripted run -- is hit-tested against the // controller_at() or injected by a scripted run -- is hit-tested against the
// geometry the last frame drew, and the two readers of a row's geometry cannot // geometry the last frame drew, and the two readers of a row's geometry cannot
// disagree about it. // disagree about it.
const std::vector<Rect>& rows = figure_->layout().rows; // the one layout of this frame
for (u32 i = 0; i < row_controllers_.size(); ++i) { for (u32 i = 0; i < row_controllers_.size(); ++i) {
row_controllers_[i]->set_viewport(figure_->layout().rows[i]); if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]);
} }
// One hover, every row (spec §15): the crosshair the row controller under the pointer // 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 // 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 // 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 // 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. // scripted run that injects its events straight into a row controller is synced too.
sync_figure_crosshair(); sync_figure_crosshair(rows);
} else { } else {
controller_.set_viewport(viewport); // also Plot::set_viewport, then layout() controller_.set_viewport(viewport); // also Plot::set_viewport, then layout()
} }
@@ -430,20 +431,23 @@ input::Controller* Host::row_controller(u32 row) noexcept {
input::Controller& Host::controller_at(f32 x, f32 y) noexcept { input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return controller_; if (figure_ == nullptr || row_controllers_.empty()) return controller_;
const u32 row = row_at(x, y); // The figure's one layout for this call: both readers below take the row rects it produced, so an
if (row >= row_controllers_.size()) return controller_; // event lays the figure out once and not once per reader -- this is the hottest path a multi-row
// figure has, and a layout now rebuilds every row's ticks, grid and crosshair geometry.
const std::vector<Rect>& rows = figure_->layout().rows;
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);
last_row_ = row; last_row_ = row;
input::Controller& controller = *row_controllers_[row]; input::Controller& controller = *row_controllers_[row];
// The row's own viewport, so the geometry this controller hit-tests is the geometry // 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. // the last frame drew: the same rect `Figure::layout()` gave the row's plot.
controller.set_viewport(figure_->layout().rows[row]); controller.set_viewport(rows[row]);
return controller; return controller;
} }
u32 Host::row_at(f32 x, f32 y) const noexcept { u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
if (figure_ == nullptr || row_controllers_.empty()) return 0u; if (rows.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 // 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 // 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 // row whose rect is nearest. Selection by distance rather than by "the first that
@@ -462,13 +466,12 @@ u32 Host::row_at(f32 x, f32 y) const noexcept {
if (distance == 0.0f) break; // inside this row: nothing can be nearer if (distance == 0.0f) break; // inside this row: nothing can be nearer
} }
} }
(void)x; // the rows tile the figure vertically, so y alone picks one
return row; return row;
} }
void Host::sync_figure_crosshair() noexcept { void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
const u32 rows = figure_ != nullptr ? figure_->row_count() : 0u; if (rows.empty() || row_controllers_.empty()) return;
if (rows == 0u || row_controllers_.empty()) return; const u32 row_count = static_cast<u32>(rows.size());
// Which row's crosshair is the hover. With a pointer of the host's own it is the row that pointer // 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 // is over -- the same rule events are routed by. A scripted run injects its events straight into
@@ -477,18 +480,18 @@ void Host::sync_figure_crosshair() noexcept {
// any row holding something else was changed from outside, and that is the one the run touched. // 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 // Nothing differing means every row still holds the last spread -- no new hover -- and the figure
// keeps it. // keeps it.
u32 hovered = rows; u32 hovered = row_count;
if (have_pointer_) { if (have_pointer_) {
hovered = row_at(last_pointer_x_, last_pointer_y_); hovered = row_in(rows, last_pointer_y_);
} else { } else {
for (u32 i = 0; i < rows; ++i) { for (u32 i = 0; i < row_count; ++i) {
const Crosshair& candidate = figure_->plot(i).crosshair(); const Crosshair& candidate = figure_->plot(i).crosshair();
if (candidate.active != spread_active_ || (candidate.active && candidate.x != spread_x_)) { if (candidate.active != spread_active_ || (candidate.active && candidate.x != spread_x_)) {
hovered = i; hovered = i;
break; break;
} }
} }
if (hovered >= rows) return; if (hovered >= row_count) return;
} }
// Applied to the whole figure: one x, every row. A row whose controller has *no* crosshair (the // Applied to the whole figure: one x, every row. A row whose controller has *no* crosshair (the
+8 -6
View File
@@ -174,10 +174,11 @@ class Host {
void on_scroll(f64 dx, f64 dy) noexcept; void on_scroll(f64 dx, f64 dy) noexcept;
void on_key(int key, int action, int mods) noexcept; void on_key(int key, int action, int mods) noexcept;
/// The index of the figure row a framebuffer pixel belongs to, by the rule /// The index of the row of `rows` a framebuffer y belongs to: the row it is in, or the nearest
/// `controller_at()` routes by: the row the pixel is in, or the nearest row when it is in none /// row when it is in none (a row gap, a margin outside the figure). One rule, shared by the event
/// (a row gap, or a margin outside the figure). 0 while no figure is set. /// routing and the crosshair sync -- and taking the rects rather than reading them, so a caller
u32 row_at(f32 x, f32 y) const noexcept; /// that has just laid the figure out does not make it lay out again for this.
static u32 row_in(const std::vector<Rect>& rows, f32 y) noexcept;
/// Spreads the hover over a whole `Figure` (spec §15). /// Spreads the hover over a whole `Figure` (spec §15).
/// ///
@@ -193,8 +194,9 @@ class Host {
/// whose controller has no crosshair clears the figure's. /// 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 /// 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. /// gets the same synced figure a real pointer does. `rows` is the figure's row rects, which the
void sync_figure_crosshair() noexcept; /// run loop has just read -- the one layout per frame this shares with the row viewports.
void sync_figure_crosshair(const std::vector<Rect>& rows) noexcept;
Plot plot_; Plot plot_;
input::Controller controller_; ///< forwards its viewport to `plot_` input::Controller controller_; ///< forwards its viewport to `plot_`
+19 -4
View File
@@ -130,8 +130,11 @@ struct CrosshairRow {
f64 y = 0.0; f64 y = 0.0;
/// The trace's name, or the positional placeholder of an unnamed one. /// The trace's name, or the positional placeholder of an unnamed one.
std::string label; std::string label;
/// The formatted value, written in the trace's y axis' own tick format (`Axis::format_value()`, /// What the row's value column shows. For a `valid` row it is the formatted number, written in
/// so a readout reads exactly like the labels beside it), and empty when `!valid`. /// the trace's y axis' own tick format (`Axis::format_value()`, so a readout reads exactly like
/// the labels beside it); for a row whose trace has **no sample at the crosshair's x** it is
/// `kCrosshairNoValue`, the em dash. The absence is therefore *legible* -- a blank cell reads as
/// "still loading" rather than as "nothing here" -- and nothing numeric is fabricated for it.
std::string value; std::string value;
/// The trace's intersection with the crosshair, in framebuffer pixels: the pen the /// The trace's intersection with the crosshair, in framebuffer pixels: the pen the
/// renderer draws the marker at. Placed through the *trace's own* axis pair, so the /// renderer draws the marker at. Placed through the *trace's own* axis pair, so the
@@ -143,8 +146,10 @@ struct CrosshairRow {
/// The label's line box: its top-left is the pen the run is staged at and its height one /// The label's line box: its top-left is the pen the run is staged at and its height one
/// text line. Empty when `!listed`. /// text line. Empty when `!listed`.
Rect label_box{}; Rect label_box{};
/// The value's line box, one gutter (the legend's padding) after the label's. Empty when /// The value's line box, right-aligned on the panel's inner edge and one gutter's worth of
/// `!listed` or `!valid`: a row with no value stages no run. /// measurement away from the label's own box. Empty when `!listed`: a row that is not in the panel
/// stages neither run, while a listed row always stages something -- the number, or
/// `kCrosshairNoValue`.
Rect value_box{}; Rect value_box{};
}; };
@@ -185,6 +190,16 @@ struct CrosshairLayout {
bool empty() const noexcept { return !active; } bool empty() const noexcept { return !active; }
}; };
/// What a listed readout row shows in its value column when its trace has **no sample at the
/// crosshair's x**: an em dash, not a blank.
///
/// The row is listed either way -- the trace *is* under the pointer, and a panel that grew and
/// shrank with the pointer would be worse to read than one that says "nothing here" -- so the
/// absence has to be *said*: a blank value cell reads as a value that has not arrived yet. The em
/// dash is in the embedded font's coverage (spec §2 covers U+2013/U+2014), it is not a number, and
/// it cannot be mistaken for one.
inline constexpr const char* kCrosshairNoValue = "\u2014";
/// The label an **unnamed** trace's readout row carries: `#` and the trace's position, e.g. /// The label an **unnamed** trace's readout row carries: `#` and the trace's position, e.g.
/// `#2`. /// `#2`.
/// ///
+6
View File
@@ -567,6 +567,12 @@ class Plot {
/// calls this when the pointer leaves the axes. /// calls this when the pointer leaves the axes.
void clear_crosshair() noexcept { crosshair_.active = false; } void clear_crosshair() noexcept { crosshair_.active = false; }
/// Switches the readout panel off -- or back on -- without moving the crosshair: the lines and
/// the per-trace markers stay, the panel is not measured and no row is listed. A host that wants
/// the pointer's column marked but not a panel over its data sets this once; it is deliberately
/// **not** part of `set_crosshair()`, so placing a hover cannot silently change it.
void set_crosshair_readout(bool on) noexcept { crosshair_.show_readout = on; }
/// The crosshair's state: where it is, which lines it draws and whether the readout is /// The crosshair's state: where it is, which lines it draws and whether the readout is
/// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the /// on. Read-only -- `set_crosshair()` and `clear_crosshair()` are how it moves -- so the
/// coordinates it holds and the geometry `layout()` places from them cannot disagree. /// coordinates it holds and the geometry `layout()` places from them cannot disagree.
+12 -7
View File
@@ -41,13 +41,18 @@ struct Style {
Color axis_text = Color::hex(0xC8D0DC); Color axis_text = Color::hex(0xC8D0DC);
Color cursor = Color::hex(0xFFB020); Color cursor = Color::hex(0xFFB020);
Color measure_fill = Color::hex(0xFFB020, 0.15f); Color measure_fill = Color::hex(0xFFB020, 0.15f);
/// The crosshair's colour: its two lines, its markers' own colour excepted, and the /// The crosshair's colour: its two lines and the readout panel's border (spec §15). Markers are
/// readout panel's border (spec §15). Deliberately **not** `cursor`: the cursor accent is /// deliberately *not* this colour -- they carry each trace's own, because a marker says *which*
/// an orange that a caller's own cursors, a region's bounds and its fill all already use, /// trace and the line says *where*.
/// while the crosshair is a passive hover instrument that has to read as *chrome* over a ///
/// dense trace rather than as another handle to grab. A pale cyan does that against the /// **Hue-neutral by design**, and that is the whole choice: a saturated colour here would be
/// dark default theme, and it is a colour no default element of a frame shares. /// confusable with a trace, and the library's own default trace colour is a saturated cyan
Color crosshair = Color::hex(0xB8E8FF); /// (`Trace`'s default, 0x40C0FF) that a hue-based reading cannot tell from a cyan crosshair --
/// only lightness would separate them, which no reader can rely on. A near-white can never be a
/// saturated trace of *any* hue, and it is still plainly not `cursor`'s orange, which is what
/// the spec asks for. The 1 px stroke is kept: form (a full-height thin line over a polyline) is
/// already distinct, so no dashes.
Color crosshair = Color::hex(0xE6EBF2);
// --- the scale ---------------------------------------------------------- // --- the scale ----------------------------------------------------------
+17 -13
View File
@@ -1305,9 +1305,10 @@ void Plot::build_crosshair() {
row.label_box = Rect{}; row.label_box = Rect{};
row.value_box = Rect{}; row.value_box = Rect{};
row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name); row.label.assign(trace.name.empty() ? crosshair_unnamed_label(i) : trace.name);
row.value.clear(); // The value: the interpolated number where the trace has a sample at this x, and the em dash
// The value, and with it the marker: a trace with no sample at this x writes no number // where it does not. The row is listed either way, so the absence has to be *said* -- a blank
// and gets no marker, rather than a value clamped to the end of its series. // value cell reads as a value that has not arrived -- and the dash is measured into the panel's
// width like any other value, so the row's boxes are placed by one rule.
if (crosshair_value(i, crosshair_.x, row.y)) { if (crosshair_value(i, crosshair_.x, row.y)) {
row.valid = true; row.valid = true;
char value[32]; char value[32];
@@ -1319,6 +1320,8 @@ void Plot::build_crosshair() {
// actually drawn however its axes are scaled. // actually drawn however its axes are scaled.
row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(crosshair_.x)), row.marker = Vec2{static_cast<f32>(x_transform(trace.axis_x).to_screen_x(crosshair_.x)),
static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))}; static_cast<f32>(y_transform(trace.axis_y).to_screen_y(row.y))};
} else {
row.value.assign(kCrosshairNoValue);
} }
} }
crosshair.rows.resize(count); crosshair.rows.resize(count);
@@ -1328,13 +1331,13 @@ void Plot::build_crosshair() {
if (count == 0u || !crosshair_.show_readout || text_ == nullptr) return; if (count == 0u || !crosshair_.show_readout || text_ == nullptr) return;
const ReadoutChrome chrome = readout_chrome(legend_, style_, *text_); const ReadoutChrome chrome = readout_chrome(legend_, style_, *text_);
// Each row's content: its label, the gutter, and its value when it has one -- an invalid // Each row's content: its label, the gutter, and its value -- which is a number or the em dash,
// row is narrower, and the panel is sized by the widest row that will be listed, which the // so every row is the same two runs and the panel is sized by the widest *listed* one, read in
// prefix-max reads in O(1) after a row was dropped from the end. // O(1) by the prefix-max after a row was dropped from the end.
crosshair_content_px_.clear(); crosshair_content_px_.clear();
for (const CrosshairRow& row : crosshair.rows) { for (const CrosshairRow& row : crosshair.rows) {
f32 content = text_->measure(row.label.c_str()); const f32 content =
if (row.valid) content += chrome.pad + text_->measure(row.value.c_str()); text_->measure(row.label.c_str()) + chrome.pad + text_->measure(row.value.c_str());
crosshair_content_px_.push_back(crosshair_content_px_.empty() crosshair_content_px_.push_back(crosshair_content_px_.empty()
? content ? content
: std::max(crosshair_content_px_.back(), content)); : std::max(crosshair_content_px_.back(), content));
@@ -1393,11 +1396,12 @@ void Plot::build_crosshair() {
row.swatch = readout_swatch(chrome, crosshair.panel, i); row.swatch = readout_swatch(chrome, crosshair.panel, i);
const Vec2 pen = readout_text_pen(chrome, crosshair.panel, i); const Vec2 pen = readout_text_pen(chrome, crosshair.panel, i);
row.label_box = Rect{pen.x, pen.y, text_->measure(row.label.c_str()), chrome.line_h}; row.label_box = Rect{pen.x, pen.y, text_->measure(row.label.c_str()), chrome.line_h};
if (row.valid) { // Every listed row stages a value run: the number, or `kCrosshairNoValue` where the trace has
const f32 width = text_->measure(row.value.c_str()); // no sample here. A blank cell would read as a value still to come, which is a different thing
row.value_box = // from "nothing at this x".
Rect{crosshair.panel.right() - chrome.pad - width, pen.y, width, chrome.line_h}; const f32 width = text_->measure(row.value.c_str());
} row.value_box =
Rect{crosshair.panel.right() - chrome.pad - width, pen.y, width, chrome.line_h};
} }
} }
+5 -5
View File
@@ -1190,7 +1190,7 @@ void Renderer::draw_swatch(const Plot& plot, u32 trace, const Rect& box) {
void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair; const CrosshairLayout& crosshair = layout.crosshair;
if (!crosshair.active) return; if (crosshair.empty()) return;
const f32 stroke = style_.device(style_.line_width_px); const f32 stroke = style_.device(style_.line_width_px);
// The two lines. Each is staged as one data-space segment at the *centre* of the stroke box // The two lines. Each is staged as one data-space segment at the *centre* of the stroke box
@@ -1231,7 +1231,7 @@ void Renderer::draw_crosshair_lines(const Plot& plot, const PlotLayout& layout)
void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout) { void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout) {
const CrosshairLayout& crosshair = layout.crosshair; const CrosshairLayout& crosshair = layout.crosshair;
if (!crosshair.active) return; if (crosshair.empty()) return;
// The readout: the same backplate a legend has (the shared `draw_readout_panel()`), with its // The readout: the same backplate a legend has (the shared `draw_readout_panel()`), with its
// border in the crosshair's own colour -- which is what makes the panel read as part of the // border in the crosshair's own colour -- which is what makes the panel read as part of the
@@ -1242,9 +1242,9 @@ void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout
if (!row.listed) continue; if (!row.listed) continue;
draw_swatch(plot, row.trace, row.swatch); draw_swatch(plot, row.trace, row.swatch);
stage_text({row.label_box.x, row.label_box.y}, row.label.c_str()); stage_text({row.label_box.x, row.label_box.y}, row.label.c_str());
// A row with no sample at this x has no value to write -- and it is *not* written as a // The value is a number, or `kCrosshairNoValue` for a trace with no sample at this x (the plot
// zero or a dash: the box is empty and the number is simply absent (see CrosshairRow). // put it there): either way there is a run to stage, so a row is never a blank cell.
if (row.valid) stage_text({row.value_box.x, row.value_box.y}, row.value.c_str()); stage_text({row.value_box.x, row.value_box.y}, row.value.c_str());
} }
} }
+9 -4
View File
@@ -650,16 +650,21 @@ PLOT_TEST(figure_crosshair_applies_one_x_to_every_row) {
PLOT_CHECK(third.panel.w == 0.0f); PLOT_CHECK(third.panel.w == 0.0f);
// Row 3's series starts at x = 100: it is a visible signal with nothing at x = 3, so it is // Row 3's series starts at x = 100: it is a visible signal with nothing at x = 3, so it is
// listed with **no value** rather than a value clamped to the end of its window. // listed with **no number** rather than one clamped to the end of its window -- and the absence
// is said out loud with the documented placeholder, not left as a blank cell that would read as a
// value still to come.
const plot::CrosshairLayout& fourth = figure.plot(3u).layout().crosshair; const plot::CrosshairLayout& fourth = figure.plot(3u).layout().crosshair;
PLOT_CHECK(fourth.rows.size() == 1u); PLOT_CHECK(fourth.rows.size() == 1u);
PLOT_CHECK(fourth.rows[0].trace == 0u); PLOT_CHECK(fourth.rows[0].trace == 0u);
PLOT_CHECK(fourth.rows[0].label == "elsewhere"); PLOT_CHECK(fourth.rows[0].label == "elsewhere");
PLOT_CHECK(!fourth.rows[0].valid); PLOT_CHECK(!fourth.rows[0].valid);
PLOT_CHECK(fourth.rows[0].value.empty()); PLOT_CHECK(fourth.rows[0].value == plot::kCrosshairNoValue);
// ... and it is still listed, so the row's presence does not depend on having a value. PLOT_CHECK(fourth.rows[0].marker.x == 0.0f && fourth.rows[0].marker.y == 0.0f); // no intersection
// ... and it is still listed, with a value column, so the row's presence and its shape do not
// depend on having a sample there.
PLOT_CHECK(fourth.rows[0].listed); PLOT_CHECK(fourth.rows[0].listed);
PLOT_CHECK(fourth.rows[0].value_box.w == 0.0f); PLOT_CHECK(fourth.rows[0].value_box.w > 0.0f);
PLOT_CHECK(fourth.rows[0].value_box.right() <= fourth.panel.right());
// The crosshair is a data coordinate, so the x is the *same* whether or not the rows sample // The crosshair is a data coordinate, so the x is the *same* whether or not the rows sample
// there -- moving it moves every row, and the rows that have data report their own value at // there -- moving it moves every row, and the rows that have data report their own value at
+35 -6
View File
@@ -2038,12 +2038,41 @@ PLOT_TEST(crosshair_readout_lists_every_visible_trace_including_unnamed_ones) {
PLOT_CHECK(cleared.panel.w == 0.0f && cleared.panel.h == 0.0f); PLOT_CHECK(cleared.panel.w == 0.0f && cleared.panel.h == 0.0f);
PLOT_CHECK(cleared.vertical.w == 0.0f && cleared.horizontal.h == 0.0f); PLOT_CHECK(cleared.vertical.w == 0.0f && cleared.horizontal.h == 0.0f);
// A crosshair with the readout switched off still draws its lines -- and lists nothing. // A crosshair whose x is outside every trace's window: the row is still listed -- the trace is
scene.plot.set_crosshair(2.0, true); // one of the signals under the pointer -- and says so with the documented placeholder rather than
plot::Crosshair no_readout = scene.plot.crosshair(); // a blank cell or a fabricated number.
PLOT_CHECK(no_readout.active); scene.plot.set_crosshair(10.0, true); // past the last sample of the 4-sample series
PLOT_CHECK(!no_readout.horizontal); // the one-coordinate form draws no row const plot::CrosshairLayout& past_the_end = scene.plot.layout().crosshair;
PLOT_CHECK(no_readout.show_readout); PLOT_CHECK(past_the_end.rows.size() == 2u); // the two visible traces, both with no sample here
PLOT_CHECK(!past_the_end.rows[0].valid);
PLOT_CHECK(past_the_end.rows[0].value == plot::kCrosshairNoValue);
PLOT_CHECK(!past_the_end.rows[0].value.empty());
PLOT_CHECK(past_the_end.rows[0].value_box.w > 0.0f); // the dash has a box of its own
PLOT_CHECK(past_the_end.rows[0].listed);
PLOT_CHECK(past_the_end.rows[0].value_box.w <= past_the_end.panel.w);
// The readout switch, and the reason it is a switch rather than a field nobody can reach: with it
// off the crosshair itself is untouched -- the lines are still placed and the trace is still a
// row -- while nothing is listed in a panel, so a host that wants the pointer's column marked and
// not a panel over its data can say so. Switching it back on lists the rows again.
scene.plot.set_crosshair(1.5, true);
scene.plot.set_crosshair_readout(false);
const plot::CrosshairLayout& off = scene.plot.layout().crosshair;
PLOT_CHECK(off.active);
PLOT_CHECK(off.vertical.w > 0.0f); // the switch is the readout's: the lines are untouched
PLOT_CHECK(off.rows.size() == 2u); // both traces are still there, with their markers
for (const plot::CrosshairRow& row : off.rows) {
PLOT_CHECK(!row.listed); // ... and neither is a line in a panel
PLOT_CHECK(row.value_box.w == 0.0f);
PLOT_CHECK(row.marker.x > 0.0f); // the marker is placed either way
}
PLOT_CHECK(off.panel.w == 0.0f && off.panel.h == 0.0f);
scene.plot.set_crosshair_readout(true);
const plot::CrosshairLayout& on = scene.plot.layout().crosshair;
PLOT_CHECK(on.rows[0].listed);
PLOT_CHECK(on.rows[1].listed);
PLOT_CHECK(on.panel.w > 0.0f && on.panel.h > 0.0f);
PLOT_CHECK(on.rows[0].value == "15");
} }
/// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise /// Spec §15, the panel's placement: near the pointer, flipped over it where it would otherwise
+44 -8
View File
@@ -4575,18 +4575,54 @@ PLOT_TEST(horizontal_ruler_stays_inside_the_axes_at_the_edges) {
namespace { namespace {
/// The crosshair's own colour (0xB8E8FF) at real coverage: a pale cyan, green clearly above red /// The crosshair's own ink: **bright and hue-neutral**, which is the property the default colour
/// and blue above green, which no other element of the frames this case draws produces. The two /// has on purpose (see `Style::crosshair`) so that it can never be read as a trace of any hue.
/// traces here are magenta and green -- deliberately *not* blue, which is the one other colour /// This predicate is therefore about brightness and chroma rather than about a hue, and it needs no
/// that would satisfy "blue above green and green above red" (0x40C0FF does). The axis text /// "not this trace's colour" exception: every other element of these frames is either dark (the
/// (0xC8D0DC) is grey enough to fail the green-over-red term, the chrome and the grid are darker /// background, the grid), saturated (the magenta, green and orange traces) or dimmer than the
/// still, and the same hue at lower coverage still passes, so a partially covered stroke counts /// crosshair (the axis text 0xC8D0DC, whose red channel is 200 against the crosshair's 230).
/// as ink rather than the test needing a fully covered pixel to exist.
bool crosshair_pixel(const plot::u8 px[4]) { bool crosshair_pixel(const plot::u8 px[4]) {
return px[1] > px[0] + 25 && px[2] > px[1] + 12 && px[2] > 120; const int lo = std::min(px[0], std::min(px[1], px[2]));
const int hi = std::max(px[0], std::max(px[1], px[2]));
return px[0] > 215 && hi - lo <= 24;
} }
} // namespace } // namespace
/// The crosshair's colour has to be tellable from a trace, and telling colours apart by hue is the
/// one thing a reader cannot be asked to do at a glance: the library's own default trace colour is a
/// saturated cyan, so a saturated cyan crosshair would differ from it in lightness alone. The
/// default is therefore near-white -- a colour no saturated trace of any hue can be -- and still
/// plainly not the cursor accent. Both halves are asserted, so a future recolouring that makes it
/// confusable with a trace or with a handle fails here rather than in a screenshot.
PLOT_TEST(crosshair_colour_cannot_be_mistaken_for_a_trace) {
const plot::render::Style style;
const plot::Trace default_trace;
// `Color` stores normalised channels; the comparisons below are in 0..255, like the hex literals
// the colours are written with.
const auto chan = [](plot::f32 c) { return static_cast<int>(c * 255.0f + 0.5f); };
const int lo = std::min({chan(style.crosshair.r), chan(style.crosshair.g), chan(style.crosshair.b)});
const int hi = std::max({chan(style.crosshair.r), chan(style.crosshair.g), chan(style.crosshair.b)});
// Hue-neutral: its channels are within a few levels of each other, so it is a grey/white and not
// a hue at all. A saturated colour -- the default trace's cyan included -- is 150+ levels apart.
PLOT_CHECK(hi - lo <= 24);
PLOT_CHECK(lo > 200); // bright chrome, not a mid-tone that a dimmed trace could reach
// `Color` is a plain channel struct with no comparison of its own, so the two "not this colour"
// assertions spell the channels out.
const auto same = [](plot::Color a, plot::Color b) {
return a.r == b.r && a.g == b.g && a.b == b.b;
};
PLOT_CHECK(!same(style.crosshair, default_trace.color));
PLOT_CHECK(!same(style.crosshair, style.cursor));
// ... and a saturated trace of any hue is out of reach for it by construction: the *most*
// chromatic colour the crosshair could be confused with has to be within the chroma bound above,
// and every trace colour in this library's demos is far outside it.
const plot::Color demo_traces[3] = {plot::Color::hex(0x40C0FF), plot::Color::hex(0xFFB020),
plot::Color::hex(0x35D0A8)};
for (const plot::Color trace : demo_traces) {
PLOT_CHECK(!same(style.crosshair, trace));
}
}
namespace { namespace {
/// A third colour predicate for the lateral case below: the orange 0xFFB020 of a trace of its /// A third colour predicate for the lateral case below: the orange 0xFFB020 of a trace of its