Compare commits

...
10 Commits
Author SHA1 Message Date
Martino Ferrari f132a5ec92 test(plot): pin the hot-handle re-index on region removal, refresh coverage
`remove_region()` now clears or decrements the published `hot_.region` with the
cache it re-indexes, so a frame after a removal highlights the region the handle
actually names. The new case covers both branches (a handle above the hole
follows it down, the removed region's handle is cleared with its zone), and the
coverage document is regenerated by the command that measured it: 95.2% of src/,
every file at or above 80%.
2026-09-28 22:42:56 +02:00
Martino Ferrari 1f5ce0d4a5 fix(plot,figure): re-index hot region, logical row floor, refuse live on a linked axis
Three smaller findings at the plot/figure seam:

* `Plot::remove_region()` re-indexed `region_stats_` but left `hot_.region`
  naming the old index, so a frame after a removal highlighted the wrong
  region's bound and an open drag edited a different region. The published
  handle (and its zone) is now cleared or decremented with the cache, and the
  contract documents that a host must not remove a region mid-drag.

* `Figure`'s `kMinRowHeightPx` was applied as a *device*-pixel floor while
  chrome is logical, so at 2x the guarantee was half as strong exactly where
  the chrome is twice as large. It is now a logical constant converted through
  the row's own `ui_scale()`, 1:1 for a non-positive scale as `Style::device()`
  does.

* `Figure::link_x()` refused a member whose axis 0 was `XFollow::Live`, but
  nothing refused `set_x_follow(0u, XFollow::Live)` *after* linking. The group
  then wrote its range into an axis that reinterpreted it against its own live
  edge, read it back as an external change, and tracked one member instead of
  the union. `Plot` now carries a linkage flag (`set_x_link_member()`), set by
  `link_x()` and cleared by `unlink_row()`, and `set_x_follow()` refuses `Live`
  on a linked axis.

Tests: arming `Live` on a linked member leaves it `Off` and unlinking restores
the ability to arm it; the row-height floor is 24 and 48 device pixels at 1x
and 2x respectively.
2026-09-28 22:36:37 +02:00
Martino Ferrari 2fbd1de7d4 fix(render,input): guard the zoom gain, clip annotation captions, no-throw dump
Three findings reachable by ordinary use:

* `Controller::axis_zoom()` divided by `Plot::ui_scale()` directly, though
  `Style::device()` documents a non-positive scale as host state that must be
  tolerated. With `ui_scale == 0` and a negative drag delta, `logical_dy` was
  `-inf`, the factor `+inf`, and the guard `!(factor > 0.0)` did not reject it:
  `zoom_x_axis`/`zoom_y_axis` computed `anchor + (bound - anchor) * inf`, and
  `set_y_range()` stored it verbatim and cleared `auto_scale` -- an axis left
  non-finite until an auto-fit. A negative scale silently inverted the zoom.
  A scale that is not positive and finite now means no zoom at all.

* `draw_annotations()` stages its caption into the frame's single text batch,
  which `flush_text()` draws *after* `clear_clip()`, so a caption staged while
  the axes clip was engaged was rasterised with the viewport clip. An
  annotation anchored outside the visible range kept a caption with no cross,
  and in a `Figure` -- whose rows tile the figure -- that caption painted into
  the neighbouring row. A caption whose anchor is outside `axes` is now skipped
  with its cross, rather than splitting the frame's one text draw call (which
  would cost a draw call per row and mis-clip the labels staged outside `axes`).

* `dump_frame_ppm()` was the one public entry point not `noexcept`, so a
  `std::bad_alloc` crossed the API boundary. Its buffers are now sized before
  anything is on disk and a failure is reported as `ErrorCode::OutOfMemory`,
  exactly as `TrueTypeFont::from_file()` reports it.

Tests: `ui_scale` 0 and -1 leave the axis range finite and unchanged with
`auto_scale` intact; an annotation anchored outside a bottom figure row's y
range leaves no caption ink in the row above, while an in-range one still
draws; a `static_assert` pins the dump's `noexcept`.
2026-09-28 22:36:34 +02:00
Martino Ferrari 9aff06acee fix(glfw): route a drag by the row it began in, and drop stale hover state
`controller_at()` routed every pointer event by the row the pixel was in,
ignoring the row a drag started in, so a press on one figure row followed by a
motion and release in another delivered the motion and release to a controller
whose `mode_ == None`. The owning controller kept `mode_`, `drag_region_`,
`drag_cursor_` and `drag_fixed_x_` set for ever: `publish_hot()` kept publishing
the grabbed handle, `grabbed_region()` reported a drag that had ended, and a
later gesture re-entering the row resumed the stale edit from the old anchor.

`feed_pointer_event()` now owns the routing: a press captures the row it landed
in and every motion and the release until the button comes up go to that row
(`pointer_target()`), cleared on release and when another figure is set. The
GLFW callbacks build the event and call it, so a windowless host or a test can
drive the routing too.

Two neighbours in the same seam:

* `sync_figure_crosshair()` called the four-argument overload unconditionally,
  so a documented vertical-only crosshair (`horizontal == false`) was promoted
  to a full cross at the stored y == 0, clamped to the axes edge and made
  permanent; the change detection also keyed on x alone, missing a y-only move
  to another row at the same x (two rows showing one horizontal line, spec 17).
  It now consults `crosshair().horizontal` and compares the hovered row's y.
* Only the row under the pointer republished its hot handles, so a row the
  pointer left kept drawing a hovered marker or bound as if it were still
  under the pointer, and answered `hovered_region()`/`grabbed_cursor()` for
  it. The run loop clears every other row's handles, keeping the captured
  row while a drag owns the button, and reads the frame's one layout once
  instead of recomputing every row five times.

Test: a press on row A's region, a motion into row B, a release in row B
asserts no controller keeps a drag mode, nothing reports a grabbed handle, and
a later press-and-motion that re-enters row A does not resume the old edit.
2026-09-28 22:36:30 +02:00
Martino Ferrari 81792dcd05 docs: correct three header comments that had drifted from the code 2026-09-28 22:17:55 +02:00
Martino Ferrari 040e40254d chore: remove a stray fragment committed by a shell redirect 2026-09-28 22:13:59 +02:00
Martino Ferrari ed3a8ce645 licence: MIT LICENSE and SPDX identifiers in every first-party file
The repository had no licence at all, so every source was all-rights-reserved
by default. Add the verbatim MIT text at LICENSE (Copyright (c) 2026 Martino
Ferrari) and a two-line SPDX header to each of the 113 first-party sources --
48 include/plot, 26 src, 10 adapters, 5 examples, 21 tests, 3 tools -- so the
grant travels with each file rather than relying on the root document alone.

Scripts keep their shebang on line 1; the SPDX lines follow it. Genuinely
vendored code is untouched: third_party/stb_truetype.h carries no header of
ours. src/render/embedded_mono_font.cpp is generated, so tools/fonts/gen_atlas.py
emit() now writes the two lines into its header comment and the file was
regenerated -- gen_atlas.py --check passes and the regeneration adds only those
comment lines to the previously committed file.

README's provenance section now states MIT and links LICENSE.

No code, test or public API change.
2026-09-28 22:13:40 +02:00
Martino Ferrari 059227d080 docs: README with build, embedding and verification entry points 2026-09-28 22:00:57 +02:00
Martino Ferrari 19c8d4368d docs(coverage): regenerate the measured table from the command that measures it 2026-09-28 22:00:57 +02:00
Martino Ferrari b7d0f901cc docs: a user guide covering getting started, architecture, embedding, data, interaction, styling and testing 2026-09-28 21:50:19 +02:00
124 changed files with 2344 additions and 117 deletions

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Martino Ferrari
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+174
View File
@@ -0,0 +1,174 @@
# plot
An embeddable OpenGL time-series plotting library for real-time, high-sample-rate data.
The core (`plot::core`, C++20, OpenGL 3.3 core) has no third-party dependency at all;
the host integrations that own a window and an event loop are separate, opt-in targets
built on top of it.
## Hosts, and what a frame costs
| host | CMake target | source |
|---|---|---|
| GLFW | `plot::glfw` | `adapters/glfw/` |
| Dear ImGui | `plot::imgui` (build-tree only) | `adapters/imgui/` |
| Qt6 | `plot::qt6` | `adapters/qt6/` |
| GTK4 | `plot::gtk4` | `adapters/gtk4/` |
Per-frame rendering cost is O(visible pixels + samples produced since the last frame),
not O(samples): a trace denser than one sample per pixel is drawn from a min/max LOD
pyramid rather than from raw samples. The `plot_multiplot_demo` run stages **18,651 GPU
segments** to draw **~4.5 million visible samples** across six traces — the frame is
bounded by the pixels it lights, not by the sample count behind them. That figure is from
a `linux-release` build under `xvfb-run` on **llvmpipe** (Mesa's software rasteriser); no
run touched a GPU, so read it as an upper bound and a record of the software path.
## Features
- grid with independent major/minor line classes (`Style::grid_major_visible`,
`grid_minor_visible`);
- x and y axes, with a vertical (bottom-to-top) y-axis title;
- cursors in both orientations — vertical markers and horizontal rulers;
- draggable regions and measurement bands with exact interval statistics;
- a signal legend anchored to an axes corner;
- a hover crosshair with a per-signal, interpolated readout;
- a multi-plot `Figure` of stacked rows sharing one synchronised x axis;
- live follow (`XFollow::Live`) with relative (multiplicative) zoom and mouse panning;
- DPI-consistent sizing: metrics are logical units converted once through
`Style::ui_scale`;
- an anti-aliased embedded monospaced font, plus an optional runtime TrueType backend
(`plot::font_ttf`).
## Build and install
Needs a C++20 compiler and CMake **3.24+**. Presets live in `CMakePresets.json`
(`linux-debug`, `linux-release`, `linux-asan`, `linux-coverage`).
```sh
cmake --preset linux-release
cmake --build build/release
cmake --install build/release --prefix /where/you/want
```
Install is on by default (`PLOT_INSTALL=ON`) and installs the `plot_core` archive, the
public headers under `include/plot/`, and a CMake package config under `lib/cmake/plot/`.
A consumer then needs only:
```cmake
find_package(plot 0.1 REQUIRED)
target_link_libraries(my_app PRIVATE plot::core)
```
## Embedding
A host owns the window, the GL context and the event loop; `plot` owns none of them. This
is the whole of a minimal GLFW program — the adapter opens the window, runs the loop and
feeds the controller, and the callback is where live data would be pushed:
```cpp
#include <glfw/glfw_host.hpp> // the optional GLFW adapter
#include <plot/plot.hpp>
#include <cmath>
#include <vector>
int main() {
plot::glfw::Host host;
plot::Plot& p = host.plot();
std::vector<plot::f32> y(512);
for (plot::usize i = 0; i < y.size(); ++i)
y[i] = static_cast<plot::f32>(std::sin(0.05 * static_cast<double>(i)));
plot::data::ArraySeries series(std::move(y), 0.0, 1.0);
const plot::u32 x = p.add_x_axis({"x", {0.0, 511.0}, true});
const plot::u32 sig = p.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
p.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x, sig, "sine"});
plot::Status st;
return host.run({800, 480, "plot"}, [](plot::Plot&, plot::f64) {}, &st) ? 0 : 1;
}
```
It compiles and links against a built tree (see [docs/getting-started.md](docs/getting-started.md)
for the same program written against `plot::core` directly, with no adapter):
```sh
g++ -std=c++20 -I include -I adapters readme_main.cpp \
build/debug/adapters/glfw/libplot_glfw.a build/debug/libplot_core.a \
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_readme
```
## Documentation
The user guide lives under `docs/`; `docs/superpowers/` is the design record, not user
documentation.
- [Getting started](docs/getting-started.md) — prerequisites, build/install/consume, and a
minimal GLFW program against `plot::core`.
- [Architecture](docs/architecture.md) — the layers (`core`/`math` → `gl` → `data` →
`render` → `plot` → `input`), what each owns, and the dependency rules.
- [Embedding into a host](docs/embedding.md) — the four adapters, the `ui_scale`/DPI rule
per host, and the headless demo invocations.
- [Data and performance](docs/data-and-performance.md) — the sample stores, the
single-producer/single-consumer contract, the LOD pyramid, and the measured frame times.
- [Interaction](docs/interaction.md) — cursors, regions, the measurement band, the legend,
the crosshair, the gesture table, and live follow.
- [Styling](docs/styling.md) — every `Style` field with its default and unit, the grid
classes, and installing a text backend.
- [Building and testing](docs/building-and-testing.md) — presets, `PLOT_BUILD_*` options,
the suite, the headless demo modes, and the coverage command.
- [Coverage](docs/coverage.md) — the coverage method and the measured per-file table.
## Testing and coverage
```sh
cmake --preset linux-debug
cmake --build build/debug
ctest --test-dir build/debug --output-on-failure # 283 cases, 0 failed
tools/coverage.sh # configure+build+run+capture+floor check
```
The `linux-debug` suite runs **283 cases**; the `linux-coverage` preset builds **272** of
them, because its adapters are off and 11 cases need an adapter target (8 in
`test_imgui_widget.cpp`, 3 in `test_glfw_host.cpp`). Coverage is enforced by
`tools/coverage.sh` against `tools/coverage_floor.py`: **80%** per file under `src/` and
**90%** aggregate. The current measurement is an aggregate **95.3% (3284/3447 lines)** over
23 files, every file at or above 80%; the per-file table is in
[docs/coverage.md](docs/coverage.md), which the command regenerates.
## Licence and third-party provenance
- **This library** is **MIT licensed**: the full text is in [`LICENSE`](LICENSE), and
every first-party file (C++, Python and shell) carries its SPDX identifier in the file
header — `// SPDX-License-Identifier: MIT` — so the grant travels with each source file,
not just the repository root.
- **Adwaita Mono** (the embedded font, `render::EmbeddedMonoFont`) is under **SIL OFL
1.1**, committed verbatim at [`licences/OFL-1.1.txt`](licences/OFL-1.1.txt). The backend
class is named neutrally (`EmbeddedMonoFont`, not "Adwaita Mono") because OFL reserves
the font name for modified redistributions. The baked atlas is generated at authoring
time by `tools/fonts/gen_atlas.py` and committed as data, so `plot_core` rasterises
nothing at build or run time.
- **stb_truetype v1.26** (public domain, Sean Barrett / RAD Game Tools) is vendored at
`third_party/stb_truetype.h` and compiled **only** by the optional `plot_font_ttf`
target; its include directory is `PRIVATE`, so no consumer and no other target can reach
it. `PLOT_BUILD_TTF_FONT` defaults on exactly when the vendored header is present.
- **`plot_core` is third-party-free.** It links only `${CMAKE_DL_LIBS}`, and no public
header under `include/plot/` includes a third-party header. The header self-containment
and umbrella checks in `tests/CMakeLists.txt` fail the build if either rule breaks.
## Limitations
- **Timings are llvmpipe under Xvfb** (`tools/coverage.sh`, the demo summaries). No
measurement here used a GPU; there are no hardware numbers.
- **macOS and Windows are untested.** Development and CI are Linux/EGL + X11 only.
- **Fractional-scale compositing and touchpad scroll calibration are unverified** — the
`ui_scale` sources are correct per host, but no run exercised a real fractional DPI or a
calibrated scroll device.
- **Untrusted font files are out of scope** for the runtime `plot::font_ttf` backend:
stb_truetype does no range checking, so `from_file()` must only be handed files the host
trusts.
- **`render` ↔ `plot` is not a strict include chain.** `Renderer` includes `plot/plot.hpp`
because drawing *is* being handed a `Plot`; the `plot` layer names only `render`'s value
types (`Style`, `TextBackend`), never `Renderer`. The cycle and its direction are
documented in [docs/architecture.md](docs/architecture.md).
+91 -9
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <glfw/glfw_host.hpp> #include <glfw/glfw_host.hpp>
// Before GLFW's header, so it does not include <GL/gl.h>: that header's macros // Before GLFW's header, so it does not include <GL/gl.h>: that header's macros
@@ -207,12 +210,13 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
// that hands each row its rect (and so its own `Plot` its viewport). The single // that hands each row its rect (and so its own `Plot` its viewport). The single
// controller is untouched -- each row has its own, see controller_at(). // controller is untouched -- each row has its own, see controller_at().
figure_->set_viewport(viewport); figure_->set_viewport(viewport);
figure_->layout();
// Every row's controller gets that row's rect, so a gesture -- routed by // Every row's controller gets that row's rect, so a gesture -- routed by
// controller_at() or injected by a scripted run -- is hit-tested against the // pointer_target() 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. The rows come off the one recomputing layout of this frame --
const std::vector<Rect>& rows = figure_->layout().rows; // the one layout of this frame // calling `layout()` again here, or in `controller_at()`, would lay every row out a
// second and a third time for the same frame.
const std::vector<Rect>& rows = figure_->layout().rows;
for (u32 i = 0; i < row_controllers_.size(); ++i) { for (u32 i = 0; i < row_controllers_.size(); ++i) {
if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]); if (i < rows.size()) row_controllers_[i]->set_viewport(rows[i]);
} }
@@ -222,6 +226,18 @@ bool Host::run(const RunConfig& cfg, const FrameFn& frame, Status* st) {
// 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(rows); sync_figure_crosshair(rows);
// Only the row the pointer's events go to ever republishes its own hot handles, so a row
// the pointer left keeps the `HotHandles` it last published and goes on drawing a hovered
// marker or region bound as if the pointer were still on it -- and its `hovered_region()`/
// `grabbed_cursor()` keep answering for it. Clear every *other* row here, in the run loop,
// where that row is known (the captured row while a drag owns the button, so an in-flight
// gesture keeps its highlight).
if (have_pointer_ && !rows.empty()) {
const u32 active = pointer_row(rows);
for (u32 i = 0; i < rows.size() && i < row_controllers_.size(); ++i) {
if (i != active) figure_->plot(i).set_hot_handles(HotHandles{});
}
}
} else { } else {
controller_.set_viewport(viewport); // also Plot::set_viewport, then layout() controller_.set_viewport(viewport); // also Plot::set_viewport, then layout()
} }
@@ -337,7 +353,7 @@ void Host::on_cursor_pos(f64 wx, f64 wy) noexcept {
have_pointer_ = true; have_pointer_ = true;
e.mods = mods_now(window_); e.mods = mods_now(window_);
e.down = any_button_down(window_); e.down = any_button_down(window_);
controller_at(e.x, e.y).on_pointer(e); feed_pointer_event(e);
} }
void Host::on_mouse_button(int button, int action, int mods) noexcept { void Host::on_mouse_button(int button, int action, int mods) noexcept {
@@ -364,7 +380,7 @@ void Host::on_mouse_button(int button, int action, int mods) noexcept {
e.pressed = action == GLFW_PRESS; e.pressed = action == GLFW_PRESS;
e.released = action == GLFW_RELEASE; e.released = action == GLFW_RELEASE;
e.down = any_button_down(window_); e.down = any_button_down(window_);
controller_at(e.x, e.y).on_pointer(e); feed_pointer_event(e);
} }
void Host::on_scroll(f64 dx, f64 dy) noexcept { void Host::on_scroll(f64 dx, f64 dy) noexcept {
@@ -415,8 +431,13 @@ void Host::set_figure(Figure* figure) noexcept {
last_row_ = 0u; last_row_ = 0u;
// A new figure holds no crosshair this host has spread: the comparison in // 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. // `sync_figure_crosshair()` starts from "nothing spread", which is the state its rows are in.
// Nor does it hold a captured drag row: the controllers are rebuilt below, so the old
// capture names a controller that no longer exists.
spread_active_ = false; spread_active_ = false;
spread_x_ = 0.0; spread_x_ = 0.0;
spread_y_ = 0.0;
captured_ = false;
captured_row_ = 0u;
if (figure_ == nullptr) return; if (figure_ == nullptr) return;
const u32 rows = figure_->row_count(); const u32 rows = figure_->row_count();
row_controllers_.reserve(rows); row_controllers_.reserve(rows);
@@ -446,6 +467,49 @@ input::Controller& Host::controller_at(f32 x, f32 y) noexcept {
return controller; return controller;
} }
void Host::feed_pointer_event(const input::PointerEvent& e) noexcept {
if (figure_ == nullptr) {
controller_.on_pointer(e);
return;
}
pointer_target(e.x, e.y, e.pressed, e.released).on_pointer(e);
}
input::Controller& Host::pointer_target(f32 x, f32 y, 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<Rect>& rows = figure_->layout().rows;
if (rows.empty() || rows.size() != row_controllers_.size()) return controller_;
u32 row = 0u;
if (captured_) {
// A button-down gesture belongs to the row it began in for its whole life: a drag
// that crosses into a neighbouring row must not hand its motion -- or its release --
// to a controller that never saw the press, which would leave the owner's drag open
// for ever (and let a later gesture resume it from the old anchor).
row = captured_row_;
if (released) captured_ = false;
} else {
row = row_in(rows, y);
if (pressed) {
captured_ = true;
captured_row_ = row;
}
}
// The captured row is re-read against this frame's rects: the figure can be resized
// mid-drag, and the guard keeps a stale index from reaching past the controller list.
if (row >= row_controllers_.size()) row = 0u;
last_row_ = row;
input::Controller& controller = *row_controllers_[row];
controller.set_viewport(rows[row]);
return controller;
}
u32 Host::pointer_row(const std::vector<Rect>& rows) noexcept {
if (captured_ && captured_row_ < rows.size()) return captured_row_;
return row_in(rows, last_pointer_y_);
}
u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept { u32 Host::row_in(const std::vector<Rect>& rows, f32 y) noexcept {
if (rows.empty()) return 0u; 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 // The row the pixel is in, or -- when it is in none of them, which is the row gap a
@@ -482,11 +546,18 @@ void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
// keeps it. // keeps it.
u32 hovered = row_count; u32 hovered = row_count;
if (have_pointer_) { if (have_pointer_) {
hovered = row_in(rows, last_pointer_y_); // The row the pointer's events currently go to -- the captured row while a drag owns
// the button, the hovered row otherwise -- so the hover follows the gesture it began.
hovered = pointer_row(rows);
} else { } else {
for (u32 i = 0; i < row_count; ++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_)) { // A row differs when its active state, its x, or the y of *its own* horizontal line
// differs from what was spread: a pointer that moves to another row at the same x is
// a new hover and must move the one horizontal line, not leave the old row's behind.
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; hovered = i;
break; break;
} }
@@ -502,8 +573,19 @@ void Host::sync_figure_crosshair(const std::vector<Rect>& rows) noexcept {
// figure's, so the rows cannot be left showing a hover the pointer has abandoned. // figure's, so the rows cannot be left showing a hover the pointer has abandoned.
const Plot& row = figure_->plot(hovered); const Plot& row = figure_->plot(hovered);
if (row.crosshair().active) { if (row.crosshair().active) {
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true); if (row.crosshair().horizontal) {
// The row carries its own y (its controller placed both coordinates), so the figure's
// four-argument overload gives the pointer row the one horizontal line.
figure_->set_crosshair(row.crosshair().x, hovered, row.crosshair().y, true);
} else {
// The row's crosshair is vertical-only -- a caller placed it through the
// single-coordinate `Figure::set_crosshair()`, which is documented vertical-only. The
// four-argument overload would turn the horizontal line on at the stored (meaningless)
// y == 0, clamped to the axes edge and made permanent. Promote only the vertical line.
figure_->set_crosshair(row.crosshair().x, true);
}
spread_x_ = row.crosshair().x; spread_x_ = row.crosshair().x;
spread_y_ = row.crosshair().y;
spread_active_ = true; spread_active_ = true;
} else { } else {
figure_->clear_crosshair(); figure_->clear_crosshair();
-13
View File
@@ -1,13 +0,0 @@
e.mods = static_cast<u32>(mods);
// GLFW reports auto-repeat as its own action; a held key keeps acting, exactly
// as holding the key on a keyboard does.
e.down = action != GLFW_RELEASE;
// A key has no position of its own: it goes to the row the pointer was last over,
// which is the row the user is looking at.
input::Controller& controller =
(figure_ == nullptr || last_row_ >= row_controllers_.size()) ? controller_
: *row_controllers_[last_row_];
controller.on_key(e);
}
} // namespace plot::glfw
+43 -4
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <functional> #include <functional>
@@ -132,11 +135,25 @@ class Host {
/// that leaves the data area still ends where it should. /// 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 /// 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 event callbacks /// hit-test geometry in step with the layout between two frames. It is the pure
/// route through it, and so can a host that wants to know where a synthetic gesture /// query -- what a gesture *would* land on -- and it does not move the capture
/// would land. /// below; the GLFW callbacks and `feed_pointer_event()` are the routing.
input::Controller& controller_at(f32 x, f32 y) noexcept; input::Controller& controller_at(f32 x, f32 y) noexcept;
/// Feeds one pointer event, stated in framebuffer pixels, through the host's own
/// routing and into the controller it belongs to. The GLFW callbacks fill the
/// position and the button transitions out of GLFW and call exactly this, so a host
/// or a test with no window can drive the routing directly.
///
/// The routing is the row the event lands in, with one exception that is the whole
/// point of it: a press **captures** the row it landed in, and every motion and the
/// release until that button comes up go to the captured row. A drag that crosses
/// into a neighbouring figure row is therefore still the drag it began as -- the
/// owning controller sees the motion and the release that end it -- rather than
/// being handed to a controller whose `mode_ == None`, which would leave the owner's
/// drag open for ever and let a later gesture resume it from the old anchor.
void feed_pointer_event(const input::PointerEvent& e) noexcept;
/// The gesture controller the host feeds. Valid for the host's whole life. /// The gesture controller the host feeds. Valid for the host's whole life.
input::Controller& controller() noexcept { return controller_; } input::Controller& controller() noexcept { return controller_; }
@@ -180,6 +197,19 @@ class Host {
/// that has just laid the figure out does not make it lay out again for this. /// 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; static u32 row_in(const std::vector<Rect>& rows, 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_`) and every motion and the
/// release until the button comes up are routed to it; `controller_at()` is the pure
/// query that never captures. Also hands the target its row's viewport, so the
/// hit-test geometry is the last frame's.
input::Controller& pointer_target(f32 x, f32 y, 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.
u32 pointer_row(const std::vector<Rect>& rows) noexcept;
/// Spreads the hover over a whole `Figure` (spec §15). /// 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 /// A figure's rows share one x, so the crosshair one row's controller placed is read back here and
@@ -217,10 +247,19 @@ class Host {
/// `sync_figure_crosshair()`): what a row is compared against to find the one a /// `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. /// scripted run touched, since the figure writes one value to every row.
/// `spread_active_` false means "spread nothing" -- the state before the first /// `spread_active_` false means "spread nothing" -- the state before the first
/// sync, and the one a clear leaves. /// sync, and the one a clear leaves. `spread_y_` is the horizontal line's own
/// value, compared as well so a y-only change -- the same x in another row --
/// is not mistaken for "nothing changed".
f64 spread_x_ = 0.0; f64 spread_x_ = 0.0;
f64 spread_y_ = 0.0;
bool spread_active_ = false; bool spread_active_ = false;
/// The row a button-down drag has captured: the row its press landed in, which
/// owns its motions and its release until the button comes up. `captured_` is
/// false between gestures, and cleared when another figure is set.
bool captured_ = false;
u32 captured_row_ = 0u;
u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes() u32 fb_width_ = 0; ///< framebuffer size of the last refresh_sizes()
u32 fb_height_ = 0; u32 fb_height_ = 0;
/// Framebuffer pixels per window pixel. Taken from the two sizes GLFW reports /// Framebuffer pixels per window pixel. Taken from the two sizes GLFW reports
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/rect.hpp> #include <plot/core/rect.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <gtk4/gtk_plot_area.hpp> #include <gtk4/gtk_plot_area.hpp>
#include <gtk4/gtk_input.hpp> #include <gtk4/gtk_input.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <functional> #include <functional>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <imgui/imgui_plot.hpp> #include <imgui/imgui_plot.hpp>
#include <imgui.h> #include <imgui.h>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <optional> #include <optional>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <qt6/qt_plot_widget.hpp> #include <qt6/qt_plot_widget.hpp>
#include <qt6/qt_pointer.hpp> #include <qt6/qt_pointer.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
// The plot headers come first, and must. Qt's <QOpenGLWidget> reaches // The plot headers come first, and must. Qt's <QOpenGLWidget> reaches
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+68
View File
@@ -0,0 +1,68 @@
# Architecture
`plot` is one library, `plot_core`, plus optional adapter targets. The public headers are
grouped into seven directories, and the umbrella header `include/plot/plot.hpp` includes
them in this order:
```
core / math → gl → data → render → plot → input
```
with the host adapters (`adapters/glfw`, `adapters/imgui`, `adapters/qt6`, `adapters/gtk4`)
built on top. This page says what each layer owns and the rules the build enforces.
## What each layer owns
| layer | directory | owns |
|---|---|---|
| `core` | `include/plot/core/`, `src/core/` | the vocabulary: `f32`/`f64`/`u32`/`usize`, `kInvalid`, `Range`, `Rect`, `Color`, `Span`, and `Status`/`ErrorCode` for fallible operations. |
| `math` | `include/plot/math/`, `src/math/` | `Vec2`, `Mat3`, and `Transform` — the data↔pixel affine mapping, including the log-axis handling and its invertibility guarantee. |
| `gl` | `include/plot/gl/`, `src/gl/` | a hand-written OpenGL surface: enumerator typedefs, the entry-point table `plot::gl::load()` fills, `plot::gl::caps()`, and the move-only RAII wrappers (`gl::Buffer`, `VertexArray`, `Program`, `Texture`, `Framebuffer`, `Fence`). No GL loader library is used. |
| `data` | `include/plot/data/`, `src/data/` | the sample stores: the `Series` interface, `ArraySeries`, `StreamSeries`, `RingBuffer`, and the min/max LOD `MinMaxPyramid`. |
| `render` | `include/plot/render/`, `src/render/` | presentation: `Style`, the `Renderer`, the text machinery (`TextBackend`, `TextAtlas`, `EmbeddedMonoFont`), and the shader/program/buffer/vertex-array primitives. |
| `plot` | `include/plot/plot/`, `src/plot/` | the scene model: `Plot`, `Figure`, and the primitives they own — `Axis`, `Trace`, `Cursor`, `Region`, `Measure`, `Legend`, `Crosshair`, `Annotation`, `Grid`, `Ticks`. Pure CPU state, no GL. |
| `input` | `include/plot/input/`, `src/input/` | `Controller` and the host-neutral event types (`PointerEvent`, `WheelEvent`, `KeyEvent`); turns events into gestures. |
| adapters | `adapters/*/` | one per host: they own the window/context/event loop and translate their toolkit's events into `input` events. |
## The dependency rules
- **`plot_core` is third-party-free.** Its `CMakeLists.txt` links only
`${CMAKE_DL_LIBS}`, and the public headers under `include/plot/` include no third-party
header. The one exception is the optional `plot::font_ttf` target, which compiles the
vendored `third_party/stb_truetype.h` (stb_truetype v1.26, public domain) in its own
object library `plot_stb_truetype`, whose include directory is `PRIVATE` — so nothing
else in the build, and no consumer, can reach it by accident. `PLOT_BUILD_TTF_FONT`
defaults to on exactly when the vendored header is present.
- **Every public header compiles standalone, and `plot.hpp` includes every one of them.**
`tests/CMakeLists.txt` generates one translation unit per header (globbed, so a new
header is picked up automatically), builds an umbrella TU, and registers ctest cases
(`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`,
`plot_umbrella_check`) that fail the build if either rule breaks.
- **The umbrella stops at the library.** It never includes an adapter header, so
`#include <plot/plot.hpp>` cannot drag in GLFW, ImGui, Qt or GTK. The adapter headers are
reached as `<glfw/glfw_host.hpp>`, `<imgui/imgui_plot.hpp>`, `<qt6/qt_plot_widget.hpp>`
and `<gtk4/gtk_plot_area.hpp>`.
- **The renderer never owns the context or the event loop.** `Renderer` never creates,
binds or resizes a framebuffer: `begin_frame(fb_w, fb_h, viewport_px)` draws into
whatever framebuffer is bound, clears exactly the given rectangle (scissored, so a host
that painted the rest of its surface keeps it), and `end_frame()` turns the GL state it
moved back off. The renderer also never sees a pointer, a button or a modifier — it draws
the handles the plot reports as hot, and only a host that drives the plot with an
`input::Controller` ever makes anything hot. Owning the window, the context and the loop
is the adapter's job (or, for a library user, the application's).
One direction is worth stating precisely, because it is the one place the layer order is
not a strict include chain: `Renderer` (in `render/`) includes `plot/plot.hpp` and
`plot/figure.hpp`, because drawing *is* being handed a `Plot` or a `Figure` to draw. The
`plot` layer names only `render`'s value types — `render::Style` and
`render::TextBackend` — and never names `Renderer`. So the runtime direction is
"the renderer knows the plot, the plot does not know the renderer".
## The design record
The specifications and implementation plans — the requirements, the numbered spec sections
this documentation sometimes refers to, and the reasoning behind decisions — live under
[`docs/superpowers/`](superpowers/) (`specs/` and `plans/`). That tree is the project's
design record, not user documentation: it is where a decision is argued, not where the
library is explained. This `docs/` guide and [docs/coverage.md](coverage.md) are the
user-facing documents.
+109
View File
@@ -0,0 +1,109 @@
# Building and testing
## Presets and options
Four configure/build presets live in `CMakePresets.json`:
| preset | binaryDir | what it builds |
|---|---|---|
| `linux-debug` | `build/debug` | Debug; tests, examples and all four adapters |
| `linux-release` | `build/release` | Release; `PLOT_BUILD_TESTS=OFF` (so the installed `libplot_core.a` carries none of the test-only counter instrumentation); examples and all four adapters |
| `linux-asan` | `build/asan` | Debug with `PLOT_SANITIZE=address`; tests and all four adapters |
| `linux-coverage` | `build/cov` | the instrumented tree behind `tools/coverage.sh`: `--coverage -O0 -g`, `PLOT_COVERAGE=ON`, tests on, adapters and examples off |
The options (`cmake/plot_options.cmake`) and their defaults:
| option | default | meaning |
|---|---|---|
| `PLOT_BUILD_TESTS` | `ON` | build the test suite (needs EGL) |
| `PLOT_BUILD_EXAMPLES` | `OFF` | build the demos |
| `PLOT_BUILD_GLFW` | `OFF` | build the GLFW adapter and its demo |
| `PLOT_BUILD_IMGUI` | `OFF` | build the Dear ImGui adapter and its demo (ImGui is fetched at configure time) |
| `PLOT_BUILD_QT6` | `OFF` | build the Qt6 adapter and its demo |
| `PLOT_BUILD_GTK4` | `OFF` | build the GTK4 adapter and its demo |
| `PLOT_BUILD_TTF_FONT` | on iff `third_party/stb_truetype.h` exists | build the optional `plot::text::TrueTypeFont` backend |
| `PLOT_INSTALL` | `ON` | generate install/export rules for `find_package(plot)` |
| `PLOT_SANITIZE` | `""` | sanitizer list for `-fsanitize=` (e.g. `address`) |
| `PLOT_COVERAGE` | `OFF` | add the `coverage` target (requires an instrumented tree) |
Adapters and examples are off by default, so the core library and its tests build on a bare
toolchain. Each example is guarded by the adapter target it needs, so
`-DPLOT_BUILD_EXAMPLES=ON` without the matching adapter is a no-op rather than a configure
error.
## Running the suite
```sh
cmake --preset linux-debug
cmake --build build/debug
ctest --test-dir build/debug --output-on-failure
```
ctest registers four tests: `plot_tests` (the test binary), the two header/umbrella build
checks (`plot_headers_self_contained`, `plot_umbrella_covers_public_headers`) and
`plot_umbrella_check`. The `plot_tests` binary itself runs **283 cases** in the `linux-debug`
preset; its harness prints one line per case and a final `283 cases, 0 failed` summary line.
The case counts per file are the `PLOT_TEST(...)` declarations under `tests/`.
## Headless demo modes
Each demo supports two environment variables, implemented once and reused:
- `PLOT_DEMO_EXIT_AFTER_MS=<n>` — stop the loop after `n` milliseconds and return. Unset,
unparsable or not positive means "run until the window is closed". A run with a deadline
creates its window hidden, so an automated run never flashes a window on a real display,
and its frame times are not throttled by a vblank.
- `PLOT_DEMO_SCREENSHOT=<path>` — on that final frame, write it as a binary PPM before the
swap. The GLFW host writes the framebuffer with `Renderer::dump_frame_ppm()`; the ImGui
demo writes the whole framebuffer, while Qt and GTK write their widget's/area's own
framebuffer (their toolkits' chrome lives outside it).
That pair is what lets CI run the GUI demos under `xvfb-run` with no display access and no
screen capture (the invocations are in [embedding.md](embedding.md)).
Three demos add env-gated **self-checking** modes. These are verification affordances for
the project's own testing, driven through the real host and its own controllers — they are
not a second implementation of anything, and they exit non-zero if a measured property fails:
| variable | demo | what it drives |
|---|---|---|
| `PLOT_DEMO_FOLLOW=1` | `plot_glfw_demo` | the live-follow sequence: three wheel notches and a pan on a `XFollow::Live` axis, printing the range width, the distance behind the newest sample and the follow state at each step |
| `PLOT_DEMO_REGIONS` (`=hot` to hover a bound) | `plot_glfw_demo` | the direct-manipulation sequence: freeze the view, place a region, pan 100 px with the press outside it, and check the region came along with its width untouched |
| `PLOT_DEMO_SYNC=1` | `plot_multiplot_demo` | the synced-follow check: the three rows hold one range and its right edge keeps tracking the newest sample through a zoom and a pan on different rows |
| `PLOT_DEMO_CROSSHAIR=<row>` | `plot_multiplot_demo` | parks the pointer in a row each frame so a headless screenshot shows the one horizontal line the hovered row draws |
(`plot_glfw_demo` also accepts `PLOT_DEMO_CROSSHAIR=1`, which parks the pointer at the centre
of the data area so the two-line crosshair has ink to measure.)
## Coverage
```sh
tools/coverage.sh # from the repository root
cmake --build build/cov --target coverage # the same thing, through CMake
```
The script configures `build/cov` from the `linux-coverage` preset, builds it, clears the
`.gcda` counters, runs the whole suite, captures `src/**` with `lcov`, renders an HTML report
to `build/cov/html/index.html`, prints the per-file table, and **exits non-zero when a floor
is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`, `genhtml` and `python3`, and exits
`2` with a message when one is missing.
The floors are enforced by `tools/coverage_floor.py`:
| scope | floor |
|---|---|
| every file under `src/` | **80%** of its instrumented lines |
| `src/` as a whole | **90%** of instrumented lines |
Branch coverage is deliberately off. The current measurement (from `build/cov/coverage.info`,
re-read with `python3 tools/coverage_floor.py build/cov/coverage.info`) is an **aggregate
95.3% (3284/3447 lines)** over 23 files, every file at or above 80% — the floor is satisfied.
The method, the full per-file table and the reproducibility notes are in
[docs/coverage.md](coverage.md); re-run the command for the live numbers.
**One thing to know about the coverage preset:** it builds **272 of the 283 cases**. The 11
that are missing are the adapter cases that need an adapter target to exist — 8 in
`test_imgui_widget.cpp` and 3 in `test_glfw_host.cpp` — and the coverage preset turns the
adapters off. The toolkit-free adapter seams still run there: `test_qt_pointer.cpp` (2 cases)
and `test_gtk_input.cpp` (8 cases) compile without Qt or GTK. So the coverage number measures
the library plus its unit suite, not the adapter bodies (which the headless demo runs cover).
+47 -52
View File
@@ -3,6 +3,13 @@
`plot` measures line coverage of the compiled library bodies under `src/` and enforces a floor on `plot` measures line coverage of the compiled library bodies under `src/` and enforces a floor on
it, so a regression fails a command instead of quietly lowering a number in a document. it, so a regression fails a command instead of quietly lowering a number in a document.
**The table and the floor verdict below are generated.** `tools/coverage.sh` rewrites the region
between `<!-- BEGIN GENERATED: tools/coverage.sh -->` and
`<!-- END GENERATED: tools/coverage.sh -->` from the report that run just captured, by calling
`tools/coverage_floor.py --write-doc`. Do not edit inside those markers: the numbers there come
from the command, not from a hand-typed copy, and a run on an unchanged tree rewrites the region
with identical content.
## The one command ## The one command
```sh ```sh
@@ -12,12 +19,10 @@ cmake --build build/cov --target coverage # the same thing, through CMake
The script configures `build/cov` from the `linux-coverage` preset, builds it, runs the whole The script configures `build/cov` from the `linux-coverage` preset, builds it, runs the whole
`ctest` suite, captures `src/**` with `lcov` into `build/cov/coverage.info`, renders the HTML report `ctest` suite, captures `src/**` with `lcov` into `build/cov/coverage.info`, renders the HTML report
to `build/cov/html/index.html`, prints the per-file table below, and **exits non-zero when a floor to `build/cov/html/index.html`, prints the per-file table, rewrites the generated region of this
is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`, `genhtml` and `python3` file, and **exits non-zero when a floor is violated**. It needs `cmake`, `ninja`, `ctest`, `lcov`,
(Arch: `pacman -S cmake ninja lcov python`); it exits `2` with a message when one is missing. `genhtml` and `python3` (Arch: `pacman -S cmake ninja lcov python`); it exits `2` with a message
when one is missing.
A clean run of it — no build tree, all 26 translation units under `src/` compiled, 274 cases — takes
about two minutes here, of which the suite is ~107 s.
## The floors ## The floors
@@ -35,45 +40,40 @@ about two minutes here, of which the suite is ~107 s.
## The measured table ## The measured table
Measured on 2026-09-28 with GCC 16.2.1 from a clean `build/cov`, over the full test suite. <!-- BEGIN GENERATED: tools/coverage.sh -->
`coverage.sh` prints exactly this table on every run.
```
coverage_floor: ok -- 23 files under src/, aggregate 95.2% lines (3302/3469), every file at or above 80%, aggregate target 90%
```
<!-- BEGIN GENERATED TABLE (tools/coverage_floor.py --table --format markdown) -->
| File | Lines | Line rate | Functions | | File | Lines | Line rate | Functions |
| --- | ---: | ---: | ---: | | --- | ---: | ---: | ---: |
| `src/data/array_series.cpp` | 43/43 | 100.0% | 100.0% | | `src/data/array_series.cpp` | 43/43 | 100.0% | 100.0% |
| `src/data/minmax_pyramid.cpp` | 163/163 | 100.0% | 100.0% | | `src/data/minmax_pyramid.cpp` | 163/163 | 100.0% | 100.0% |
| `src/data/series.cpp` | 105/107 | 98.1% | 100.0% | | `src/data/series.cpp` | 105/107 | 98.1% | 100.0% |
| `src/data/stream_series.cpp` | 39/39 | 100.0% | 100.0% | | `src/data/stream_series.cpp` | 39/39 | 100.0% | 100.0% |
| `src/gl/handle.cpp` | 214/256 | 83.6% | 100.0% | | `src/gl/handle.cpp` | 256/256 | 100.0% | 100.0% |
| `src/gl/loader.cpp` | 53/81 | 65.4% | 77.8% | | `src/gl/loader.cpp` | 81/81 | 100.0% | 100.0% |
| `src/input/controller.cpp` | 327/341 | 95.9% | 100.0% | | `src/input/controller.cpp` | 329/343 | 95.9% | 100.0% |
| `src/math/transform.cpp` | 28/28 | 100.0% | 100.0% | | `src/math/transform.cpp` | 28/28 | 100.0% | 100.0% |
| `src/plot/axis.cpp` | 12/12 | 100.0% | 100.0% | | `src/plot/axis.cpp` | 12/12 | 100.0% | 100.0% |
| `src/plot/figure.cpp` | 213/218 | 97.7% | 95.5% | | `src/plot/figure.cpp` | 218/223 | 97.8% | 95.5% |
| `src/plot/grid.cpp` | 15/16 | 93.8% | 100.0% | | `src/plot/grid.cpp` | 15/16 | 93.8% | 100.0% |
| `src/plot/measure.cpp` | 21/21 | 100.0% | 100.0% | | `src/plot/measure.cpp` | 21/21 | 100.0% | 100.0% |
| `src/plot/plot.cpp` | 733/753 | 97.3% | 98.9% | | `src/plot/plot.cpp` | 743/763 | 97.4% | 98.9% |
| `src/plot/ticks.cpp` | 93/99 | 93.9% | 100.0% | | `src/plot/ticks.cpp` | 93/99 | 93.9% | 100.0% |
| `src/render/buffer.cpp` | 87/98 | 88.8% | 100.0% | | `src/render/buffer.cpp` | 88/98 | 89.8% | 100.0% |
| `src/render/embedded_mono_font.cpp` | 45/45 | 100.0% | 100.0% | | `src/render/embedded_mono_font.cpp` | 45/45 | 100.0% | 100.0% |
| `src/render/primitives.cpp` | 71/75 | 94.7% | 100.0% | | `src/render/primitives.cpp` | 71/75 | 94.7% | 100.0% |
| `src/render/program.cpp` | 26/27 | 96.3% | 100.0% | | `src/render/program.cpp` | 26/27 | 96.3% | 100.0% |
| `src/render/renderer.cpp` | 529/604 | 87.6% | 93.7% | | `src/render/renderer.cpp` | 530/609 | 87.0% | 93.7% |
| `src/render/text.cpp` | 27/30 | 90.0% | 100.0% | | `src/render/text.cpp` | 27/30 | 90.0% | 100.0% |
| `src/render/text_atlas.cpp` | 40/41 | 97.6% | 100.0% | | `src/render/text_atlas.cpp` | 40/41 | 97.6% | 100.0% |
| `src/render/vertex_array.cpp` | 49/59 | 83.1% | 100.0% | | `src/render/vertex_array.cpp` | 59/59 | 100.0% | 100.0% |
| `src/text/true_type_font.cpp` | 270/291 | 92.8% | 96.8% | | `src/text/true_type_font.cpp` | 270/291 | 92.8% | 96.8% |
| **src/ aggregate** | **3203/3447** | **92.9%** | **97.8%** | | **src/ aggregate** | **3302/3469** | **95.2%** | **98.3%** |
<!-- END GENERATED TABLE -->
**The floor currently fails, and that is the point.** `src/gl/loader.cpp` sits at 65.4% lines <!-- END GENERATED: tools/coverage.sh -->
(53/81) — below the 80% per-file floor — so `tools/coverage.sh` exits `1` on today's tree. What is
uncovered there is the loader's error paths and its extension scanner; `src/gl/handle.cpp` (83.6%)
and `src/render/vertex_array.cpp` (83.1%) are the same kind of gap, just above the line. Those tests
are Task 2 of `docs/superpowers/plans/2026-09-28-plot-docs-and-coverage.md`; the floor went in first
so the fix has something to satisfy rather than something to negotiate with, and so the *next*
regression in that area is a failing command rather than a number nobody reads.
## What the report measures ## What the report measures
@@ -118,40 +118,35 @@ before `genhtml` re-creates it.
per-file `LF`/`LH`/`FNF`/`FNH` tallies that are identical for all 23 files — verified by diffing the per-file `LF`/`LH`/`FNF`/`FNH` tallies that are identical for all 23 files — verified by diffing the
two tracefiles — but the per-line hit *counts* differ by a few parts per million, because a handful two tracefiles — but the per-line hit *counts* differ by a few parts per million, because a handful
of tests drive a producer thread whose iteration count follows wall-clock timing. Everything the of tests drive a producer thread whose iteration count follows wall-clock timing. Everything the
floor reads (the `LF`/`LH` tallies, and therefore every rate in the table above) is unaffected. floor reads (the `LF`/`LH` tallies, and therefore every rate in the generated table above) is
unaffected.
## Adjusting the floors, and seeing the gate fail ## Adjusting the floors, and seeing the gate fail
Both floors are flags, which is how the failure path is demonstrated — and how a later task tightens Both floors are flags, which is how the failure path is demonstrated — and how a later task tightens
the contract. Against `build/cov/coverage.info` from the table above: the contract. Against the report in the generated table above, a floor raised above every file's
rate fails the check and names each offender:
```sh ```sh
$ python3 tools/coverage_floor.py build/cov/coverage.info # the real floors # Any file not at 100% fails: a floor that is guaranteed to bite, whatever the
coverage_floor: FAILED -- 1 floor(s) violated in build/cov/coverage.info: # current rates are. The list of offenders is the files in the table above whose
src/gl/loader.cpp: 65.4% lines (53/81) is below the 80% per-file floor # rate is below 100%.
$ echo $? python3 tools/coverage_floor.py --per-file 100 build/cov/coverage.info
1 # coverage_floor: FAILED -- <n> floor(s) violated in build/cov/coverage.info:
# <file>: <rate>% lines (<hit>/<found>) is below the 100% per-file floor
# ...
# $? == 1
$ python3 tools/coverage_floor.py --per-file 95 build/cov/coverage.info # floor raised above rates # The same, for the aggregate: a target above the measured aggregate fails with one line.
coverage_floor: FAILED -- 10 floor(s) violated in build/cov/coverage.info: python3 tools/coverage_floor.py --aggregate 99 build/cov/coverage.info
src/gl/handle.cpp: 83.6% lines (214/256) is below the 95% per-file floor # coverage_floor: FAILED -- 1 floor(s) violated in build/cov/coverage.info:
src/gl/loader.cpp: 65.4% lines (53/81) is below the 95% per-file floor # src/ aggregate: <rate>% lines (<hit>/<found>) is below the 99% aggregate floor
src/plot/grid.cpp: 93.8% lines (15/16) is below the 95% per-file floor # $? == 1
src/plot/ticks.cpp: 93.9% lines (93/99) is below the 95% per-file floor
src/render/buffer.cpp: 88.8% lines (87/98) is below the 95% per-file floor
src/render/primitives.cpp: 94.7% lines (71/75) is below the 95% per-file floor
src/render/renderer.cpp: 87.6% lines (529/604) is below the 95% per-file floor
src/render/text.cpp: 90.0% lines (27/30) is below the 95% per-file floor
src/render/vertex_array.cpp: 83.1% lines (49/59) is below the 95% per-file floor
src/text/true_type_font.cpp: 92.8% lines (270/291) is below the 95% per-file floor
$ echo $?
1
$ python3 tools/coverage_floor.py --per-file 60 --aggregate 90 build/cov/coverage.info # A floor at or below the measured rates passes.
coverage_floor: ok -- 23 files under src/, aggregate 92.9% lines (3203/3447), every file at or python3 tools/coverage_floor.py build/cov/coverage.info
above 60%, aggregate target 90% # coverage_floor: ok -- ...
$ echo $? # $? == 0
0
``` ```
## Why the check is a CMake target and not a ctest case ## Why the check is a CMake target and not a ctest case
+151
View File
@@ -0,0 +1,151 @@
# Data and performance
## The sample stores
All three series derive from `plot::data::Series` (`include/plot/data/series.hpp`), which
describes a **uniform-grid** series: sample `i` lives at `x = x0() + i * dx()`.
```cpp
class Series {
public:
virtual u64 size() const;
virtual f64 x0() const; // x of sample 0 (the oldest retained sample)
virtual f64 dx() const;
virtual Range x_bounds() const;
virtual Range y_bounds() const;
virtual const MinMaxPyramid& pyramid() const;
virtual bool streaming() const noexcept; // default false
virtual void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy) const;
Range reduce(u64 begin, u64 end) const; // exact min/max over [begin, end)
f64 sum(u64 begin, u64 end) const; // exact sum over [begin, end)
f64 sumsq(u64 begin, u64 end) const; // exact sum of squares
};
```
Indices are the series' **own window**: index 0 is the oldest sample it currently holds, so
`size()`, `x0()`, `reduce()`, `gather()` and `pyramid()` are all in the same coordinates.
**`ArraySeries`** (`array_series.hpp`) owns a `std::vector<f32>` of y samples plus an
`x0`/`dx` grid. Construct with `ArraySeries(std::vector<f32> y, f64 x0, f64 dx)`. The vector
is taken by value and owned, so the samples cannot change behind the series' back; the store
changes only through `reset(y, x0, dx)` (a whole new vector, then a rebuild) or `commit()`,
which rebuilds the pyramid and cached bounds from the current `y`.
**`StreamSeries`** (`stream_series.hpp`) is the single-producer / single-consumer streaming
store: `StreamSeries(u64 capacity, f64 x0, f64 dx)`.
## The SPSC contract
One producer thread, one consumer thread, and the division is strict
(`stream_series.hpp`):
- **The producer may only call** `push(f32 y)`, `push(const f32* ys, u64 n)` and
`produced()`. Those touch the ring buffer and nothing else: **no lock, no pyramid, no GL**.
The acquisition thread can therefore never stall the renderer.
- **The consumer calls `acquire()`**, which snapshots the produced count and brings the
pyramid and bounds up to it, and then queries the published window. `size()`, `reduce()`,
`sum()`, `sumsq()`, `gather()` and `pyramid()` all use the window published by the last
`acquire()`.
`acquire()` costs O(samples produced since the last acquire), not O(capacity): the pyramid
is indexed in absolute sample space, so a sliding window only drops the leading buckets the
ring overwrote and appends what is new; aggregates still in the window are never recomputed.
The one exception is a producer that laps the consumer (more than `capacity()` samples
between two acquires), where the missing samples cannot be represented and the pyramid
restarts at the new base, re-reading the whole window.
The ring overwrites the oldest samples — that is what keeps a live stream showing the newest
data — so it is **not a frozen window**. Those slots are relaxed atomics, so this is not a
data race and no value tears; but if the producer pushes `k` samples while a read is in
flight, the first `k` positions of that read can hold one of those `k` newer samples rather
than the one the snapshot named. Ingest with no reuse underneath a read at all needs the
producer to stop before unread data (drop-on-full) or a claim protocol — a different
contract from this one.
`streaming()` returns `true` for `StreamSeries` and `false` for everything else. Only a
streaming series has a **live edge** (the newest x it holds, which moves on its own), and it
is that property `XFollow::Live` follows (see [interaction.md](interaction.md)).
## The min/max LOD pyramid
`data::MinMaxPyramid` (`minmax_pyramid.hpp`) is the index that makes per-frame cost depend on
the visible pixels rather than on the sample count.
- Samples are grouped into buckets of `kBucket = 64` (**`kBucket`** is a fixed constant, so
all series share one LOD geometry). Level `l` bucket `k` covers the absolute samples
`[k * (kBucket << l), (k + 1) * (kBucket << l))`, and bucket boundaries are fixed for the
pyramid's life — appending never re-buckets existing aggregates, and a window can slide
over the same buckets.
- Each level holds its complete buckets in a `std::deque` (O(1) `push_back` and
`pop_front`) plus a `base`. `append()` folds a sample into the in-progress level-0 bucket
and, when it completes, pushes it and walks up, stopping at the first level that gains no
entry: an append costs **amortised O(1)**. `drop_before(u)` slides the window's leading
edge, bounding the memory by the retained window rather than by everything ever appended.
- An entry is 24 bytes (`MinMax` plus the exact `sum`/`sumsq` the exact range queries fold
over), so level 0 costs `0.375n` bytes and the geometric tail brings the whole pyramid to
about **`0.75n` bytes — roughly 19% of the `4n` bytes of raw samples** (`f32`). A
min/max-only index would be `0.25n` (~6%); the two doubles are the price of answering
mean/RMS/integral from whole buckets instead of raw samples.
Queries on the pyramid are **bucket-grained** and named `*_buckets` (`reduce_buckets`,
`sum_buckets`, `sumsq_buckets`): a pyramid stores aggregates, so it can only answer exactly
for whole buckets, and partial edge buckets are excluded rather than approximated. Exact
range queries live on `Series`, which owns the raw samples.
`level_for(u64 n, f64 samples_per_pixel)` returns the pyramid level whose buckets hold about
`samples_per_pixel` samples for a window of `n` samples (clamped to levels that exist, to
levels with more than one bucket, and to levels that give the window at least two buckets).
The renderer uses this: a trace's x range is mapped onto sample indices, and `Series::gather`
is asked for that window *at `pyramid().level_for(n, samples_per_pixel)`* whenever a pixel
covers `Style::dense_threshold_spp` samples or more (default 1.5). A 100-million-sample
series therefore stages O(visible pixels) vertices, not O(samples), and `FrameStats` records
which path each trace took. Below the threshold the raw samples are drawn instead; both
sides go through the same instanced anti-aliased quad pipeline.
## Exact versus interpolated
The library deliberately holds two conventions next to each other, and they do **not**
agree:
- **`Series::reduce` / `sum` / `sumsq` are exact over a sample range.** They answer "what
are the samples' extremes / sum / sum of squares over `[begin, end)`". Each is
implemented once over `copy_raw()`: the bucket-aligned interior comes from the pyramid
(O(log n) bucket aggregates) and at most two partial edge buckets are read raw and
scanned, so the cost is O(`kBucket`) worst case and **independent of the series length**,
and no query allocates. `Measure::evaluate` and `Plot::region_stats` are built on these,
never on the `*_buckets` forms, which would silently report wrong extremes for an
unaligned interval.
- **The crosshair readout is interpolated, and says so.** `crosshair_span(series, x)`
returns the two samples `x` lies between (`begin`, `end`, `t`), and
`crosshair_interpolate(y_begin, y_end, t)` does a linear interpolation: it answers "what
is the signal *at* this instant", which is generally between two samples — a different
question from an interval statistic. The lookup is O(1) (one division and a floor) and
allocation-free; `Plot::crosshair_value(trace, x, out_y)` exposes the same number to a
host. A row whose trace has no sample at the crosshair's x shows `kCrosshairNoValue`
(an em dash, `"—"`), and an x outside the series window yields no value rather than the
nearest sample's.
`Measurement` (from `Plot::measurement()`) documents `integral` as a **left-Riemann sum**,
`sum(begin, end) * dx` — the samples taken as the value at the left edge of each step. That
is part of the result's contract, not an implementation detail.
## Measured frame times
These are the demos' own frame summaries, taken from a `linux-release` build, **all of them
llvmpipe (Mesa software rasteriser) under Xvfb** — no run touched a GPU. Treat them as
upper bounds and as a record of the software path, not as hardware numbers.
| demo | window | mean ms/frame | work |
|---|---|---|---|
| GLFW (`plot_glfw_demo`) | 1280×720 | 0.42 | one 1 MS/s `StreamSeries` |
| ImGui (`plot_imgui_demo`) | 1280×720 | 0.37 | the same stream, embedded |
| multiplot (`plot_multiplot_demo`) | 1280×720 | 0.66 | 3 rows, 6 traces, 500 kS/s each |
| Qt6 (`plot_qt_demo`) | 820×588 at exit | 3.23 | one stream, `QOpenGLWidget` FBO |
| GTK4 (`plot_gtk_demo`) | 810×564 at exit | 2.85 | one stream, `GtkGLArea` FBO |
The multiplot run stages 18 651 segments for ~4.5 million visible samples, which is the
pyramid doing its job: the frame is bounded by the pixels, not the samples. The Qt and GTK
figures are dominated by their toolkit's texture-backed FBO under llvmpipe, not by the
library.
+213
View File
@@ -0,0 +1,213 @@
# Embedding into a host
A host owns the window, the GL context and the event loop; `plot` owns none of them. The
contract the renderer offers is small: make a 3.3 core context current, resolve the entry
points with `plot::gl::load()`, call `Renderer::init()`, then each frame
```cpp
renderer.style().ui_scale = <device pixels per logical unit>;
renderer.begin_frame(fb_w, fb_h, viewport_px); // clears exactly viewport_px
renderer.draw(plot); // or renderer.draw(figure)
renderer.end_frame(); // restores the GL state it moved
```
and hand the plot (or figure) whatever events the host receives. The `Renderer` never
creates, binds or resizes a framebuffer, never reads your event loop, and never sees a
pointer or a button.
Below are the four shipped adapters. Each is a separate opt-in target
(`plot::glfw`, `plot::imgui`, `plot::qt6`, `plot::gtk4`) and each reads its own source
under `adapters/`.
## Shared conventions
**Pixel space.** Every `Rect`, viewport and pointer position `plot` speaks is in
**device (framebuffer) pixels, origin top-left**. Every host below reports geometry in
*logical* units (window points, widget pixels) and converts before forwarding, so a
gesture lands on the pixels the plot was laid out in.
**DPI / `ui_scale`.** The metrics in `render::Style` (font size, stroke widths, paddings,
tick spacing) are **logical** units; `Style::ui_scale` is device pixels per logical unit,
and the renderer converts each metric through `Style::device()` exactly once. The host
writes `ui_scale` from its own display, so a 12 px label is the same physical size on a 1x
and a 2x panel. The one metric that is *not* scaled is `dense_threshold_spp`, a
samples-per-pixel density (see [styling.md](styling.md)). How each host supplies the
scale:
| host | value written to `ui_scale` | source |
|---|---|---|
| GLFW | `fb_scale_x` = framebuffer width / window width | `glfwGetFramebufferSize()` vs `glfwGetWindowSize()`, re-read on resize |
| ImGui | `io.DisplayFramebufferScale.x` | filled by the platform backend |
| Qt6 | `devicePixelRatioF()` | re-read every `paintGL()` |
| GTK4 | `mapping_.factor` = device size / logical allocation | the two sizes `::resize` reports, not `gtk_widget_get_scale_factor()` |
**Headless verification.** Every demo supports `PLOT_DEMO_EXIT_AFTER_MS=<ms>` (stop the
loop after that long) and `PLOT_DEMO_SCREENSHOT=<path>` (write the final frame as a PPM).
This is how the project runs its own GUI demos in CI under Xvfb, with no display access
and no screen capture. The invocations it uses (each demo, from the repository root, all
five exit `0`):
```sh
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/glfw.ppm \
xvfb-run -a ./build/release/examples/plot_glfw_demo
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/multiplot.ppm \
xvfb-run -a ./build/release/examples/plot_multiplot_demo
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/imgui.ppm \
xvfb-run -a ./build/release/examples/plot_imgui_demo
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/qt.ppm \
QT_QPA_PLATFORM=xcb xvfb-run -a -s "-screen 0 1920x1200x24" \
./build/release/examples/plot_qt_demo
PLOT_DEMO_EXIT_AFTER_MS=4000 PLOT_DEMO_SCREENSHOT=/tmp/gtk.ppm \
GDK_BACKEND=x11 xvfb-run -a -s "-screen 0 1920x1200x24" \
./build/release/examples/plot_gtk_demo
```
The `-s "-screen 0 1920x1200x24"` shape matters for Qt and GTK, whose demos script a
resize before the exit frame; a bare `xvfb-run` screen (640×480) clamps the window and
that self-check reports a false failure.
## GLFW — `plot::glfw`
`#include <glfw/glfw_host.hpp>`, link `plot::glfw` (which links `plot::core`). This is the
reference host: `Host` owns the GLFW window and its 3.3 core context, the GL entry points,
the `Renderer`, the `Plot`, an `input::Controller` and the frame loop.
```cpp
plot::glfw::Host host;
plot::Plot& p = host.plot(); // populate axes/traces here
plot::Status st;
if (!host.run({1280, 720, "plot"}, [](plot::Plot& p, plot::f64 dt) { /* feed data */ }, &st)) { ... }
```
What `run()` does, in order:
1. `glfwInit()`, then window hints for OpenGL **3.3 core** (plus forward compatibility) and
`glfwCreateWindow()`, `glfwMakeContextCurrent()`, `glfwSwapInterval(vsync)`.
2. `plot::gl::load(&resolve_gl)`, where `resolve_gl` wraps `glfwGetProcAddress` (a function
pointer respelled as `void*` by bit copy).
3. `Renderer::init()`.
4. Per frame: `glfwPollEvents()` → `refresh_sizes()` →
`controller.set_viewport(framebuffer rect)` → the host's `frame` callback →
`begin_frame(fb_width, fb_height, viewport)` → `draw(plot)` (or `draw(figure)`) →
`[screenshot]` → `end_frame()` → `glfwSwapBuffers()`.
Pointer translation: `on_cursor_pos()` multiplies the GLFW window coordinates by
`fb_scale_x_`/`fb_scale_y_` (framebuffer ÷ window) and synthesises `dx`/`dy` itself (GLFW
reports no delta); `down` comes from `glfwGetMouseButton`, and the modifier mask is read
from the key state, because GLFW's pointer and scroll callbacks carry no mask. Wheel
notches are forwarded as `WheelEvent`, and keys as `KeyEvent`.
Figure mode: `host.set_figure(&figure)` makes the loop lay the figure over the framebuffer,
create **one `Controller` per row**, route each pointer/wheel/key event to the row it lands
in (`controller_at(x, y)`), and draw with `Renderer::draw(Figure&)`. The host also spreads
one hover over every row (`sync_figure_crosshair`), since only the host knows which row the
pointer is in.
`run()` leaves the window open on success so a producer thread can be joined before the GL
resources go away; `close()` (or the destructor) releases the renderer, context and window
on the calling thread. A run with `PLOT_DEMO_EXIT_AFTER_MS` set creates the window hidden.
## Dear ImGui — `plot::imgui`
`#include <imgui/imgui_plot.hpp>`, link `plot::imgui`. Build-tree only: ImGui is supplied
by `FetchContent`, so `find_package(plot)` does not export this target.
`PlotWidget::draw(id, plot, size, renderer)` must be called between `ImGui::NewFrame()` and
`ImGui::Render()`, from inside a window. It:
1. opens a `BeginChild` region of the requested size (no scrollbar) and forces
`ImGuiCol_ChildBg` transparent, so the widget never paints over its own plot;
2. intersects the child's rectangle with ImGui's clip rect, so a child the host cuts off
does not paint over what is beside it;
3. covers the visible region with an `InvisibleButton` and reads `IsItemHovered()`; this is
the hover gate (false while another widget is being dragged) and the item that
`SetItemKeyOwner(ImGuiKey_MouseWheelY)` claims the wheel with, so the plot's zoom wins
over window scrolling;
4. maps the child's point rectangle to framebuffer pixels through
`io.DisplayFramebufferScale`, gives the controller that region, and renders immediately
(`begin_frame`/`draw`/`end_frame`) into it.
Because ImGui reports button *levels* and the controller wants *edges*, the widget
remembers the previous frame's buttons and synthesises `pressed`/`released`; a button
pressed over the child keeps being delivered until every button is up, so a drag that
leaves the region still ends. ImGui's `(-FLT_MAX, -FLT_MAX)` "no pointer" sentinel is
treated as no position update (no movement, buttons still flow).
Coordinate assumption: the points→pixels mapping is the whole framebuffer's only when the
host's GL viewport is the whole framebuffer at origin `(0,0)` and the bound target is the
default framebuffer. A host that renders into an FBO or a sub-viewport must place the plot
itself with `Renderer::begin_frame()` and `plot.set_viewport()`.
## Qt6 — `plot::qt6`
`#include <qt6/qt_plot_widget.hpp>`, link `plot::qt6`. `PlotWidget` is a `QOpenGLWidget`,
so it draws into the FBO Qt binds before `paintGL()`, and `QOpenGLWidget` needs no target
of its own.
**Precondition, set before the widget exists** (a `QOpenGLWidget` is handed the
application default `QSurfaceFormat` and cannot renegotiate):
```cpp
QSurfaceFormat fmt;
fmt.setVersion(3, 3);
fmt.setProfile(QSurfaceFormat::CoreProfile);
fmt.setSwapBehavior(QSurfaceFormat::DoubleBuffer);
fmt.setDepthBufferSize(0);
fmt.setStencilBufferSize(0);
QSurfaceFormat::setDefaultFormat(fmt);
QCoreApplication::setAttribute(Qt::AA_UseDesktopOpenGL); // before QApplication
```
Lifecycle: `initializeGL()` calls `initializeOpenGLFunctions()`, `plot::gl::load()`,
`Renderer::init()` and records the outcome in `init_status()` (a `QOpenGLWidget` virtual
cannot return a `Status`; it does not assert). `resizeGL()` refreshes the viewport so a
gesture arriving before the next paint maps against the new geometry. `paintGL()` sets the
viewport on the controller, runs the frame callback, sets `ui_scale` to
`devicePixelRatioF()`, then `begin_frame`/`draw`/`end_frame`, and records frame timings
(`frames_painted()`, `last_frame_ms()`, `mean_frame_ms()`).
Input translation: Qt reports press/release edges directly, so `mousePressEvent` /
`mouseMoveEvent` / `mouseReleaseEvent` / `mouseDoubleClickEvent` / `wheelEvent` /
`keyPressEvent` become `PointerEvent`/`WheelEvent`/`KeyEvent`. Every position is multiplied
by `devicePixelRatioF()`; the *anchor* a move's delta is measured from is kept in logical
pixels, so a drag crossing onto a display with another scale factor pans by the distance
moved rather than a cross-scale jump (`PointerAnchor`, `qt_pointer.hpp`). Qt's
`angleDelta()` is in eighths of a degree (120 per notch) and is converted; a wheel with no
vertical component is refused before it reaches the controller.
Include-order note: this header includes `plot/gl/api.hpp` before `<QOpenGLWidget>`,
because Qt reaches `<GL/gl.h>` whose macros would rewrite `plot::gl`'s enumerators. A
translation unit using the widget must keep that order and must not name a `plot::gl::GL_*`
enumerator after a Qt GL header.
## GTK4 — `plot::gtk4`
`#include <gtk4/gtk_plot_area.hpp>`, link `plot::gtk4`. GTK cannot have a GObject subclass
declared around a C++ class (and `GtkGLArea`'s data is private), so this adapter is a
factory, not a subclass:
```cpp
plot::gtk4::PlotArea* area = plot::gtk4::PlotArea::create(plot);
GtkWidget* widget = area->widget(); // put it in your container
```
`create()` builds a `gtk_gl_area_new()`, registers the signals and controllers that drive
it, and returns the wrapper — which GTK owns and frees with the widget; never delete it.
Signals: `::realize` makes the context current, checks `gtk_gl_area_get_error()`, loads the
entry points and initialises the renderer (recording the outcome in `init_status()`);
`::unrealize` releases the renderer's GL objects while the context can still be made
current; `::resize` derives the device-pixel mapping from the widget's logical allocation
and the device size GTK reports, and refreshes the viewport; `::render` sets the viewport on
the controller, runs the frame callback, sets `ui_scale` to the mapping's factor, and draws.
Input translation uses GTK's gesture recognisers: `GtkGestureDrag` supplies motion,
`GtkGestureClick` supplies the press/release edges, `GtkEventControllerMotion` supplies
hover (forwarded with `down == false`, so a drag is not applied twice), and
`GtkEventControllerScroll` (vertical) and `GtkEventControllerKey` supply the wheel and the
`+`/`-`/`f` keys. GTK emits `drag-begin` before the click's `pressed`, so the adapter opens
its gesture from the first press and closes it from the first release. The area queues a
render after every input event that can change the picture (not on hover).
A `GtkGLArea` renders into its own FBO, so a screenshot of the *plot* is a readback of that
FBO, not of the window (GTK's chrome lives outside it) — see the demo's `dump_area_ppm`.
+171
View File
@@ -0,0 +1,171 @@
# Getting started
`plot` is a C++20 library for real-time, high-sample-rate time-series plots drawn with
OpenGL 3.3 core. The core library — the CMake target `plot::core` — has no third-party
dependency at all; the host adapters for GLFW, Dear ImGui, Qt6 and GTK4 are separate,
opt-in targets on top of it (see [embedding.md](embedding.md)).
## Prerequisites
- A C++20 compiler.
- CMake **3.24 or newer**. `cmake/plot_options.cmake` fails the configure with a
`FATAL_ERROR` below that, and the presets in `CMakePresets.json` declare
`cmakeMinimumRequired` 3.24.
- A driver exposing an **OpenGL 3.3 core** profile. `plot::gl::load()` resolves only the
3.3 entry points the library actually calls; anything newer (persistent buffer mapping,
sync objects) is optional, probed through `plot::gl::caps()` and only used when the
driver offers it.
- To build the **test suite** you also need EGL: `tests/CMakeLists.txt` fails the configure
without it, because the GL tests create a real headless EGL context. The library itself
does not need EGL.
- Each adapter needs its own toolkit (GLFW, Dear ImGui downloaded at configure time, Qt6 or
GTK4) — see [building-and-testing.md](building-and-testing.md) for the options.
## Build and install
The presets live in `CMakePresets.json`:
```sh
cmake --preset linux-release # or linux-debug, linux-asan, linux-coverage
cmake --build build/release
cmake --install build/release --prefix /where/you/want
```
Install is on by default (`PLOT_INSTALL`, `ON`). It installs the `plot_core` archive, the
public headers under `include/plot/`, and a CMake package config under
`lib/cmake/plot/`. Adapters join the install/export set only when their toolkit can be
re-found on a consumer's machine; the configure log records which ones were exported and
why.
## Consuming it
```cmake
find_package(plot 0.1 REQUIRED)
target_link_libraries(my_app PRIVATE plot::core)
```
`plot::core` is the alias of the `plot_core` target (`EXPORT_NAME core`, so the installed
name matches the build-tree name). It carries the C++20 requirement and the include
directory as its public interface and nothing else. The exported adapters are named
`plot::glfw`, `plot::qt6`, `plot::gtk4` and (when built) `plot::font_ttf`;
`plot::imgui` is a build-tree target only, because ImGui is supplied by `FetchContent` and
there is no ImGui package for an installed config file to point at.
Every public component is reachable through the umbrella header:
```cpp
#include <plot/plot.hpp> // core, math, gl, data, render, plot, input — in layer order
```
Adapters are deliberately **not** in the umbrella: their headers are reached as
`<glfw/glfw_host.hpp>`, `<imgui/imgui_plot.hpp>`, `<qt6/qt_plot_widget.hpp>` and
`<gtk4/gtk_plot_area.hpp>`, so including the umbrella never drags a toolkit in.
## A minimal GLFW program
The program below opens a window, creates a 3.3 core context, loads the GL entry points,
and draws one trace. It uses the GLFW toolkit for the window and `plot::core` directly —
it does not use the shipped `plot::glfw::Host` adapter, so it also shows the pieces the
adapter hides (window, context, loader, renderer, controller, frame loop).
It was compiled and linked as part of writing this page:
```sh
g++ -std=c++20 -I include main.cpp build/debug/libplot_core.a \
$(pkg-config --libs glfw3) -ldl -lpthread -o plot_quickstart
```
```cpp
// A minimal plot: one window, one axis pair, one trace.
#include <plot/plot.hpp> // the whole public surface, in layer order
#include <plot/gl/loader.hpp>
#define GLFW_INCLUDE_NONE // keep GLFW from including <GL/gl.h> and its macros
#include <GLFW/glfw3.h>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
namespace {
// GLFW hands back `void (*)(void)`; plot::gl::ProcLoader wants `void*`.
void* resolve_gl(const char* name) {
GLFWglproc p = glfwGetProcAddress(name);
void* out = nullptr;
static_assert(sizeof(out) == sizeof(p));
std::memcpy(&out, &p, sizeof(out));
return out;
}
} // namespace
int main() {
if (glfwInit() != GLFW_TRUE) return 1;
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE);
GLFWwindow* window = glfwCreateWindow(800, 480, "plot quick-start", nullptr, nullptr);
if (window == nullptr) { glfwTerminate(); return 1; }
glfwMakeContextCurrent(window);
glfwSwapInterval(1);
plot::Status st;
if (!plot::gl::load(&resolve_gl)) { std::fprintf(stderr, "gl::load failed\n"); return 1; }
plot::Renderer renderer;
if (!renderer.init(&st)) { std::fprintf(stderr, "init failed: %s\n", st.message); return 1; }
// One trace: 512 samples of a sine, x = sample index.
std::vector<plot::f32> y(512);
for (plot::usize i = 0; i < y.size(); ++i) {
y[i] = static_cast<plot::f32>(std::sin(0.05 * static_cast<double>(i)));
}
plot::data::ArraySeries series(std::move(y), 0.0, 1.0);
plot::Plot plot;
const plot::u32 x_axis = plot.add_x_axis({"x", {0.0, 511.0}, true});
const plot::u32 y_axis =
plot.add_y_axis({"signal", {-1.0, 1.0}, true, plot::AxisPlace::Left});
plot.add_trace({&series, plot::Color::hex(0x4FA3FF), 1.5f, x_axis, y_axis, "sine"});
plot::input::Controller controller(plot);
while (glfwWindowShouldClose(window) == GLFW_FALSE) {
glfwPollEvents();
int fb_w = 0, fb_h = 0;
glfwGetFramebufferSize(window, &fb_w, &fb_h);
if (fb_w == 0 || fb_h == 0) continue;
const plot::Rect viewport{0.0f, 0.0f, static_cast<plot::f32>(fb_w),
static_cast<plot::f32>(fb_h)};
controller.set_viewport(viewport);
renderer.style().ui_scale = 1.0f; // device pixels per logical unit
renderer.begin_frame(static_cast<plot::u32>(fb_w), static_cast<plot::u32>(fb_h), viewport);
renderer.draw(plot);
renderer.end_frame();
glfwSwapBuffers(window);
}
glfwDestroyWindow(window);
glfwTerminate();
return 0;
}
```
Things worth noticing, because they are the embedding contract in miniature:
- `plot::gl::load()` must run on the thread that owns the current context, and
`Renderer::init()` returns `false` with a `Status` when the load failed or the driver
refused a shader/buffer. The renderer never creates, binds or resizes a framebuffer.
- `begin_frame(fb_w, fb_h, viewport_px)` takes the framebuffer size (for the projection)
and the top-left-origin rectangle the plot occupies; `draw()` fills exactly that region;
`end_frame()` restores the GL state it changed.
- The `Plot` is pure CPU state. When you draw it through a `Renderer`, set colours and
metrics on `renderer.style()`, not on the plot — `draw()` copies the style into the plot
and hands it the `ui_scale` before laying out.
- `ui_scale` is device pixels per logical unit. Here it is `1.0` because the program uses
the framebuffer 1:1; a HiDPI host passes the framebuffer/window ratio instead (see
[embedding.md](embedding.md)).
Next: [architecture.md](architecture.md) for how the pieces fit, [embedding.md](embedding.md)
to put a plot into a real host, and [data-and-performance.md](data-and-performance.md) for
feeding it a live stream.
+209
View File
@@ -0,0 +1,209 @@
# Interaction
Everything in this page is driven by `input::Controller` (`include/plot/input/controller.hpp`,
`src/input/controller.cpp`), which turns host events into gestures knowing nothing about Qt,
GTK, GLFW or ImGui. It is pure CPU and needs no GL context and no font — everything it reads
comes from the `Plot`'s layout, ranges and transforms. It owns no viewport of its own:
`Controller::set_viewport(Rect)` forwards to `Plot::set_viewport()` and refreshes the layout,
so the two cannot disagree.
The plot's own state — cursors, regions, the measurement, the legend, the crosshair — is
owned by `Plot`; the controller only moves it and publishes what is hot.
## Cursors
A cursor is a draggable marker at a data coordinate (`Cursor`, `plot/cursor.hpp`). There are
two orientations, one type:
| orientation | created by | live coordinate | live snap | dead fields |
|---|---|---|---|---|
| `CursorOrientation::Vertical` | `Plot::add_cursor(f64 x, Color)` | `x` on `axis_x` | `SnapMode` | `y`, `h_snap`, `axis_y` |
| `CursorOrientation::Horizontal` | `Plot::add_h_ruler(f64 y, Color)` | `y` on `axis_y` | `HRulerSnap` | `x`, `snap`, `axis_x` |
A vertical cursor is a marker at `x` spanning the axes' **height**; a horizontal ruler is at
`y` spanning their **width**. Dead fields are *ignored*, not honoured: `set_cursor_y()` on a
vertical cursor and `set_cursor_x()` on a ruler change nothing, and neither does the other
orientation's snap setter.
Snaps. `SnapMode::NearestSample` pulls a marker onto the nearest sample of the first visible
trace on its x axis; `SnapMode::Free` leaves it. `HRulerSnap::NearestTick` pulls a ruler onto
the nearest tick of its y axis' tick set (the *last* `layout()`'s set — ticks are generated
there and nowhere else); `HRulerSnap::Free` leaves it.
Grab radius. A pointer within **7 logical pixels** (`kCursorHitRadiusLogical`) of a cursor's
line grabs it. The nearest of a vertical and a horizontal cursor wins; neither orientation
widens the other's reach. The distance is logical, converted through `Style::device()` at the
plot's scale, so the aim is the same physical distance on any display.
Snap on drop. `set_cursor_x()`/`set_cursor_y()` take a `CursorPlacement`: `Snapped` (the
default) applies the cursor's snap rule immediately — the programmatic path; `Free` stores
the value verbatim and leaves the snap to `Plot::snap_cursor()`. The controller uses `Free`
while dragging, so the marker tracks the pointer under the hand and the snap is committed
once, on release. A dragged ruler is additionally clamped to its own y axis' range, because a
pointer past the top or bottom of the axes has no data under it.
## Regions
A region is a shaded x interval with exact statistics (`Region`, `plot/region.hpp`). Create
with `Plot::add_region(const Region&)`; `Region::snap` is `RegionSnap::NearestSample` or
`Free`. The bounds are **ordered**: `set_region(id, x0, x1)` stores min/max, so a drag that
crosses one bound over the other **swaps** them rather than inverting the region. A pair of
bounds that is not both finite is refused (nothing changes).
Three grab zones (`RegionGrab`): `Left` (the bound at `x0`), `Right` (the bound at `x1`) and
`Body` (the interior). The controller resolves them nearest-first against the geometry
`Plot::layout()` measured — **a bound within the grab radius beats the body**, and the nearer
of the two bounds wins; with no bound in range, the topmost region whose interior holds the
pointer is the body. That geometry is the same `RegionLayout` the renderer paints, so what is
drawn and what can be grabbed cannot drift apart.
The bound band is the same 7-logical-pixel radius, cut back so a narrow region keeps a
middle a body drag can reach (`min(radius, (width − 2)/2)`, with a minimum interior of
**2 logical pixels**, `kMinRegionBodyLogical`). A region narrower than **3 logical pixels**
(`kMinRegionEdgeGrabLogical`) is grabbed by its body instead, widened to that same minimum —
otherwise its two bounds would be one target and it could never be moved again. `Body` drag
translates both bounds by one delta, preserving the width exactly; a bound drag moves one end
and holds the other. A snapping region is put on the sample grid once, on release
(`snap_region()`), not on every motion. `remove_region(id)` drops the region and shifts the
ids above it down.
## The measurement band
`Plot::add_measure(cursor_a, cursor_b, trace = 0)` measures the interval between two cursors
over the series of `trace`. The renderer draws the shaded band the readout belongs to from
`Plot::measure_span(id)` (which reports the cursor pair), and the statistics come from
`Plot::measurement(id)`: `dx`, `dy`, `slope`, `mean`, `rms`, `ymin`, `ymax` and the
left-Riemann `integral`. The band's edges *are* those cursors, so it is resized by dragging
either of them. A measurement is re-evaluated only when a cursor moved, when it was added, or
when the series underneath changed — never once per frame; `const` because recomputing fills
a cache.
## The legend
The legend is on by default (`Legend::visible = true`) and free until a trace is named: with
no named visible trace — or no text backend to measure a name with — `Plot::layout()` produces
an empty layout and nothing is drawn. A trace earns an entry by being given a `name`
(`TraceDesc::name`/`Trace::name`). Configure it with `Plot::set_legend(const Legend&)`;
`Legend::place` anchors the panel to a corner of the axes (`TopLeft`, `TopRight`,
`BottomLeft`, `BottomRight`) and `Legend::margin` is both the anchor's inset and the budget
the panel has to fit in, so a panel that does not fit drops entries and ends with `...` rather
than overhanging. See [styling.md](styling.md) for the knobs and their defaults.
## The crosshair
The crosshair is a **hover** instrument, not a cursor: it follows the pointer and exists only
while the pointer is over the data area (`Crosshair`, `plot/crosshair.hpp`). There is at most
one per plot.
- `Plot::set_crosshair(f64 x, bool active)` places a **vertical-only** crosshair: the column
of `x`, a marker on every visible trace at that x, and the readout panel beside it. This is
the form for a caller that has a coordinate and not a pointer.
- `Plot::set_crosshair(f64 x, f64 y, bool active)` adds the **horizontal** line at `y`. This is
the form the controller uses, because a pointer over the axes has both coordinates.
- `Plot::clear_crosshair()` hides it, leaving it where it was. The controller calls this when
the pointer leaves the axes; a press, wheel or key that moves the data under the pointer
*re-anchors* the crosshair rather than clearing it.
- `set_crosshair_readout(bool)` switches the panel off — or back on — without moving the
crosshair. `set_crosshair_style(const CrosshairStyle&)` overrides this plot's stroke and
dash.
In a `Figure`, the crosshair is applied to **every row** (one shared `x`): each row's readout
lists its own traces at that instant. `Figure::set_crosshair(x, active)` is vertical-only
across the figure, and `Figure::set_crosshair(x, hovered_row, y_in_row, active)` adds the
horizontal line **only in the row the pointer is in** — a figure's rows share x and not y, so
a figure-wide horizontal line would be a value that means something different in each row.
Readout values are **interpolated**, not interval statistics (see
[data-and-performance.md](data-and-performance.md)); a row whose trace has no sample at the
crosshair's x is listed with the em dash `"—"` and no marker, rather than a fabricated value.
## The gesture table (exactly as implemented)
A press opens the first of these that applies, in this order (from `Controller::begin_drag`
and `Controller::begin_handle_drag`):
| gesture | drag mode | effect |
|---|---|---|
| **shift + left drag** | `BoxZoom` | both ranges from the selection rect, applied on release |
| **middle drag on the x label strip** | `AxisZoomX` | x-only zoom |
| **middle drag on the y label strip** | `AxisZoomY` | y-only zoom |
| middle drag anywhere else | — | nothing |
| **left or right drag on a handle** | `MoveCursor` / `MoveRegion` / `ResizeRegionLeft` / `ResizeRegionRight` | move the grabbed handle |
| **left or right drag elsewhere** | `Pan` | translate both ranges |
| **wheel** | — | zoom about the pointer (shift: x only, ctrl: y only; neither or both: both axes) |
| **`+` / `-`** | — | zoom about the plot centre |
| **`f`** (or `F`) | — | `auto_fit_x()` / `auto_fit_y()` every axis |
Precedence for a press that is not shift+left and not middle: **cursor marker → region bound
→ region body → pan**. Shift is checked first of all, so a selection box is what shift+left
means even when it starts over a handle. The right button **pans and grabs** exactly as the
left does — both are buttons a host drives drags with, so neither is singled out for handles.
`Ctrl` is not needed for a cursor move and does not change what a press on one does.
Values behind the gestures:
- one wheel notch (or one `+`/`-` press) changes the span by `exp(-0.15)` (~14%,
`kZoomPerNotch`); positive = zoom in;
- a middle-drag axis zoom is one decade per **200 logical pixels**
(`kZoomPerPixel = ln10 / 200`), with the wheel's sign convention (dragging down zooms in);
- a box-zoom selection under **4 logical pixels** on either side (`kMinBoxLogical`) is a
click and leaves the ranges alone.
Events are sanitised before they act: a non-finite coordinate or delta becomes the last known
position and no movement, but the buttons still flow, so a drag whose release arrives with a
garbage position still ends. A wheel amount that is not a number is refused outright (a
zeroed notch would still run the range setters, which clear auto scale). A gesture that sets
a range also clears that axis' `auto_scale` — one place, in `Plot`'s range setters.
What the pointer is on is published to the plot (`Plot::set_hot_handles()`) after every event
that can move the geometry under the pointer, so the renderer draws hovered/grab highlights
without knowing what a pointer is. `hovered_cursor()`, `grabbed_cursor()`, `hovered_region()`,
`grabbed_region()` and `region_grab(u32)` expose the same answers to a host (a resize cursor, a
tooltip, a status line).
## Live follow
An x axis has a follow state, `XFollow::Off` (the default) or `XFollow::Live`
(`Plot::set_x_follow(axis, f)`). `Live` makes the visible window a view from the live edge:
every `layout()` places it at `[newest − offset − span, newest − offset]`, where `newest` is
the newest x any visible streaming trace bound to the axis holds (`Plot::x_live_edge()`).
`Off` means the range is exactly what a caller set or an auto-fit produced.
**`set_x_range()` on a live axis is not an absolute window.** A range handed to
`Plot::set_x_range(axis, r)` while the axis is `Live` *and* has a live edge is read as the
**span** the window should have and the **offset** it implies — `offset = newest − r.max`,
clamped to what the traces still retain — and the visible window follows from those at the
live edge of the moment. `XFollow::Off` stores the range verbatim. The point of the
reinterpretation is that every gesture (pan, wheel, box zoom, axis zoom) reaches the x range
through this one setter, so they all keep following for free.
The offset is clamped to `[0, retained span]` (`Plot::set_x_offset()`, read back with
`Plot::x_offset()`): the upper limit is what the traces still hold, so the window's right
edge never goes behind the oldest retained sample (and the view is left showing the dropped
data rather than inventing samples that are gone); the lower limit is why dragging toward the
live edge stops instead of turning the window inside out. A non-finite offset is refused. The
clamp is applied again by `layout()` every frame.
Consequences while an axis is `Live` *and* has a live edge:
- `auto_scale` is **inert** on that axis: `layout()` does not fit it, because a fit would
overwrite the span (the user's zoom) on every frame. The follow state wins; `auto_fit_x()`
is how such an axis is framed (it fits, sets the span to the retained window's, and returns
the offset to 0 without turning following off).
- Nothing but `set_x_follow()` disarms following. No gesture and no `set_x_range()` reaches
the follow state.
- **Zoom is relative (multiplicative)**: the wheel multiplies the span, so notches compound,
and the anchor is kept still by adjusting the offset. **Pan** shifts the offset by the
drag's data delta and leaves the span alone, so a user can walk back through the retained
window and stops at the live edge.
Arming `Live` on an axis with a live edge places its window at the live edge at once, from the
span it already had. Turning `Live` off leaves the axis where it is: the last live window
becomes an ordinary stored range.
**Figures.** In a `Figure`, the follow state belongs to the link **group**, not the row: a
group pushes one window to many rows, so the state has to be the group's or the same range
would mean a different window in each member. Use `Figure::add_x_link()`, `link_x(row, group)`,
`set_x_link_range(group, r)` and `set_x_link_follow(group, XFollow::Live)`; a group derives
its window once per frame from the newest x across all its members' traces. `link_x()` refuses
a member whose own axis is `Live`.
+161
View File
@@ -0,0 +1,161 @@
# Styling
Every colour and metric the renderer draws with lives in `render::Style`
(`include/plot/render/style.hpp`). A caller sets fields on `Renderer::style()`; the next frame
picks them up, because nothing here is baked into a shader.
Set colours and metrics on the **renderer**, not on the plot: `Renderer::draw()` copies its
`Style` into the plot and hands it the `ui_scale` *before* it lays out, so the strips, tick
densities and strokes the plot measures with are the values the renderer draws with. A plot
drawn without a renderer lays out with whatever `Plot::set_style()` was given.
## The unit rule: logical versus device
A metric a user perceives as a **size** — a font, a stroke width, a padding, a marker, a
swatch, the minimum spacing between ticks — is written in **logical** units, so a 12 px label
is the same physical size on a 1x and a 2x panel. The host states its display with `ui_scale`
(device pixels per logical unit); every consumer converts exactly once, through `device()`:
```cpp
f32 device(f32 logical) const noexcept {
return ui_scale > 0.0f ? logical * ui_scale : logical; // non-positive scale converts 1:1
}
```
`Plot::layout()` uses it to size the label strips, title strips and legend chrome; the renderer
uses it to convert widths and font px (the font is reset at `device(axis_font_px)`).
The one exception is `dense_threshold_spp`, which is a **density** (samples per *rasterised*
pixel), compared against a ratio computed from the framebuffer's own width. Scaling it would
make the chosen LOD level depend on the display rather than on the data, so it is used raw.
## `Style` fields
Colours (neither logical nor device — nothing to scale):
| field | default | meaning |
|---|---|---|
| `background` | `0x101418` | frame clear colour and legend/readout panel base |
| `grid_major` | `0x2A3038` | major (labelled-tick) grid lines |
| `grid_minor` | `0x1C2126` | minor (subdivision) grid lines |
| `axis_line` | `0x8A94A6` | axis lines |
| `axis_text` | `0xC8D0DC` | tick labels and axis titles |
| `cursor` | `0xFFB020` | cursor markers; also the hovered/grab highlight |
| `measure_fill` | `0xFFB020` at α 0.15 | the measurement band |
| `crosshair` | `0xE6EBF2` | crosshair lines and the readout panel's border (hue-neutral by design; trace markers keep each trace's own colour) |
The scale:
| field | default | unit |
|---|---|---|
| `ui_scale` | `1.0` | device pixels per logical unit; supplied by the host |
Logical metrics (scaled by `device()`):
| field | default | meaning |
|---|---|---|
| `axis_font_px` | `12.0` | logical em the axis text is drawn at |
| `grid_label_pad` | `4.0` | gap between a tick label and the axes, and the inset inside a title strip |
| `grid_major_width` | `1.0` | width of a major grid line |
| `grid_minor_width` | `1.0` | width of a minor grid line |
| `grid_major_visible` | `true` | stage the major grid at all |
| `grid_minor_visible` | `true` | stage the minor grid at all |
| `line_width_px` | `1.5` | width of the strokes framing the data (axis lines, cursor markers) |
| `handle_width_px` | `5.0` | width of a highlighted draggable handle (a hovered/grabbed cursor tab, a region's hot bound) |
| `tick_min_px` | `60.0` | minimum spacing between major ticks |
Crosshair stroke and dash (logical):
| field | default | meaning |
|---|---|---|
| `crosshair_width` | `0.75` | the crosshair's stroke width (deliberately thinner than a trace's `1.5`) |
| `crosshair_dash_on` | `4.0` | ink length of the dash |
| `crosshair_dash_off` | `4.0` | gap length of the dash; **`0` means solid** |
Device metric (never scaled):
| field | default | meaning |
|---|---|---|
| `dense_threshold_spp` | `1.5` | samples per device pixel at or above which a trace is gathered from the LOD pyramid instead of from raw samples |
## The grid
The grid's **positions** come from the tick set alone: `Grid::build()` emits one `GridLine`
per tick, spanning the perpendicular axis' full range, tagged `major` (a labelled tick) or
not. `Style` decides only how each class is drawn — its width and visibility — and the two
colours above.
A class switched off stages **no geometry at all**: the renderer skips it before the first
segment is staged, so a dense plot's minor grid costs nothing rather than being drawn
transparent. `Grid` therefore carries both classes' geometry whatever the style says; a
caller that draws the grid itself applies the same two switches. `Grid::vertical` and
`Grid::horizontal` (both `true`) control which directions emit lines at all.
## The crosshair's dash
`crosshair_dash_on`/`crosshair_dash_off` are the fragment shader's discard pattern, so no
geometry is added. `dash_off == 0` draws a solid line — the value every other line in the
library uses, so the one pipeline serves them unchanged. A single plot may override all three
knobs without touching the global `Style` through `CrosshairStyle` (below).
## `Legend`
`Legend` (`include/plot/legend.hpp`), set with `Plot::set_legend()`. All sizes are **logical**,
converted through `Style::device()` when `Plot::layout()` measures the panel.
| field | default | meaning |
|---|---|---|
| `visible` | `true` | draw the legend at all (free when nothing is named) |
| `place` | `LegendPlace::TopRight` | corner of `axes` the panel is anchored to |
| `frame` | `true` | paint the panel (background at partial alpha plus a 1-px border) |
| `pad` | `6.0` | inset from the panel edge to its first row, and the swatch↔name gutter (one knob for both) |
| `swatch_w` | `14.0` | swatch width |
| `swatch_h` | `8.0` | swatch height; the row height is `max(swatch_h, line height)` |
| `row_gap` | `3.0` | blank space between rows |
| `margin` | `8.0` | inset from the axes edge, *and* the budget the panel must fit in (so it drops entries rather than overhanging) |
There is no "outside the plot" placement: the strips around `axes` belong to the tick labels
and axis titles, and a legend that reserved space would resize the plot the moment a trace was
named. A truncated legend ends with the constant `kLegendEllipsis` (`"..."`).
## `CrosshairStyle`
A per-`Crosshair` override of the three crosshair knobs, in logical pixels
(`include/plot/crosshair.hpp`), set with `Plot::set_crosshair_style()`. Every field defaults to
`-1.0`, which means "not overridden": the field falls back to the matching `Style::crosshair_*`
metric, so a default-constructed `CrosshairStyle` is exactly the global style.
| field | default | meaning |
|---|---|---|
| `width` | `-1.0` | logical stroke width; `< 0` takes `Style::crosshair_width` |
| `dash_on` | `-1.0` | logical ink length; `< 0` takes `Style::crosshair_dash_on` |
| `dash_off` | `-1.0` | logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0` (once resolved) means solid |
## Traces
A trace's own `width` (`TraceDesc::width`, `Trace::width`) is **logical** too — a stroke like
`line_width_px`, converted by the same `device()` call. Its default colour is `0x4FA3FF` and
its default width `1.5`. Region fill/edge colours are the region's own (`Region::fill`
`0xFFB020` at α 0.15, `Region::edge` `0xFFB020`); a cursor's colour is per-cursor
(`0xFFB020` by default).
## Installing a text backend
The font a plot measures with and the renderer draws with is a `render::TextBackend`
(`include/plot/render/text.hpp`): `measure()`, `height()`, `ascent()`, `em_px()`, `glyph()`,
`cell_uv()`, `atlas_pixels()`, `atlas_width()`, `atlas_height()` and `revision()`.
- `Plot::set_text_backend(const render::TextBackend*)` sets the backend a plot lays its labels
out with. It is **borrowed**, not owned, and may be null (then the label areas fall back to
`PlotLayout`'s defaults and no string is measured).
- `Renderer::set_text_backend(const render::TextBackend*)` sets the face the renderer draws
with. It is also borrowed and must outlive the frames.
**Note the interaction:** `Renderer::init()` binds `render::EmbeddedMonoFont`, and every
`Renderer::draw()` installs that binding on the plot it is given — replacing whatever a host
set with `Plot::set_text_backend()` (a debug build names the one it dropped). So installing a
backend on the *plot* only takes effect for a plot no renderer draws. To draw with your own
face (say a `plot::text::TrueTypeFont` from the optional `plot::font_ttf` target), install it
with `Renderer::set_text_backend()`; the swap takes effect on the next frame, which re-reads
the backend's metrics at `style().device(axis_font_px)` and re-uploads its atlas when the
backend's `revision()` moved. Passing `nullptr` restores the embedded face.
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The reference application: the whole library running as a real program, fed by // The reference application: the whole library running as a real program, fed by
// a 1 MS/s producer thread through the GLFW adapter. // a 1 MS/s producer thread through the GLFW adapter.
// //
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The GTK4 application: the same 1 MS/s stream the other demos draw, as the // The GTK4 application: the same 1 MS/s stream the other demos draw, as the
// content of a GtkApplicationWindow whose header bar carries its own "Fit" // content of a GtkApplicationWindow whose header bar carries its own "Fit"
// button, with GTK drawing the window chrome around a plot the area renders into // button, with GTK drawing the window chrome around a plot the area renders into
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The Dear ImGui application: the same stream the GLFW demo draws, embedded as a // The Dear ImGui application: the same stream the GLFW demo draws, embedded as a
// widget inside an ImGui window that also owns a few controls, with ImGui drawing // widget inside an ImGui window that also owns a few controls, with ImGui drawing
// its own chrome into the same framebuffer. // its own chrome into the same framebuffer.
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The multi-plot application: three plots stacked in one `Figure`, sharing one x // The multi-plot application: three plots stacked in one `Figure`, sharing one x
// axis, each carrying its own signals and its own legend. // axis, each carrying its own signals and its own legend.
// //
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The Qt application: the same 1 MS/s stream the other demos draw, as the central // The Qt application: the same 1 MS/s stream the other demos draw, as the central
// widget of a QMainWindow that also carries its own toolbar, with Qt drawing the // widget of a QMainWindow that also carries its own toolbar, with Qt drawing the
// window chrome around a plot the widget renders into its own framebuffer. // window chrome around a plot the widget renders into its own framebuffer.
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <cstddef> #include <cstddef>
#include <span> #include <span>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <vector> #include <vector>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#ifdef PLOT_PYRAMID_COUNT_COVERS #ifdef PLOT_PYRAMID_COUNT_COVERS
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#ifdef PLOT_PYRAMID_COUNT_MERGES #ifdef PLOT_PYRAMID_COUNT_MERGES
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <deque> #include <deque>
#include <vector> #include <vector>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <atomic> #include <atomic>
#include <bit> #include <bit>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <vector> #include <vector>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <vector> #include <vector>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/gl/api.hpp> #include <plot/gl/api.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/color.hpp> #include <plot/core/color.hpp>
#include <plot/core/status.hpp> #include <plot/core/status.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/gl/api.hpp> #include <plot/gl/api.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/rect.hpp> #include <plot/core/rect.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <cmath> #include <cmath>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <cmath> #include <cmath>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
/// Umbrella header: pulls in every public component of the plot library, in /// Umbrella header: pulls in every public component of the plot library, in
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <string> #include <string>
+10
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <string> #include <string>
#include <vector> #include <vector>
@@ -76,6 +79,13 @@ struct Axis {
/// group stops following or the row leaves it. /// group stops following or the row leaves it.
bool live_owner = false; bool live_owner = false;
f64 live_owner_newest = 0.0; f64 live_owner_newest = 0.0;
/// Set while this plot is a member of a `Figure` x link group (`Figure::link_x()`
/// writes it, `unlink_row()` clears it). The group owns the shared x window *and*
/// its follow state -- `Figure::link_x()` refuses a `Live` member joiner -- so
/// `Plot::set_x_follow(Live)` on this axis is refused after linking too, rather
/// than letting a later call put the member's axis back into a state the group
/// cannot drive (see `Plot::set_x_follow()`).
bool link_member = false;
/// Ticks for the last `refresh()`, ascending, majors and (when asked) minors. /// Ticks for the last `refresh()`, ascending, majors and (when asked) minors.
std::vector<Tick> ticks; std::vector<Tick> ticks;
+6 -1
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <string> #include <string>
#include <vector> #include <vector>
@@ -19,7 +22,9 @@ namespace plot {
/// and `dash_off` are independent, so a caller may override only the dash, or only the /// and `dash_off` are independent, so a caller may override only the dash, or only the
/// width, and leave the rest to the style. /// width, and leave the rest to the style.
struct CrosshairStyle { struct CrosshairStyle {
f32 width = -1.0f; ///< logical stroke width; `< 0` takes `Style::crosshair_width`. f32 width = -1.0f; ///< logical stroke width; `< 0` takes `Style::crosshair_width`
///< (the default). Only a *negative* value defers: `0` is used
///< literally and draws an invisible line.
f32 dash_on = -1.0f; ///< logical ink length; `< 0` takes `Style::crosshair_dash_on`. f32 dash_on = -1.0f; ///< logical ink length; `< 0` takes `Style::crosshair_dash_on`.
f32 dash_off = -1.0f; ///< logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0` f32 dash_off = -1.0f; ///< logical gap; `< 0` takes `Style::crosshair_dash_off`, and `0`
///< (once resolved) means solid. ///< (once resolved) means solid.
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <string> #include <string>
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#ifdef PLOT_COUNT_MEASURE_RECOMPUTES #ifdef PLOT_COUNT_MEASURE_RECOMPUTES
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <span> #include <span>
#include <vector> #include <vector>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <vector> #include <vector>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <vector> #include <vector>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
#include <plot/data/series.hpp> #include <plot/data/series.hpp>
+21 -3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <vector> #include <vector>
@@ -326,6 +329,11 @@ class Plot {
/// newest sample* rather than a pair of numbers. With no live edge -- no trace on /// newest sample* rather than a pair of numbers. With no live edge -- no trace on
/// the axis, or none of them streaming -- nothing is recomputed and the axis /// the axis, or none of them streaming -- nothing is recomputed and the axis
/// keeps its stored range, `auto_scale` included, exactly as an `Off` axis does. /// keeps its stored range, `auto_scale` included, exactly as an `Off` axis does.
///
/// On an axis that is a member of a `Figure` x link group, arming `Live` is
/// **refused** (the state is left as it was): the group owns the shared window and
/// its follow state, exactly as `Figure::link_x()` refuses a `Live` member joiner.
/// See `set_x_link_member()`.
void set_x_follow(u32 axis, XFollow f); void set_x_follow(u32 axis, XFollow f);
/// The follow state of one x axis. `Off` for an axis that does not exist. /// The follow state of one x axis. `Off` for an axis that does not exist.
XFollow x_follow(u32 axis) const; XFollow x_follow(u32 axis) const;
@@ -360,6 +368,14 @@ class Plot {
/// axis no owner has claimed, and for an axis that does not exist. /// axis no owner has claimed, and for an axis that does not exist.
bool x_live_owner(u32 axis, f64& newest) const noexcept; bool x_live_owner(u32 axis, f64& newest) const noexcept;
/// Publishes -- or clears -- that this axis is a member of a `Figure` x link group.
/// `Figure::link_x()` sets it when a row joins and `unlink_row()` clears it when one
/// leaves; it is what makes `set_x_follow(Live)` refused on a linked axis, because
/// the group owns the shared window and the follow state. A no-op for an axis that
/// does not exist. Not for callers outside the figure: a `Plot` that is not in a
/// figure never has a linked axis.
void set_x_link_member(u32 axis, bool linked) noexcept;
/// Data -> pixels for one axis, built from the axis' range, log flag and the /// Data -> pixels for one axis, built from the axis' range, log flag and the
/// current layout. Always invertible: a collapsed range or a collapsed axes /// current layout. Always invertible: a collapsed range or a collapsed axes
/// rect is widened by math::Transform, so to_data_*(to_screen_*(v)) == v. /// rect is widened by math::Transform, so to_data_*(to_screen_*(v)) == v.
@@ -551,9 +567,11 @@ class Plot {
/// A floor on the left gutter `layout()` reserves, in device pixels: the label /// A floor on the left gutter `layout()` reserves, in device pixels: the label
/// strip and the title column together are never narrower than this, so `axes` /// strip and the title column together are never narrower than this, so `axes`
/// never starts left of `viewport().x + min_y_gutter()`. 0.0 -- the default -- /// starts no further left than `viewport().x + min_y_gutter()` **up to the cap
/// imposes nothing and the plot sizes its strip from its own widest tick label, /// below** -- a floor wider than half the viewport is clamped exactly as the
/// which is what a plot standing on its own has always done. /// measured strip is, so the guarantee is `min(min_y_gutter(), y_budget)`. 0.0
/// -- the default -- imposes nothing and the plot sizes its strip from its own
/// widest tick label, which is what a plot standing on its own has always done.
/// ///
/// It exists for `Figure`, which is the only thing that can know the answer: a row /// It exists for `Figure`, which is the only thing that can know the answer: a row
/// cannot see its siblings' tick labels, so the figure lays each row out once to /// cannot see its siblings' tick labels, so the figure lays each row out once to
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <string> #include <string>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <string> #include <string>
#include <vector> #include <vector>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/color.hpp> #include <plot/core/color.hpp>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/status.hpp> #include <plot/core/status.hpp>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#ifdef PLOT_RING_STATS #ifdef PLOT_RING_STATS
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
#include <plot/math/vec2.hpp> #include <plot/math/vec2.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
#include <plot/math/vec2.hpp> #include <plot/math/vec2.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/color.hpp> #include <plot/core/color.hpp>
#include <plot/core/status.hpp> #include <plot/core/status.hpp>
+11 -9
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <span> #include <span>
#include <vector> #include <vector>
@@ -281,15 +284,14 @@ class Renderer {
/// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per /// Writes the framebuffer's viewport region as a binary PPM (P6, 8 bits per
/// channel, rows top-down) so a headless render can be looked at without a /// channel, rows top-down) so a headless render can be looked at without a
/// window. Reads the framebuffer that is bound now, which is the one the frame /// window. Reads the framebuffer that is bound now, which is the one the frame
/// was drawn into. Returns false, with `st` filled in, on a bad argument or an /// was drawn into. Returns false, with `st` filled in, on a bad argument, an
/// unreadable pixel. /// unreadable pixel, or an allocation the readback could not make -- which is
/// /// reported as `ErrorCode::OutOfMemory`, exactly as `TrueTypeFont::from_file()`
/// The one entry point here that is not `noexcept`: it sizes its readback hole /// reports the same failure. `noexcept` like every other entry point here: the
/// and its output row, so an allocation failure propagates `std::bad_alloc` /// buffers are sized before anything is on disk, so the two failures that can
/// rather than terminating a process that could have carried on without a /// arise from sizing them are a `Status`, not a `std::bad_alloc` across the API
/// screenshot. A dump is a diagnostic, and no `Status` code could say more than /// boundary and a half-written file.
/// the exception already does. bool dump_frame_ppm(const char* path, Status* st = nullptr) const noexcept;
bool dump_frame_ppm(const char* path, Status* st = nullptr) const;
private: private:
/// The backend the plot layer sees, at the size the text pipeline draws it: /// The backend the plot layer sees, at the size the text pipeline draws it:
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
namespace plot::render { namespace plot::render {
+9 -2
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/color.hpp> #include <plot/core/color.hpp>
@@ -50,8 +53,12 @@ struct Style {
/// (`Trace`'s default, 0x40C0FF) that a hue-based reading cannot tell from a cyan crosshair -- /// (`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 /// 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 /// 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 /// the spec asks for.
/// already distinct, so no dashes. ///
/// The stroke is thin and dashed by default -- see `crosshair_width`, `crosshair_dash_on` and
/// `crosshair_dash_off` below for the values: thinner than a trace so the instrument reads as
/// chrome rather than data, and dashed so it is distinguishable by *form* as well as by
/// lightness.
Color crosshair = Color::hex(0xE6EBF2); Color crosshair = Color::hex(0xE6EBF2);
/// The crosshair's stroke and dash, in logical pixels (spec §17). The crosshair is a /// The crosshair's stroke and dash, in logical pixels (spec §17). The crosshair is a
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <cmath> #include <cmath>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/status.hpp> #include <plot/core/status.hpp>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <plot/core/status.hpp> #include <plot/core/status.hpp>
#include <plot/core/types.hpp> #include <plot/core/types.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#pragma once #pragma once
#include <memory> #include <memory>
#include <span> #include <span>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/core/status.hpp> #include <plot/core/status.hpp>
namespace plot { namespace plot {
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/array_series.hpp> #include <plot/data/array_series.hpp>
#include <algorithm> #include <algorithm>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/detail/cover_count.hpp> #include <plot/data/detail/cover_count.hpp>
#include <plot/data/detail/merge_count.hpp> #include <plot/data/detail/merge_count.hpp>
#include <plot/data/minmax_pyramid.hpp> #include <plot/data/minmax_pyramid.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/series.hpp> #include <plot/data/series.hpp>
#include <algorithm> #include <algorithm>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/data/stream_series.hpp> #include <plot/data/stream_series.hpp>
#include <algorithm> #include <algorithm>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/gl/handle.hpp> #include <plot/gl/handle.hpp>
#include <plot/gl/loader.hpp> #include <plot/gl/loader.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/gl/loader.hpp> #include <plot/gl/loader.hpp>
#include <plot/gl/caps.hpp> #include <plot/gl/caps.hpp>
+15 -4
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/input/controller.hpp> #include <plot/input/controller.hpp>
#include <plot/math/transform.hpp> #include <plot/math/transform.hpp>
@@ -605,10 +608,18 @@ void Controller::axis_zoom(const PointerEvent& e) {
// A pointer drag reports pixels, not wheel notches, so it uses its own // A pointer drag reports pixels, not wheel notches, so it uses its own
// sensitivity: 200 px is one decade. The gain is a physical distance the hand // sensitivity: 200 px is one decade. The gain is a physical distance the hand
// moves, so the device-pixel delta is converted to logical pixels first -- the // moves, so the device-pixel delta is converted to logical pixels first -- the
// same drag must zoom the same amount on a 2x display. A scale no host should // same drag must zoom the same amount on a 2x display.
// ever set (0, or negative) makes the factor non-positive, and the guard below //
// turns that into no zoom at all rather than an axis re-centred on infinity. // A scale that is not a positive number is not a scale -- a host that has not
const f64 logical_dy = static_cast<f64>(e.dy) / static_cast<f64>(plot_->ui_scale()); // measured its display, a field nobody filled in -- the same state
// `Style::device()` tolerates. Here it means *no zoom*: dividing by it would make
// `logical_dy` infinite and the factor infinite (or NaN), and that factor reaches
// `set_x_range()`/`set_y_range()`, which store it verbatim and clear `auto_scale`
// -- an axis left non-finite until an auto-fit. A negative scale would silently
// invert the zoom direction too, so the whole class is refused here.
const f64 scale = static_cast<f64>(plot_->ui_scale());
if (!(scale > 0.0) || !std::isfinite(scale)) return;
const f64 logical_dy = static_cast<f64>(e.dy) / scale;
const f64 factor = std::exp(-logical_dy * kZoomPerPixel); const f64 factor = std::exp(-logical_dy * kZoomPerPixel);
if (!(factor > 0.0)) return; if (!(factor > 0.0)) return;
if (mode_ == DragMode::AxisZoomX) { if (mode_ == DragMode::AxisZoomX) {
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/math/transform.hpp> #include <plot/math/transform.hpp>
namespace plot::math { namespace plot::math {
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/axis.hpp> #include <plot/plot/axis.hpp>
namespace plot { namespace plot {
+32 -12
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/figure.hpp> #include <plot/plot/figure.hpp>
#include <algorithm> #include <algorithm>
@@ -7,16 +10,17 @@
namespace plot { namespace plot {
namespace { namespace {
/// The height a row never drops below, in device pixels: the floor that keeps a /// The height a row never drops below, in **logical** pixels -- the same unit as every other
/// plot's chrome from erasing it in a figure with many rows or a small viewport. It /// chrome size, converted to device pixels by the row's own `ui_scale()` where the floor is
/// yields to the figure -- it is capped at an equal share of the height there is -- /// applied. The floor that keeps a plot's chrome from erasing it in a figure with many rows
/// rather than pushing rows past the viewport. /// or a small viewport. It yields to the figure -- it is capped at an equal share of the
/// height there is -- rather than pushing rows past the viewport.
/// ///
/// Device pixels, like every rect a figure computes: a `Figure` carries no `Style` /// A `Figure` carries no `Style` -- the renderer's is the one its rows are laid out with --
/// -- the renderer's is the one its rows are laid out with -- so there is no logical /// but every row's `Plot` has the `ui_scale` the renderer installed on it, so the conversion
/// unit here to convert from. A host that wants a taller floor gives the figure a /// is the row's own and the guarantee is the same *physical* height at any DPI. A host that
/// taller viewport, or gives the rows weights. /// wants a taller floor gives the figure a taller viewport, or gives the rows weights.
constexpr f32 kMinRowHeightPx = 24.0f; constexpr f32 kMinRowHeightLogical = 24.0f;
} // namespace } // namespace
@@ -69,8 +73,12 @@ u32 Figure::x_link(u32 row) const noexcept {
void Figure::unlink_row(u32 row) noexcept { void Figure::unlink_row(u32 row) noexcept {
// A row that leaves its group stops following anything, so the hint a following group // A row that leaves its group stops following anything, so the hint a following group
// published to it goes with the membership: leaving `has_live` set behind would leave a // published to it goes with the membership: leaving `has_live` set behind would leave a
// former member anchoring a zoom on an edge it no longer follows. // former member anchoring a zoom on an edge it no longer follows. Its linkage flag goes
if (row < plots_.size()) plots_[row].set_x_live_owner(0u, false, 0.0); // with it too: an unlinked row is an ordinary plot again, free to arm `Live` itself.
if (row < plots_.size()) {
plots_[row].set_x_live_owner(0u, false, 0.0);
plots_[row].set_x_link_member(0u, false);
}
for (XLink& group : links_) { for (XLink& group : links_) {
for (std::size_t i = 0; i < group.rows.size(); ++i) { for (std::size_t i = 0; i < group.rows.size(); ++i) {
if (group.rows[i] != row) continue; if (group.rows[i] != row) continue;
@@ -111,6 +119,10 @@ Status Figure::link_x(u32 row, u32 group) {
const std::size_t index = static_cast<std::size_t>(at - g.rows.begin()); const std::size_t index = static_cast<std::size_t>(at - g.rows.begin());
g.rows.insert(at, row); g.rows.insert(at, row);
g.pushed.insert(g.pushed.begin() + static_cast<std::ptrdiff_t>(index), Range{}); g.pushed.insert(g.pushed.begin() + static_cast<std::ptrdiff_t>(index), Range{});
// A member's axis 0 is now owned by the group (its window and its follow state), so
// `set_x_follow(Live)` on it is refused from here on -- the same rule the refusal above
// applies at the door.
p.set_x_link_member(0u, true);
if (!g.has_range) { if (!g.has_range) {
// The first row into a group is the one that frames it: there is no shared range // The first row into a group is the one that frames it: there is no shared range
// to apply yet, and adopting this row's is what makes the next joiner land on a // to apply yet, and adopting this row's is what makes the next joiner land on a
@@ -353,7 +365,15 @@ const FigureLayout& Figure::layout() {
// heights still sum to exactly the usable height, so the rows tile the figure and // heights still sum to exactly the usable height, so the rows tile the figure and
// the last one ends on its bottom edge. // the last one ends on its bottom edge.
const bool equal = row_weights_.empty(); const bool equal = row_weights_.empty();
const f32 floor_px = std::min(kMinRowHeightPx, usable / static_cast<f32>(rows)); // The floor is a logical size like every other chrome metric, so it is converted through
// the row's own `ui_scale()` -- the one the renderer installed on every row -- which is
// what makes the guarantee the same *physical* height at any DPI instead of half as strong
// at 2x exactly where the chrome it protects is twice as large. A scale that is not
// positive is not a scale and converts 1:1, exactly as `Style::device()` documents.
const f32 floor_scale = plots_[0].ui_scale();
const f32 floor_device = floor_scale > 0.0f ? kMinRowHeightLogical * floor_scale
: kMinRowHeightLogical;
const f32 floor_px = std::min(floor_device, usable / static_cast<f32>(rows));
const f32 extra = usable - floor_px * static_cast<f32>(rows); const f32 extra = usable - floor_px * static_cast<f32>(rows);
f32 total_w = 0.0f; f32 total_w = 0.0f;
for (const f32 w : row_weights_) total_w += w; for (const f32 w : row_weights_) total_w += w;
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/grid.hpp> #include <plot/plot/grid.hpp>
#include <algorithm> #include <algorithm>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/measure.hpp> #include <plot/plot/measure.hpp>
#include <cmath> #include <cmath>
+33
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/plot.hpp> #include <plot/plot/plot.hpp>
#include <plot/plot/detail/measure_count.hpp> #include <plot/plot/detail/measure_count.hpp>
@@ -391,6 +394,13 @@ void Plot::set_y_auto_scale(u32 axis, bool on) {
void Plot::set_x_follow(u32 axis, XFollow f) { void Plot::set_x_follow(u32 axis, XFollow f) {
if (axis >= x_axes_.size()) return; if (axis >= x_axes_.size()) return;
// A figure link group owns this axis' shared window *and* its follow state:
// `Figure::link_x()` refuses a `Live` member joiner, and arming `Live` here *after*
// linking would be the same state reached by the back door -- `push_link()` would
// write the group's range into an axis that reinterprets it against its own live
// edge, `adopt_member_changes()` would read it straight back as an external change,
// and the group would track one member instead of the union of them. Refused.
if (f == XFollow::Live && x_axes_[axis].link_member) return;
x_axes_[axis].follow = f; x_axes_[axis].follow = f;
// Arming a live axis places its window at once, so x_range() reports the window // Arming a live axis places its window at once, so x_range() reports the window
// the next layout produces rather than the one the axis held while it was Off. // the next layout produces rather than the one the axis held while it was Off.
@@ -399,6 +409,11 @@ void Plot::set_x_follow(u32 axis, XFollow f) {
place_live_x_window(axis); place_live_x_window(axis);
} }
void Plot::set_x_link_member(u32 axis, bool linked) noexcept {
if (axis >= x_axes_.size()) return;
x_axes_[axis].link_member = linked;
}
XFollow Plot::x_follow(u32 axis) const { XFollow Plot::x_follow(u32 axis) const {
return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off; return axis < x_axes_.size() ? x_axes_[axis].follow : XFollow::Off;
} }
@@ -703,6 +718,24 @@ void Plot::snap_region(u32 id) {
void Plot::remove_region(u32 id) { void Plot::remove_region(u32 id) {
if (id >= regions_.size()) return; if (id >= regions_.size()) return;
regions_.erase(regions_.begin() + static_cast<std::ptrdiff_t>(id)); regions_.erase(regions_.begin() + static_cast<std::ptrdiff_t>(id));
// The published hot handles name a region by index and are re-indexed with everything
// else: the removed region's own handle is cleared (nothing is hot there any more, and a
// frame drawn before the next event would otherwise highlight the region that slid into
// its slot), and a handle naming a region above the hole follows it down. The `zone` is
// the grab that went with that index, so it is cleared with it -- a stale zone describing
// a region that is no longer there would colour the next region's bound.
//
// A host must not remove a region *mid-drag*: the controller holds the id it grabbed, and
// this setter cannot re-index a controller's private `drag_region_`. The gesture's release
// would then edit whichever region now carries that id. Remove regions between gestures.
if (hot_.region != kInvalid) {
if (hot_.region == id) {
hot_.region = kInvalid;
hot_.zone = RegionGrab::None;
} else if (hot_.region > id) {
hot_.region -= 1u;
}
}
// The cache is keyed by region index, so the removed region's entries name nothing // The cache is keyed by region index, so the removed region's entries name nothing
// and every entry above the hole names a region one slot further down. The // and every entry above the hole names a region one slot further down. The
// surviving regions' own intervals are untouched -- erase keeps their order -- so // surviving regions' own intervals are untouched -- erase keeps their order -- so
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/plot/ticks.hpp> #include <plot/plot/ticks.hpp>
#include <cmath> #include <cmath>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/render/buffer.hpp> #include <plot/render/buffer.hpp>
#include <plot/gl/api.hpp> #include <plot/gl/api.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// GENERATED FILE -- do not edit by hand. // GENERATED FILE -- do not edit by hand.
// //
// Regenerate with: tools/fonts/gen_atlas.py // Regenerate with: tools/fonts/gen_atlas.py
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/render/primitives.hpp> #include <plot/render/primitives.hpp>
#include <cassert> #include <cassert>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/render/program.hpp> #include <plot/render/program.hpp>
#include <plot/render/shaders.hpp> #include <plot/render/shaders.hpp>
+31 -6
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/render/renderer.hpp> #include <plot/render/renderer.hpp>
#include <plot/gl/api.hpp> #include <plot/gl/api.hpp>
@@ -9,6 +12,7 @@
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <new>
#include <vector> #include <vector>
namespace plot { namespace plot {
@@ -1120,6 +1124,10 @@ void Renderer::draw_measures(const Plot& plot, const PlotLayout& layout) {
void Renderer::draw_annotations(const Plot& plot) { void Renderer::draw_annotations(const Plot& plot) {
const f32 line_h = font_backend_.height(); const f32 line_h = font_backend_.height();
// The clip every other overlay is drawn under, read here because the caption is staged
// into the frame's single text batch -- flushed after `clear_clip()` -- so the scissor that
// keeps the rest of this pass inside the axes does not reach it.
const Rect& axes = plot.layout().axes;
for (u32 i = 0; i < plot.annotation_count(); ++i) { for (u32 i = 0; i < plot.annotation_count(); ++i) {
const Annotation& annotation = plot.annotation(i); const Annotation& annotation = plot.annotation(i);
if (!annotation.visible) continue; if (!annotation.visible) continue;
@@ -1131,7 +1139,14 @@ void Renderer::draw_annotations(const Plot& plot) {
lines_.add_segment({px - style_.device(3.0f), py}, {px + style_.device(3.0f), py}); lines_.add_segment({px - style_.device(3.0f), py}, {px + style_.device(3.0f), py});
lines_.add_segment({px, py - style_.device(3.0f)}, {px, py + style_.device(3.0f)}); lines_.add_segment({px, py - style_.device(3.0f)}, {px, py + style_.device(3.0f)});
flush_lines(pixel_mapping(), annotation.color, style_.device(1.0f)); flush_lines(pixel_mapping(), annotation.color, style_.device(1.0f));
if (!annotation.text.empty()) { // A caption whose anchor is outside the axes is skipped with its cross, which the clip
// has already removed: a caption with no cross is a label for a point the frame does not
// show, and -- because the text batch is drawn *after* the clip is released -- it would
// otherwise paint outside the axes it is measured in, into the neighbouring row of a
// `Figure` whose rows tile the figure. Splitting the frame's one text draw call to clip
// it instead would cost a draw call per row and mis-clip the tick labels, which are
// staged outside `axes` on purpose and share the batch after this one.
if (!annotation.text.empty() && axes.contains(px, py)) {
stage_text({px + style_.device(5.0f), py - 0.5f * line_h}, annotation.text.c_str()); stage_text({px + style_.device(5.0f), py - 0.5f * line_h}, annotation.text.c_str());
} }
} }
@@ -1259,7 +1274,7 @@ void Renderer::draw_crosshair_readout(const Plot& plot, const PlotLayout& layout
// -------------------------------------------------------------------- dump // -------------------------------------------------------------------- dump
bool Renderer::dump_frame_ppm(const char* path, Status* st) const { bool Renderer::dump_frame_ppm(const char* path, Status* st) const noexcept {
if (path == nullptr || path[0] == '\0') { if (path == nullptr || path[0] == '\0') {
return fail(st, ErrorCode::InvalidArgument, "PPM path is empty"); return fail(st, ErrorCode::InvalidArgument, "PPM path is empty");
} }
@@ -1272,9 +1287,20 @@ bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
return fail(st, ErrorCode::InvalidArgument, "no frame has been drawn yet"); return fail(st, ErrorCode::InvalidArgument, "no frame has been drawn yet");
} }
// RGBA, not RGB: 4-byte pixels have no row padding whatever the pack alignment, // The two buffers are sized -- and can fail to size -- before anything is on disk, so an
// so a host's GL_PACK_ALIGNMENT cannot make the read overrun the buffer. // allocation failure is reported through the `Status` rather than throwing `std::bad_alloc`
readback_.assign(usize(width) * height * 4u, 0u); // across the public boundary (which is `noexcept`) and leaving a half-written file behind.
// The readback hole is RGBA, not RGB: 4-byte pixels have no row padding whatever the pack
// alignment, so a host's GL_PACK_ALIGNMENT cannot make the read overrun the buffer. The row
// is the PPM's own 3-byte stride, one line at a time.
std::vector<u8> row;
try {
readback_.assign(usize(width) * height * 4u, 0u);
row.assign(usize(width) * 3u, 0u);
} catch (const std::bad_alloc&) {
return fail(st, ErrorCode::OutOfMemory, "out of memory sizing the PPM readback");
}
gl::glPixelStorei(gl::GL_PACK_ALIGNMENT, 4); gl::glPixelStorei(gl::GL_PACK_ALIGNMENT, 4);
gl::glReadPixels(static_cast<gl::GLint>(gl_viewport_.x), static_cast<gl::GLint>(gl_viewport_.y), gl::glReadPixels(static_cast<gl::GLint>(gl_viewport_.x), static_cast<gl::GLint>(gl_viewport_.y),
static_cast<gl::GLsizei>(width), static_cast<gl::GLsizei>(height), gl::GL_RGBA, static_cast<gl::GLsizei>(width), static_cast<gl::GLsizei>(height), gl::GL_RGBA,
@@ -1285,7 +1311,6 @@ bool Renderer::dump_frame_ppm(const char* path, Status* st) const {
return fail(st, ErrorCode::InvalidArgument, "cannot open the PPM path for writing"); return fail(st, ErrorCode::InvalidArgument, "cannot open the PPM path for writing");
} }
std::fprintf(file, "P6\n%u %u\n255\n", width, height); std::fprintf(file, "P6\n%u %u\n255\n", width, height);
std::vector<u8> row(usize(width) * 3u);
bool ok = true; bool ok = true;
for (u32 y = 0; y < height && ok; ++y) { for (u32 y = 0; y < height && ok; ++y) {
// glReadPixels hands back rows bottom-up; a PPM's rows run top-down. // glReadPixels hands back rows bottom-up; a PPM's rows run top-down.
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/render/shaders.hpp> #include <plot/render/shaders.hpp>
// Both pipelines are `#version 330 core` and stay untouched by the upload // Both pipelines are `#version 330 core` and stay untouched by the upload
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/render/text.hpp> #include <plot/render/text.hpp>
namespace plot::render { namespace plot::render {
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/render/text_atlas.hpp> #include <plot/render/text_atlas.hpp>
#include <plot/gl/api.hpp> #include <plot/gl/api.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include <plot/render/vertex_array.hpp> #include <plot/render/vertex_array.hpp>
#include <plot/gl/api.hpp> #include <plot/gl/api.hpp>
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The vendored rasteriser's single implementation unit: the one translation unit // The vendored rasteriser's single implementation unit: the one translation unit
// in this project that defines STB_TRUETYPE_IMPLEMENTATION, so the ~5000-line // in this project that defines STB_TRUETYPE_IMPLEMENTATION, so the ~5000-line
// stb_truetype.h is compiled exactly once, into the optional plot_font_ttf // stb_truetype.h is compiled exactly once, into the optional plot_font_ttf
+3
View File
@@ -1,3 +1,6 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
// The optional TrueType backend: the one place in the project that parses a font // The optional TrueType backend: the one place in the project that parses a font
// file at run time, and the only consumer of the vendored third_party/stb_truetype.h // file at run time, and the only consumer of the vendored third_party/stb_truetype.h
// (see src/text/stb_truetype_impl.cpp for its single implementation unit, and // (see src/text/stb_truetype_impl.cpp for its single implementation unit, and
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more