Compare commits
4
Commits
e337c6a60c
...
211c5b97bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
211c5b97bc | ||
|
|
125cc1f64a | ||
|
|
523446d08f | ||
|
|
133cd680ce |
No files matched your search
@@ -86,6 +86,12 @@ struct Cursor {
|
||||
u32 axis_x = 0; ///< the x axis `x` is in. Dead for `Horizontal`.
|
||||
u32 axis_y = 0; ///< the y axis `y` is in. Dead for `Vertical`.
|
||||
bool visible = true;
|
||||
/// Whether the pointer may hit this cursor at all: `false` for a *readout* -- a marker a
|
||||
/// host keeps to show something it computed, which the user must not be able to drag. The
|
||||
/// controller's `cursor_at()` skips it, so it is neither hovered nor grabbed, and the
|
||||
/// hit radius it would have taken is left to whatever is behind it. Drawn exactly as a
|
||||
/// visible one is: this is about the gesture, not the ink.
|
||||
bool selectable = true;
|
||||
|
||||
/// True when this is a horizontal ruler (spans the axes' width, reads its `y`). The one
|
||||
/// place the orientation becomes a branch, so every consumer takes the same decision and
|
||||
|
||||
@@ -229,6 +229,13 @@ class Plot {
|
||||
/// Measures the interval between two cursors over the series of `trace`.
|
||||
u32 add_measure(u32 cursor_a, u32 cursor_b, u32 trace = 0);
|
||||
u32 add_annotation(const Annotation& annotation);
|
||||
/// Moves an annotation that is already in the plot, keeping its id -- what a host whose
|
||||
/// annotation is a *readout* needs (the point where a cursor crosses a trace moves with
|
||||
/// the cursor). `Plot` has no `remove_annotation`, so the same parking discipline as the
|
||||
/// cursors applies: set `visible = false` rather than dropping it, and re-place where it
|
||||
/// belongs. An id that names no annotation is ignored, not an error, exactly as every
|
||||
/// other setter here treats a stale id.
|
||||
void set_annotation(u32 id, const Annotation& annotation);
|
||||
/// Adds a region. Its bounds are ordered and, when the region asks to snap, pulled
|
||||
/// onto the sample grid as it is added -- exactly as `add_cursor()` places a
|
||||
/// snapping cursor -- so a region handed to a plot that already holds samples is on
|
||||
@@ -472,6 +479,15 @@ class Plot {
|
||||
///
|
||||
/// Ignored for a vertical cursor, whose snap is `SnapMode` -- see `set_cursor_snap()`.
|
||||
void set_cursor_h_snap(u32 id, HRulerSnap mode);
|
||||
/// Recolours a cursor that already exists. A *readout* cursor -- one a host keeps as a
|
||||
/// marker for something it computed, rather than an instrument the user placed -- has to
|
||||
/// be told apart from its neighbours, and its colour is how it says which readout it
|
||||
/// belongs to. An id that names no cursor is ignored, like every other setter here.
|
||||
void set_cursor_color(u32 id, Color color);
|
||||
/// Makes a cursor unselectable (see `Cursor::selectable`): the pointer cannot hover or
|
||||
/// grab it, which is what a host wants for a marker it draws as a *readout*. An id that
|
||||
/// names no cursor is ignored, like every other setter here.
|
||||
void set_cursor_selectable(u32 id, bool selectable);
|
||||
|
||||
/// The measurement for `id`. Re-evaluated only when a cursor moved, when the
|
||||
/// measure was added, or when the series underneath it changed: a different
|
||||
|
||||
@@ -925,7 +925,9 @@ u64 Controller::cursor_at(f32 px, f32 py) const {
|
||||
f32 best_d = 0.0f;
|
||||
for (u32 i = 0; i < plot_->cursor_count(); ++i) {
|
||||
const Cursor& cursor = plot_->cursor(i);
|
||||
if (!cursor.visible) continue;
|
||||
// A readout is drawn but not offered to the pointer: skipping it here is what makes it
|
||||
// undraggable, rather than fighting the drag a frame at a time.
|
||||
if (!cursor.visible || !cursor.selectable) continue;
|
||||
// One hit test for both orientations, because a cursor *is* one line across the axes:
|
||||
// what the pointer has to be near is that line's own coordinate -- the column of a
|
||||
// vertical marker, the row of a horizontal ruler -- measured with the same radius
|
||||
|
||||
@@ -306,6 +306,21 @@ u32 Plot::add_annotation(const Annotation& annotation) {
|
||||
return static_cast<u32>(annotations_.size() - 1);
|
||||
}
|
||||
|
||||
void Plot::set_cursor_color(u32 id, Color color) {
|
||||
if (id >= cursors_.size()) return;
|
||||
cursors_[id].color = color;
|
||||
}
|
||||
|
||||
void Plot::set_cursor_selectable(u32 id, bool selectable) {
|
||||
if (id >= cursors_.size()) return;
|
||||
cursors_[id].selectable = selectable;
|
||||
}
|
||||
|
||||
void Plot::set_annotation(u32 id, const Annotation& annotation) {
|
||||
if (id >= annotations_.size()) return;
|
||||
annotations_[id] = annotation;
|
||||
}
|
||||
|
||||
u32 Plot::add_region(const Region& region) {
|
||||
// A region whose bounds are not numbers is not an interval this plot can hold, and a
|
||||
// `Free` one stored that way could never be dragged back (every motion re-derives both
|
||||
|
||||
@@ -2309,3 +2309,35 @@ PLOT_TEST(double_click_needs_both_timestamps_and_the_window) {
|
||||
f.controller.on_pointer({410.0f, 200.0f, 0, 0, plot::input::Button::Left, 0, false, false,
|
||||
true, 20.1});
|
||||
}
|
||||
|
||||
// A readout cursor is drawn but not offered to the pointer: the host that keeps it is showing
|
||||
// a computed value, and a drag on it would be a drag on a number rather than on an instrument.
|
||||
// The hit test is where that is enforced -- re-placing the marker every frame would only fight
|
||||
// the drag, which is the shape this replaces.
|
||||
PLOT_TEST(an_unselectable_cursor_is_neither_hovered_nor_grabbed) {
|
||||
plot::Plot p;
|
||||
p.set_viewport({0, 0, 800, 400});
|
||||
const u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const u32 readout = p.add_cursor(50.0, plot::Color::hex(0x00FF00));
|
||||
p.set_cursor_selectable(readout, false);
|
||||
PLOT_CHECK(p.cursor(readout).visible); // drawn all the same
|
||||
PLOT_CHECK(!p.cursor(readout).selectable);
|
||||
// A normal cursor right beside it stays grabbable, so the flag is a property of one cursor
|
||||
// and not a switch on the plot.
|
||||
const u32 normal = p.add_cursor(50.0, plot::Color::hex(0xFF0000));
|
||||
|
||||
plot::input::Controller c(p);
|
||||
c.set_viewport({0, 0, 800, 400});
|
||||
const f32 sx = screen_x_of(p, ax, 50.0);
|
||||
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, false, false, false});
|
||||
PLOT_CHECK(c.hovered_cursor() == normal); // the readout is not in the running
|
||||
|
||||
// Press and move: the pointer grabbed the *normal* cursor (the two are at the same x, so
|
||||
// the readout would otherwise be the nearer hit), and the readout's own x never moves.
|
||||
const f64 before = p.cursor(readout).x;
|
||||
c.on_pointer({sx, 200, 0, 0, plot::input::Button::Left, 0, true, true, false});
|
||||
PLOT_CHECK(c.grabbed_cursor() == normal);
|
||||
c.on_pointer({sx + 40, 200, 40, 0, plot::input::Button::Left, 0, true, false, false});
|
||||
PLOT_CHECK_NEAR(p.cursor(readout).x, before, 1e-12);
|
||||
PLOT_CHECK(c.grabbed_cursor() == normal);
|
||||
}
|
||||
@@ -2584,3 +2584,53 @@ PLOT_TEST(xy_series_measurement_uses_the_true_nearest_samples) {
|
||||
PLOT_CHECK_NEAR(stats.min, 10.0, 1e-9);
|
||||
PLOT_CHECK_NEAR(stats.max, 10.0, 1e-9);
|
||||
}
|
||||
|
||||
// A host whose annotation is a readout -- the point where a cursor crosses a trace --
|
||||
// moves it every frame. The id has to survive that, and a stale id must be ignored
|
||||
// rather than read or written out of bounds, exactly as the cursors' setters behave.
|
||||
// A readout cursor says which readout it belongs to with its colour; the setter keeps the
|
||||
// id, and a stale one changes nothing.
|
||||
PLOT_TEST(a_cursor_can_be_recoloured_by_id) {
|
||||
plot::Plot p;
|
||||
const u32 id = p.add_cursor(2.0, plot::Color::hex(0x111111));
|
||||
PLOT_CHECK(p.cursor(id).color.b == plot::Color::hex(0x111111).b);
|
||||
|
||||
p.set_cursor_color(id, plot::Color::hex(0x9BE8A0));
|
||||
PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g);
|
||||
PLOT_CHECK(p.cursor_count() == 1u); // recoloured, not added
|
||||
PLOT_CHECK_NEAR(p.cursor(id).x, 2.0, 1e-12);
|
||||
|
||||
p.set_cursor_color(42u, plot::Color::hex(0xFFFFFF));
|
||||
PLOT_CHECK(p.cursor(id).color.g == plot::Color::hex(0x9BE8A0).g);
|
||||
}
|
||||
|
||||
PLOT_TEST(an_annotation_can_be_moved_by_id) {
|
||||
plot::Plot p;
|
||||
const plot::Annotation pin{1.0, 2.0, "first", plot::Color::hex(0xE8E8E8), 0u, 0u, true};
|
||||
const u32 id = p.add_annotation(pin);
|
||||
PLOT_CHECK(p.annotation(id).x == 1.0);
|
||||
PLOT_CHECK(p.annotation(id).text == std::string("first"));
|
||||
|
||||
plot::Annotation moved = pin;
|
||||
moved.x = 3.5;
|
||||
moved.y = -1.25;
|
||||
moved.text = "moved";
|
||||
p.set_annotation(id, moved);
|
||||
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
|
||||
PLOT_CHECK_NEAR(p.annotation(id).y, -1.25, 1e-12);
|
||||
PLOT_CHECK(p.annotation(id).text == std::string("moved"));
|
||||
PLOT_CHECK(p.annotation_count() == 1u); // moved, not added
|
||||
|
||||
// Parked rather than removed, like a cursor: the id stays valid and reads back hidden.
|
||||
plot::Annotation parked = moved;
|
||||
parked.visible = false;
|
||||
p.set_annotation(id, parked);
|
||||
PLOT_CHECK(!p.annotation(id).visible);
|
||||
PLOT_CHECK(p.annotation_count() == 1u);
|
||||
|
||||
// A stale id changes nothing (and reads a default, as the accessors promise).
|
||||
plot::Annotation other = pin;
|
||||
other.x = 99.0;
|
||||
p.set_annotation(42u, other);
|
||||
PLOT_CHECK_NEAR(p.annotation(id).x, 3.5, 1e-12);
|
||||
}
|
||||
@@ -5971,3 +5971,82 @@ PLOT_TEST(release_gl_shuts_down_and_the_renderer_can_reinit) {
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
ctx.destroy();
|
||||
}
|
||||
|
||||
// The other orientation, in a figure: a horizontal ruler is per row (its `y` is on that
|
||||
// row's y axis, so two rows' rulers are two different values), and every row's has to be
|
||||
// drawn in its own band. The vertical case above covers the shared-time half; this is the
|
||||
// half where each row owns its instrument, which is what an app's "two rulers per plot"
|
||||
// needs -- and which the app found undrawn.
|
||||
PLOT_TEST(figure_rows_draw_their_own_horizontal_rulers) {
|
||||
plottest::HeadlessContext ctx(kW, kH);
|
||||
plot::Status st;
|
||||
PLOT_CHECK(ctx.create(&st));
|
||||
PLOT_CHECK(plot::gl::load(nullptr));
|
||||
plot::Renderer r;
|
||||
PLOT_CHECK(r.init(&st));
|
||||
|
||||
const std::vector<plot::f32> samples = wave(101u, 0.0f);
|
||||
plot::data::ArraySeries series(samples, 0.0, 1.0);
|
||||
|
||||
// A strongly red ruler and a strongly green one, so a pixel that is red- or
|
||||
// green-dominant inside a row's axes can only be that row's ink: the trace is blue and
|
||||
// the chrome grey (all three channels equal).
|
||||
const plot::Color red = plot::Color::hex(0xFF2020);
|
||||
const plot::Color green = plot::Color::hex(0x20FF20);
|
||||
const plot::f32 y_scales[3] = {671.0e6f, 90.0f, 2.51f};
|
||||
|
||||
// The harness's no-atlas backend: a ruler always writes its value as text, and this case
|
||||
// is about the *stroke*, so the text is asked for and staged as nothing. It goes on the
|
||||
// *renderer*, not on the plots: `draw(Figure&)` replaces each row's backend with the
|
||||
// renderer's own on the first frame, so a plot-level backend would be swapped out under a
|
||||
// batch that had already declared that backend's (absent) cell.
|
||||
OnePxPerStringBackend no_text;
|
||||
r.set_text_backend(&no_text);
|
||||
|
||||
plot::Figure figure(3u);
|
||||
figure.set_viewport({0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||
plot::Plot& p = figure.plot(row);
|
||||
const plot::u32 ax = p.add_x_axis({"t", {0.0, 100.0}, false, plot::AxisPlace::Bottom, false});
|
||||
const plot::u32 ay =
|
||||
p.add_y_axis({"v", {0.0, y_scales[row]}, false, plot::AxisPlace::Left, false});
|
||||
p.add_trace({&series, kRowBlue, 1.5f, ax, ay});
|
||||
// A quarter and three quarters up this row's own range: inside its axes whatever the
|
||||
// scale is, which is the point of a ruler being per row.
|
||||
p.add_h_ruler(0.25 * y_scales[row], red);
|
||||
p.add_h_ruler(0.75 * y_scales[row], green);
|
||||
}
|
||||
|
||||
r.begin_frame(kW, kH, {0, 0, static_cast<plot::f32>(kW), static_cast<plot::f32>(kH)});
|
||||
r.draw(figure);
|
||||
r.end_frame();
|
||||
PLOT_CHECK(!r.last_frame_had_gl_error());
|
||||
dump_if_requested(r, "figure_h_rulers.ppm");
|
||||
|
||||
const std::vector<plot::u8> frame = read_frame(ctx);
|
||||
// Rows of the frame that hold a fully covered red or green line, and which row's band
|
||||
// each falls in. The predicate demands full coverage exactly as the vertical case does.
|
||||
const auto lines_in = [&](const plot::Rect& area, bool want_red) {
|
||||
plot::u32 found = 0;
|
||||
for (plot::u32 y = static_cast<plot::u32>(area.y);
|
||||
y < static_cast<plot::u32>(area.bottom()); ++y) {
|
||||
plot::u32 run = 0;
|
||||
for (plot::u32 x = static_cast<plot::u32>(area.x);
|
||||
x < static_cast<plot::u32>(area.right()); ++x) {
|
||||
const plot::u8* px = &frame[(plot::usize(y) * ctx.width + x) * 4u];
|
||||
const bool ok = want_red ? (px[0] >= 200 && px[1] <= 80 && px[2] <= 80)
|
||||
: (px[1] >= 200 && px[0] <= 80 && px[2] <= 80);
|
||||
if (ok) ++run;
|
||||
}
|
||||
if (run > (area.w * 3u) / 4u) ++found;
|
||||
}
|
||||
return found;
|
||||
};
|
||||
const plot::FigureLayout layout = figure.layout();
|
||||
for (plot::u32 row = 0; row < 3u; ++row) {
|
||||
const plot::Rect& axes = figure.plot(row).layout().axes;
|
||||
PLOT_CHECK(lines_in(axes, true) >= 1u); // this row's own A ruler
|
||||
PLOT_CHECK(lines_in(axes, false) >= 1u); // and its B ruler
|
||||
}
|
||||
(void)layout;
|
||||
}
|
||||
Reference in new issue
Block a user