udpscope: an oscilloscope view for a UDPStreamer source

GTK4 and libadwaita. It receives one UDP stream through the vendored
udpsclient, buffers it in a large ring, and draws it with the vendored
plotter: a trigger with a pre/post event window, a pause that freezes the
window, cursors, measurements, math channels in a node editor, an event cache,
named configurations, preferences, and an export of the visible window to
.npy/.csv. Waveform files can be opened and analysed in place.

The interface is two threads -- a producer that owns the socket and the GTK
main thread that owns every widget and the GL context -- sharing one model
under one mutex, with samples crossing through lock-free rings.

deps/plotter is a submodule pinned to the commit this tree builds against;
deps/udpsclient is vendored in place. docs/claude/ documents the rules that
cost a defect to learn, and SPECS.md is the requirement document.
This commit is contained in:
Martino Ferrari committed 2026-10-11 15:21:40 +02:00
1 parent 8f54841c2c
commit 0e256764d5
41 files changed
+2078 -151

No files matched your search

+27
View File
@@ -78,6 +78,30 @@ add_custom_command(
COMMENT "Compiling udpscope help resources"
VERBATIM)
# --- UI files: GtkBuilder XML in a GResource ----------------------------------
# The third bundle, for the same reason as the other two: `.ui` files are data, so they are compiled
# in rather than looked for at runtime -- and `preprocess="xml-stripblanks"` makes a malformed one a
# build failure with a file and line number, instead of a window that silently refuses to open.
set(UDPSCOPE_UI_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/ui)
file(GLOB UDPSCOPE_UI_FILES CONFIGURE_DEPENDS ${UDPSCOPE_UI_DIR}/*.ui)
add_custom_command(
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
COMMAND ${GLIB_COMPILE_RESOURCES}
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
--sourcedir=${UDPSCOPE_UI_DIR}
--c-name=udpscope_ui
--generate-source
${UDPSCOPE_UI_DIR}/udpscope-ui.gresource.xml
DEPENDS ${UDPSCOPE_UI_DIR}/udpscope-ui.gresource.xml ${UDPSCOPE_UI_FILES}
COMMENT "Compiling udpscope UI resources"
VERBATIM)
# The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the
# 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon and
# help bundles are small enough per file never to need it), so the build stays at its four warnings.
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
# --- build info: version, commit and timestamp compiled into the binary -------
# Written at configure time so add_executable can name the file, and again on every
# build so the commit and the timestamp belong to the binary that is running rather
@@ -100,6 +124,7 @@ set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)
add_executable(udpscope
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
${UDPSCOPE_BUILD_INFO}
src/main.cpp
src/config.cpp
@@ -121,6 +146,8 @@ add_executable(udpscope
src/status_text.cpp
src/shortcuts.cpp
src/help_window.cpp
src/settings.cpp
src/ui_prefs.cpp
src/ui.cpp
src/ui_probe.cpp
src/ui_widgets.cpp
+23 -1
View File
@@ -455,7 +455,7 @@ Trigger mode keeps the last **32 triggered acquisitions** in a cache, so an even
time to look at is still there:
```
~/.cache/udpscope/192.168.1.40-8000/events/
~/.cache/udpscope/events/192.168.1.40-8000/
20261009-123250-0001.npy # the event, in the export format
20261009-123250-0001.txt # when, how long, how many signals, the rate, the edge
20261009-123250-0001.bmp # a picture of it, drawn from the samples
@@ -600,6 +600,28 @@ A session written before configurations existed is imported on first use as the
named `default`: copied into the directory, remembered as active, and the old file renamed to
`<host>_<port>.toml.imported` rather than deleted, so the import can be undone by hand.
## Preferences
`~/.config/udpscope/settings.toml`, written by **Preferences…** -- in the overflow menu or the source
chooser -- and read at start-up. It holds the event cache's directory, how many events to keep (`0`
keeps every one), whether relative time is on, and the interface's theme. Whether anything is cached at
all is the trigger pane's own switch, and there is deliberately no second one here; the directory is
the one place every source gets a folder inside, `<cache>/udpscope/events` by default. The cache
directory and relative time are *start-up* settings -- the buffers are planned and the cache directory
is opened before any source is known -- and the dialog says so; the theme applies as soon as it is
chosen. A key that is absent means its default, so the file only mentions what changed, and a missing
file is a first run rather than an error. The directory field shows the directory in use: the default
is filled in rather than described, and typing it back leaves the setting unset, so it goes on
following the cache directory instead of pinning one path for good. It can be typed, or picked with the folder
button beside it, which writes what it found into the same field -- the field stays the one value, and
there is nothing to keep in step.
The same dialog is reachable from the source chooser, the one window that exists before a run is
planned. From the app the cache directory and relative time take effect on the next start; from the
chooser they reach the run about to start. A preference applied there also beats a source's stored
configuration, exactly as `--relative-time` does: it is the more recent statement of intent, and it
works in both directions, so turning relative time off in the dialog is not undone by the row you click
afterwards.
## The session layout file
A configuration's file holds the display
+1 -1
+10 -1
View File
@@ -128,7 +128,16 @@ because a tick a hair under a rung's top rounds to 1000 at three significant fig
`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and
`format_time_never_lets_a_field_reach_its_radix`. The minor
`format_time_never_lets_a_field_reach_its_radix`. The box-zoom selection was the one gesture with no
feedback at all: the controller tracked the rect and nothing drew it. The drawing went upstream with
the rest — `Plot::set_selection_rect()` (the plot carries a rect the host hands it and learns nothing
about pointers), `Controller::selection_preview()` (the mask decides which axis is *not* zoomed, and
the preview is widened along it, because the swept sliver of a horizontal Ctrl drag is the gesture and
the band across the plot is what the release will apply) and `Renderer::draw_selection_frame()` — and
it carries three tests: `a_ctrl_box_zoom_previews_the_band_it_will_zoom`,
`the_selection_frame_is_carried_and_taken_off` and `the_box_zoom_frame_lands_exactly_where_it_says`,
the last of which reads the headless framebuffer back and compares the same pixels with and without
the frame. The minor
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
+12
View File
@@ -218,6 +218,18 @@ come back with a different answer.
Relative time's `slot_base_` also survives: it is samples-since-the-stream-began, the user's
chosen origin, and must not jump backwards because the link blipped.
## There may be no store at all
`rebuild_locked()` calls `store_.reset()` for a stream with **no channels** — the model's state until the
first CONFIG arrives — so `store_` is legitimately null, and `ScopeModel::live()` dereferences it. Every
path reachable without a stream has to ask first: **pausing a scope that is not connected** is one (the
button works, and freezing a view means taking a window from the rings), and it crashed inside
`live_window_locked()` on a null store. The guards belong at the entry points the UI can reach
(`live_window_locked()`, `publish_pause_view()`), not in `live()`, which cannot express "nothing there"
through a reference. The paths that only run for a *bound* trace — the series' gather, the SlotSeries'
`shared_channel()` — are safe by construction: a slot with no channel binds no series at all.
`UDPSCOPE_PROBE_PAUSE_MS` against a port nothing is listening on is the check.
## A waveform is a source that never arrives
`--open FILE` (and the chooser's Waveforms page) puts an exported `.npy` into the model instead of a
+32
View File
@@ -210,3 +210,35 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
Caps Lock is on.
- **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live in
`src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`,
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`. `preprocess="xml-stripblanks"`
catches a *malformed* file at build time -- and nothing else does, which is why it is on every file in
every bundle. What it cannot catch is a file that is valid XML but wrong for GTK: `GtkBuilder` reports
that with a **`Gtk-ERROR`, which aborts the process** by default, so a window built by hand from bad
markup takes the application down rather than failing to appear. The `[help]` probe opening every
window is what catches that. Two specifics already paid for: a `GIcon` property takes an **object**, not
a name (`<property name="icon">foo</property>` is read as a reference to another object and fails with
"Object with ID foo not found" -- the icon is a nested `GThemedIcon`), and a `.ui` big enough to exceed
4095 characters needs `-Wno-overlength-strings` on its generated source, because glib-compile-resources
writes each file as one C string literal and the warning fires on generated *data* (the icon and help
bundles are small enough per file never to need it).
- **Deprecated widgets may be the right widget, as long as no deprecated *function* is called.** The
shortcut overview is a `GtkShortcutsWindow` (`gtk/deprecated/`, deprecated in 4.20) because libadwaita's
successor, `AdwShortcutsDialog`, cannot draw a mouse gesture: `AdwShortcutsItem` has no icon property at
all, so its shortcut cell can only be an accelerator, and its cell reads "No Shortcut" when there is
none. `GtkShortcutsShortcut` has one generic gesture type whose `icon` *is* drawn. Loading the window
from a `.ui` and presenting it as a `GtkWindow` names no deprecated function, so the build stays at its
four warnings. When such a window closes, its builder is **replaced on the next opening**, not kept: the
window belongs to the builder's object list and a destroyed `GtkWindow` cannot be presented again.
- **A dialog reachable from two windows cannot own its state in either.** The preferences open from
the main window's menu *and* from the source chooser, which exists before `App` does (`main()`
chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the
caller owns — `App::prefs`, `Chooser::prefs` — and what a change *means* is a callback, because it
means different things on the two sides. Both are plain members of their owners, on the same stack
frame, so nothing can outlive the frame that built it.
- **A transient window is destroyed before its parent.** `chooser_finish()` destroys the preferences
window before the chooser, and nulls the pointer *before* the destroy so `close-request` cannot
re-enter a dead window. GTK does not promise to take a transient down with its parent, and a window
whose handlers point into a returned stack frame is how this project already earned one
use-after-free.
@@ -75,3 +75,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
the two cannot drift apart.
- **Relative time is decided by the newest statement, not by the default.** `Config::relative_time_specified`
is set by `--relative-time` and by the preferences — which the source chooser can reach before the run
is planned, so that is the one place a preference still changes *this* run. Both the list's own copy
(`apply_source()`) and a source's stored configuration (`apply_stored_settings()`) only fill in when
nothing newer was said. Without the marker, clicking a source row after applying the preference
silently reverted it — `[sources] picked ... relative=0`, with the dialog having just written `1`.
@@ -65,6 +65,19 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
picture is equally immovable; resuming or leaving trigger mode clears it.
- **An opened file is a pinned view too, and *how* it is pinned matters as much as that it is.** The
file view states its pin from the ring's own stamps every frame, and it used to do that with
`pin_event_window()` -- which writes the group's range as well as the pin's numbers -- so a box zoom
drew its frame and changed nothing: the controller's `set_x_range` went onto the *member*, the next
frame's re-statement put the group's range back, and the branch it fell into (`clamp_to_data()`,
because a file is neither `event_held` nor `Paused` nor `capturing`) never adopts a member into a
group that is not following. That is the wheel-in-single-trigger defect again, in the one mode nobody
re-tested. Two things hold it now: the branch is `Paused || capturing || rows->window_pinned()`, so a
pinned view is always clamped by `hold_pinned_window()`; and the file's per-frame statement uses
`set_data_pin()` -- the pin's numbers -- unless `pinned_window_is()` says the pin is a *different*
window, which is a re-open with a new extent and is a new event, stated in full. `[boxzoom-probe]`'s
`zoomed=` is the guard, and it is a reading to take in **both** modes: live and file must both read
`1`.
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
Two things had to agree for this, and both were wrong in the same direction:
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
+104 -5
View File
@@ -564,7 +564,15 @@ Two lines of the self-check output are assertions in disguise:
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
- `[help] pages=14 parsed=14 shortest='Choosing a source' 1563 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
- `[help] pages=14 parsed=14 shortest='Pause and play' 1627 B; shortcuts: app=12 plot=14 tables=26 ui=26 ui_accel=19 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. The `shortcuts:` numbers are the shortcut overview four ways.
`app` and `plot` are the rows in the C tables -- what the application actually *handles* -- and `tables`
is their sum. `ui`, `ui_accel`, `ui_icon` and `ui_bare` are what `src/ui/shortcuts.ui` *declares*, read
out of the resource with a markup parse rather than off the built widgets, because a tree walk counts
every row twice: `GtkShortcutsWindow` reaches each one from more than one place in its own tree.
**`ui` must equal `tables`** -- two lists of one thing, and a row in one but not the other is a gesture
that works and is not documented, or is documented and does nothing -- and **`ui_bare` must be zero**: a
row with no accelerator and no icon is a cell reading "No Shortcut". `ui_accel + ui_icon == ui`, so those
two also catch a row whose accelerator went missing. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
@@ -892,6 +900,33 @@ popover row reads `failed: <strerror>` — one stderr line, no retry storm again
| `known:` after a rename | one row, under the new name | a rename that leaves the old file, so the chooser lists the same session twice |
| `known:` for a settings-only configuration | a row, though `signals=0` | the listing filter treating "no signals yet" as "nobody configured this", which hides every host the form set up |
## `[sources] prefs`
```
[sources] prefs: opened=1 apply=1
[sources] prefs: dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark cfg_relative=1
```
`UDPSCOPE_PROBE_PICK_MS` + `UDPSCOPE_PROBE_CHOOSER_PREFS=1` opens the preferences from the *chooser*
rather than from the main window's menu, fills the form through the same `prefs_probe_fill_and_apply()`
the app's probe uses, and reports both the file and `cfg_relative` -- what the run about to start will
actually use.
- The second line must echo the probe's input, exactly as `[prefs-probe]` does.
- `cfg_relative=1` is the chooser's half of the contract: relative time is the one preference that can
still change *this* run, because `main()` plans the buffers after this dialog returns.
- The run leaves the preferences window **open** on purpose: the pick that follows destroys the chooser
with the window still up. A `Gtk-CRITICAL` or a crash here is the transient-window lifetime being
wrong, not a cosmetic failure.
- A `[sources] prefs: could not write <path>` line, with the second line reading the *defaults*
(`dir= keep=32 rel=0 theme=auto`), is the signature of the config directory not having been created:
the chooser is the first thing that writes preferences on a fresh install, so it is where a
`mkdir()` that does not make its parents shows up. This reading is why that is now caught here rather
than reported by a user whose settings quietly did not stick.
- The line after it, `[sources] picked ... relative=1`, is the pair that matters: the preference stated
in the dialog has to survive the row click that follows, which overwrites the rest of the config from
the source's stored session.
## `[sources] settings from configuration`
```
@@ -940,12 +975,12 @@ configuration that carries settings, is the defect this exists for — a saved h
[events] live, recording off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
[events] recording on, no trigger: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
[events] after trigger on: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
[events] dir=/tmp/k5/cache/udpscope/127.0.0.1-21023/events n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=1.000100 signals=4 samples=20001 rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1
[events] dir=/tmp/k5/cache/udpscope/events/127.0.0.1-21023 n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=1.000100 signals=4 samples=20001 rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1
[events] activated row0: replaying=1 pane=1 pane_w=240 tick=0 window=2.000 thumbnail=1 thumb_size=126x42 thumb_visible=1
[events] replayed: channels=4 samples=20001 replaying=1 waveform=1
[events] live again: replaying=0 waveform=0
[events] recording off, trigger off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
[events] recording off, trigger on: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
[events] recording off, trigger off: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
[events] recording off, trigger on: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1
[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 pane=1
[events] compare plots: area_b=1 rows_a=1 rows_b=1 list=4 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1
@@ -967,7 +1002,7 @@ the font and the display's scale factor; what it must never be is `0x0`.
| `pane` | 1 once the history button has been clicked | the button's handler not revealing the pane |
| `live` | `button_visible=0 pane=0** | a pane or a button on screen with no trigger mode: a column of nothing beside a live plot |
| `after trigger on` | `button_visible=1 pane=0` | the pane revealed by trigger mode alone (it must be on demand from the toolbar button), or the button missing in trigger mode |
| `recording off, trigger off` and `recording off, trigger on` | `button_visible=0 pane=0 recording=0` in both | the pane surviving its own switch being turned off -- the empty column the item exists to prevent -- or the button coming back with trigger mode while the cache is disabled |
| `recording off, trigger off` and `recording off, trigger on` | `button_visible=1 pane=1 recording=0` in both **once the cache holds anything** (the probe's own run does by this point) | the button going away with the switch when events are already written -- an event on disk outlives the switch that wrote it -- or `pane_w` not being 240 with the pane open. With an *empty* cache the same two lines read `button_visible=0 pane=0`, which is the empty column the item exists to prevent |
| `thumbnail`, `thumb_size`, `thumb_visible` | 1, a non-zero size, and 1 | the picture loaded through `gtk_picture_set_filename()`, which fails silently (the paint-able is there, the allocation is there, nothing is drawn), or a `GtkPicture` given a size request and no cap -- its natural size is its content, so the row's text is squeezed off. `thumb_size` is the reading that catches both |
| `replaying` (last line) | 0 | a replay with no way back: *Live* must start a fresh client and thread |
| `pane_w` | the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered |
@@ -979,3 +1014,67 @@ the font and the display's scale factor; what it must never be is `0x0`.
| `compare sync` | `mirrored=1`, with `y_a` and `y_b` equal to the range the probe set on the first plot | the mirror not running, or running the wrong way: a y (or x span) change in one plot that the other never sees |
| `accumulate` | `kind=2 rows=1 channels=8 samples0=33045 traces0=8 window=2.000` | the second event's layers not landing: `traces0` stays at the four signals the layout holds when the slots were not free, or the row named after a previous group's suffix so nothing binds |
| `group left` | `replaying=0 kind=0 waveform=0` | the group surviving *Live*: a file view with no file behind it |
## `[boxzoom-probe]`
```
[boxzoom-probe] open: swept=200x10 frame=100,0 200x853 axes_h=853 band=1 axis_before=[0.000000..2.000000]
[boxzoom-probe] closed: frame=0x0 gone=1 axis_after=[0.000000..0.553167] zoomed=1
```
`UDPSCOPE_PROBE_BOXZOOM_MS` injects a Ctrl box zoom straight into row 0's controller -- the way a
headless probe has to, since the area's gestures arrive from GTK -- and reads the frame back off the
*plot* one frame later. That is what makes this the adapter's per-frame sync being exercised: the
library measures and draws the rect in tests of its own.
- `band=1` is the reading that matters. The pointer swept `200x10`, and the frame is `200` wide and
`1665` -- the height of the axes -- tall. Ctrl zooms **x only**, so the band across the plot is what
the release will apply, while the sliver the pointer actually swept is only the gesture. Its `x` and
`y` must be the press point's.
- `closed: frame=0x0 gone=1` is the other half of the contract: a finished gesture leaves no frame, so
nothing goes on claiming a selection is open. Both lines together are the whole feature.
- `axis_before`/`axis_after`/`zoomed` are the *settled* axis, read a few ticks after the release so
that whatever pin or clamp the view has has already had its say -- which is the point: a rect can
report the gesture and nothing about whether it did anything. **Run this in both modes and both must
read `zoomed=1`.** A file opened with `--open` reading `zoomed=0` while the live view reads `1` is the
defect this reading exists for, and it was invisible from the frame alone: the file view pinning its
axis from the ring each frame is what swallowed the gesture.
## `[prefs-probe]`
```
[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
[prefs-probe] default restored: file=''
[prefs-probe] browse: toplevels=3 extra=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
```
`UDPSCOPE_PROBE_PREFS_MS` opens the preferences dialog, fills its form, clicks **Apply** and reads
the settings file *back from disk* -- not from `App::settings`, because a dialog that edits its own
copy and never writes is exactly the failure this has to catch.
- `opened=1` is the window having come up; `apply=0` would mean its *Apply* button was never stored,
which would make the click below test nothing.
- `dir` is the directory the dialog *offers* -- the preference if there is one, otherwise the default
written out in full (it is deliberately not a description: "the standard location" is not something a
user can act on) -- and `store` is the directory the cache is really using. **`store` must be `dir`
plus the source's folder name**, which is the reading that says the field shows the path in force
rather than a plausible string. `dir` here is the default because the file had no preference yet.
- The second line must echo the probe's own input -- directory `/tmp/udpscope-prefs-events`, keep
`7`, relative time `1`, theme `dark`. Any field that differs means the form, the write
or the parse dropped it.
- `live=1` is the theme having reached the running style manager rather than only the file.
- `browse: toplevels=N extra=1 field='…'` is the folder chooser: opened from its button, then cancelled
from *outside* it. `extra=1` is the new toplevel that is the dialog -- a count, because the dialog is
not named after anything a probe could match on -- and the field must still read the directory it read
before it opened, because **a cancelled picker changes nothing**. This is the async-dialog crash
surface (a callback landing after its dialog went away), which is why the preferences window is closed
a beat later than the cancel and why the callback tolerates every widget being null.
- `icon_ok=1` is the icon theme knowing `folder-open-symbolic`, the same question the toolbar-icons
line asks of the bundled six. It is a reading rather than a convention because the button is an icon
and *nothing else*: a name the theme does not carry draws a missing-image placeholder, and nothing
else in a run would say so.
- `default restored: file=''` closes the loop on the same idea from the other side: the probe types the
default path back into the field and presses *Apply*, and the file must end up with **no**
`event_cache_dir` at all. A path in the file is a user's choice, and typing the default is not one --
pinning it there would stop the setting following the cache directory it is named after.
+5 -3
View File
@@ -301,9 +301,11 @@ as distinct from the fault injection below, which exercises ingest:
`[events] activated row0: replaying=1 thumbnail=1 thumb_size=108x36 thumb_visible=1` (the size is
what says the picture is *on screen*: a paint-able alone is not), `[events] replayed: channels=4
samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0 waveform=0`. It ends on
the pane's other precondition, through the trigger panel's switch: `[events] recording off, trigger
off` and `[events] recording off, trigger on` must **both** read `button_visible=0 pane=0
recording=0` -- the pane goes with its switch, in either trigger state. **`samples` must equal the
the pane's availability, through the trigger panel's switch: `[events] recording off, trigger off` and
`[events] recording off, trigger on` -- once the cache holds events (the probe's own run has written
some by this point) both read `button_visible=1 pane=1 recording=0`, because a button that shows the
cache must not go away with the switch that wrote it; against an *empty* cache the same two lines read
`button_visible=0 pane=0`, which is the empty column the item exists to prevent. **`samples` must equal the
export's row count for the same window** (`20001` for a 2 s event at 10 kS/s), `thumb_bytes` must be
a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the row loaded
the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way
@@ -0,0 +1,60 @@
# UI files and current GTK practice
Where the application's interface should be a `.ui` resource instead of widget construction, what is
already there, and the order to finish it in. Written 2026-10-11.
## Why
Three reasons, in the order they bite.
1. **Some things can only be said in a UI file.** The shortcut overview is the proof: GTK's
`GtkShortcutsShortcut` has exactly one gesture type that draws an `icon`, and libadwaita's
`AdwShortcutsItem` has no icon property at all -- so a mouse gesture can only be shown as a glyph
from a `.ui`. `src/ui/shortcuts.ui` is that, and it is the reason the file exists.
2. **Structure is data.** A window's arrangement rewritten in C++ is a hundred lines of
`gtk_box_append` that no one can read at a glance, cannot be compared with a diff, and cannot be
previewed. A UI file is the same arrangement in the notation the GNOME ecosystem reads.
3. **Current GTK and libadwaita have better widgets than this app uses.** The forms here are
`GtkEntry`/`GtkSpinButton` in hand-built label rows. The modern equivalents -- `AdwEntryRow`,
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`, inside `AdwPreferencesPage`/`AdwPreferencesGroup` in an
`AdwPreferencesDialog` -- are what the preferences dialog should be, and they come from the same
move.
## Conventions (established, and in `docs/claude/invariants-gtk-and-chrome.md`)
- `.ui` files live in `src/ui/` and are compiled by the third GResource (`udpscope-ui.gresource.xml`,
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
- Every file carries `preprocess="xml-stripblanks"`, which is the only thing that catches a malformed
file at build time.
- A file that is valid XML but wrong for GTK **aborts the process** (`Gtk-ERROR`). The probe that opens
every window is what catches that; there is no build-time substitute.
- A `GIcon` property takes a nested object, not a name.
- A `.ui` over 4095 characters needs `-Wno-overlength-strings` on its generated source.
- A window built from a builder is replaced (not reused) on the next opening: it belongs to the
builder's object list, and a destroyed `GtkWindow` cannot be presented again.
- A deprecated *widget* is acceptable when no deprecated *function* is called.
## Acceptance for every slice
The same as any other change here: the build stays at **four warnings**, a run stays at **no
`Gtk-CRITICAL`**, and the slice's own surface is read back by a probe rather than asserted. A new `.ui`
needs a probe that proves it *loads* and that its contents match whatever drives it -- `[help]`'s
`ui=` against `tables=` is the model.
## Already done
- `src/ui/shortcuts.ui` -- the shortcut overview, with the mouse rows drawn as an icon cell.
- The `src/ui/` GResource and its build block.
- `[help]`'s `ui=`/`ui_accel=`/`ui_icon=`/`ui_bare=` reading, which checks the file against the tables
and asserts that no row is left without a visual.
## Remaining, smallest first
| where | what | why it is the right next step |
|---|---|---|
| `src/ui_prefs.cpp` (~330 lines) | the preferences: `.ui` + `AdwPreferencesDialog`, `AdwEntryRow`, `AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow` | the most visible win per line: it replaces four hand-built rows with four rows that look and behave like every other GNOME application's, and the file is self-contained |
| `src/help_window.cpp` (351 lines) | the help window's chrome as a `.ui` (the pages themselves stay resources) | small, self-contained, and its probe already opens it |
| `src/source_dialog.cpp` (~900 lines) | the source chooser and the host form: `.ui` + `AdwDialog`, with `AdwEntryRow`/`AdwSpinRow` for the form | the form is the second-biggest hand-built surface, and its probes drive every branch of it |
| `src/ui.cpp` (5774 lines) | the main window's **skeleton** only -- the `AdwApplicationWindow`, the header bar, the plot area's placeholder, the panes' containers, the status bar -- with the custom widgets, popovers and dialogs left in code | one file, one window: converting the skeleton is reviewable, converting the whole file is not. Do it in slices (header bar, then the panes, then the trigger panel, then the left panes) with the probe suite after each |
Nothing in this list should be done in one pass. Each row is its own change, verified on its own.
+1
View File
@@ -115,6 +115,7 @@ Config parse_args(int argc, char** argv) {
c.open_waveform = value_of(argc, argv, i, "--open");
} else if (a == "--relative-time") {
c.relative_time = true;
c.relative_time_specified = true;
} else {
fail("unknown argument: " + a);
}
+5
View File
@@ -45,6 +45,11 @@ struct Config {
/// sample's x. Absolute stream time is still recorded, for export only (`todo.md` item 2).
bool relative_time = false;
/// Whether relative time was *stated* rather than left at its default -- by `--relative-time`, or
/// by the preferences, which the source chooser can reach before this run is planned. A source's
/// stored configuration is a statement from an *older* session, so it only fills in the gap.
bool relative_time_specified = false;
/// `--capture-packets N`: the ring size of the raw-datagram capture, 1..100000.
/// 0 means the flag was absent: the capture is off, and the popover button then
/// uses the 1000-slot default when the user toggles it.
+8 -11
View File
@@ -17,17 +17,12 @@
namespace udpscope {
namespace {
/// One directory under the cache holds *every* source's events, and each source gets a folder inside
/// it: `<cache>/udpscope/events/<source>/`. The other way round -- a folder per source with an
/// `events` inside -- spreads one feature over as many directories as there are sources, and turns
/// "where are my events" into a question about which host you last used.
constexpr const char* kEventsSubdir = "/events";
void mkdirs(const std::string& path) {
std::string cur;
for (std::size_t i = 0; i < path.size(); ++i) {
cur.push_back(path[i]);
if (path[i] != '/' && i + 1 != path.size()) continue;
::mkdir(cur.c_str(), 0755);
}
}
/// `2026-10-09T11:32:00` in local time, plus the sequence number: two events inside one second are
/// ordinary (a 5 Hz trigger latches five times a second), and a stem that collides would overwrite
/// the event it collided with.
@@ -236,7 +231,9 @@ bool event_meta_parse(const std::string& text, EventRecord* out) {
return any;
}
EventStore::EventStore(std::string source) {
std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; }
EventStore::EventStore(std::string source, std::string base_dir) {
if (source.empty()) return;
std::string safe;
for (char c : source) {
@@ -244,7 +241,7 @@ EventStore::EventStore(std::string source) {
c == '.' || c == '_' || c == '-';
safe.push_back(ok ? c : '_');
}
dir_ = cache_dir() + "/" + safe + kEventsSubdir;
dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe;
}
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
+11 -2
View File
@@ -29,7 +29,7 @@ struct EventRecord {
std::uint64_t seq = 0; ///< the run's own event counter, so two events in one second differ
};
/// The cache of captured events: `<cache_dir>/<source>/events/`, one `.npy` per event written in
/// The cache of captured events: `<cache_dir>/events/<source>/`, one `.npy` per event written in
/// the **export format** -- so `--open` and the chooser's Waveforms page read it back with the
/// loader that already exists -- beside a `.txt` sidecar holding what the list shows without
/// loading the samples, and whatever thumbnail the caller rendered.
@@ -37,10 +37,19 @@ struct EventRecord {
/// No GTK: the caller renders the thumbnail and hands over a path, exactly as it hands over the
/// table. The store owns naming, the sidecar, pruning and listing, which is what makes it testable
/// without a window.
/// The directory every source's event folder is created under when no preference says otherwise:
/// `<cache>/udpscope/events`. Exposed because the preferences dialog shows it -- "the standard
/// location" is not an answer a user can act on, and a second copy of the path computed in the dialog
/// is a second thing to get wrong.
std::string default_events_dir();
class EventStore {
public:
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses.
explicit EventStore(std::string source = {});
/// `base_dir` is the directory every source's own folder is created under; empty means
/// `<cache>/udpscope/events`. It comes from the preferences (item 9), because a user with a small
/// home partition and a second disk should not have to choose between events and free space.
explicit EventStore(std::string source = {}, std::string base_dir = {});
/// How many events to keep, newest first. The oldest beyond it are deleted on every save; item 9's
/// preferences are where this becomes the user's number.
+3 -1
View File
@@ -6,7 +6,9 @@ pointer is over, and because the rows share one time axis, an x change moves all
<b>How to reach it</b>
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
<b>Drag</b> pans · <b>Ctrl+drag</b> is a box zoom on x · <b>Alt+drag</b> a box zoom on y.
<b>Drag</b> pans · <b>Shift+drag</b> is a box zoom on both axes · <b>Ctrl+drag</b> on x only ·
<b>Alt+drag</b> on values only.
The area being chosen is framed while you drag it, so you can see what the release will apply.
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
+1 -1
View File
@@ -15,7 +15,7 @@ share. <b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard
<b>Worth knowing</b>
• <b>Every triggered acquisition is kept.</b> Latches are written to the event cache
(<tt>~/.cache/udpscope/&lt;source&gt;/events/</tt>) as a <tt>.npy</tt> of the event, a
(<tt>~/.cache/udpscope/events/&lt;source&gt;/</tt>) as a <tt>.npy</tt> of the event, a
<tt>.txt</tt> of what it was, and a <tt>.bmp</tt> picture drawn from the samples. The
<b>Events</b> pane on the left -- opened from the toolbar's event-history button, which appears
while recording is enabled, and collapsed until you ask for it -- lists the last 32 newest first
+31 -55
View File
@@ -16,12 +16,16 @@ namespace {
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
/// One pointer per window, file-scope because there is one application.
///
/// The help window is a `GtkWindow`. The other two are `AdwDialog`s, which are **not**
/// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with
/// `adw_dialog_close()`.
/// The help window and the shortcut overview are `GtkWindow`s. The About dialog is an
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
/// `::closed` and closes with `adw_dialog_close()`.
GtkWidget* g_help = nullptr;
AdwDialog* g_about = nullptr;
AdwDialog* g_shortcuts = nullptr;
/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window
/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
/// builder is replaced on the next opening rather than reused.
GtkWidget* g_shortcuts = nullptr;
GtkBuilder* g_shortcuts_ui = nullptr;
gboolean on_window_close(GtkWindow* window, gpointer slot) {
(void)window;
@@ -285,67 +289,39 @@ void show_help(GtkWindow* parent) {
gtk_window_present(GTK_WINDOW(window));
}
namespace {
/// Appends one table row to `dialog`, opening a new section whenever the row's `section`
/// string differs from the last one. `*current` is the section being filled, `*title` the
/// name it carries; both are owned by the caller so the two tables share one run.
///
/// `accel` is handed to `AdwShortcutsItem` as an accelerator when it is non-empty and
/// `subtitle` is used when it is not, which is how a wheel or a drag gets a row: neither
/// GTK's nor libadwaita's accelerator syntax can name one, and asking them to parse
/// "Wheel" costs a parse warning and draws nothing.
void append_shortcut(AdwShortcutsDialog* dialog, AdwShortcutsSection** current,
const char** title, const char* section, const char* accel,
const char* subtitle, const char* description) {
if (*current == nullptr || std::string(*title) != section) {
*current = adw_shortcuts_section_new(section);
*title = section;
adw_shortcuts_dialog_add(dialog, *current);
}
AdwShortcutsItem* item = adw_shortcuts_item_new(description, accel);
if (subtitle != nullptr && *subtitle != '\0') {
adw_shortcuts_item_set_subtitle(item, subtitle);
}
adw_shortcuts_section_add(*current, item);
}
} // namespace
void show_shortcuts(GtkWindow* parent) {
if (g_shortcuts != nullptr) {
adw_dialog_present(g_shortcuts, GTK_WIDGET(parent));
gtk_window_present(GTK_WINDOW(g_shortcuts));
return;
}
AdwDialog* dialog = adw_shortcuts_dialog_new();
g_shortcuts = dialog;
adw_dialog_set_title(dialog, "Keyboard and mouse shortcuts");
AdwShortcutsSection* section = nullptr;
const char* section_title = nullptr;
int accel_count = 0;
const Accel* accels = app_accels(&accel_count);
for (int i = 0; i < accel_count; ++i) {
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), &section, &section_title,
accels[i].section, accels[i].accel, "", accels[i].description);
}
int gesture_count = 0;
const Gesture* gestures = plot_gestures(&gesture_count);
for (int i = 0; i < gesture_count; ++i) {
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), &section, &section_title,
gestures[i].section, gestures[i].accel, gestures[i].subtitle,
gestures[i].description);
}
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts);
adw_dialog_present(dialog, GTK_WIDGET(parent));
// A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one
// section per page, with the sidebar and the search box drawn by the window itself.
//
// A UI file rather than widget construction because of the mouse rows. An accelerator cannot spell
// "Ctrl and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement for this widget --
// has no icon property at all, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic
// gesture type draws the `icon` the row names, which is what puts a mouse glyph where the keycaps
// would be. (The widget is deprecated since 4.20, but this uses none of its functions: the file is
// loaded as data and presented as a `GtkWindow`, so the build stays at its four warnings.)
//
// A builder per opening, not one reused: the window belongs to the builder and `close-request`
// destroys the window, so the resource is parsed again rather than an already-dead window fetched.
g_clear_object(&g_shortcuts_ui);
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
if (g_shortcuts_ui == nullptr) return;
GObject* overlay = gtk_builder_get_object(g_shortcuts_ui, "help_overlay");
if (overlay == nullptr) return;
g_shortcuts = GTK_WIDGET(overlay);
gtk_window_set_transient_for(GTK_WINDOW(g_shortcuts), parent);
g_signal_connect(g_shortcuts, "close-request", G_CALLBACK(on_window_close), &g_shortcuts);
gtk_window_present(GTK_WINDOW(g_shortcuts));
}
void close_help_windows() {
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
if (g_about != nullptr) adw_dialog_close(g_about);
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
}
} // namespace udpscope
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A boundary that can be dragged: a vertical double-headed arrow. It says *which* edge the handle
moves, which a plain grip does not. Every coordinate is a whole pixel of the 16px cell, every
subpath is a filled `path` wound the same way (a stroke would keep this colour on a dark theme),
and the two heads meet the shaft exactly, at x 5..11 and x 7..9. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M7 4 H9 V12 H7 Z
M8 1 L11 5 H5 Z
M8 15 L5 11 H11 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 734 B

+1
View File
@@ -22,6 +22,7 @@
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-rows-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
</gresource>
+11 -1
View File
@@ -31,6 +31,12 @@ std::string sanitize(const std::string& host) {
return out.empty() ? "_" : out;
}
} // namespace
/// Creates `path` and every missing component, the last one included, like `mkdir -p`. Outside the
/// anonymous namespace because the preferences file is written through it too: a bare `mkdir()` of
/// `<config>/udpscope` fails when `<config>` does not exist yet, which is what a fresh install looks
/// like -- and it fails *silently*, leaving the settings dialog reporting a write it did not make.
void mkdirs(const std::string& path) {
// **Every** component, the last one included. The condition used to be "the path has a trailing
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
@@ -44,6 +50,8 @@ void mkdirs(const std::string& path) {
}
}
namespace {
std::string toml_escape(const std::string& s) {
std::string out = "\"";
for (char c : s) {
@@ -504,6 +512,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
parse_string_array(value.substr(1, value.size() - 2), names);
}
st.rows.back().signals = names;
} else if (key == "weight") {
st.rows.back().weight = std::strtod(value.c_str(), nullptr);
}
} else if (section == Section::Math && !st.math.empty()) {
MathChannel& m = st.math.back();
@@ -620,7 +630,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
if (state.capture_packets > 0) o << "capture_packets = " << state.capture_packets << "\n";
for (const auto& row : state.rows) {
o << "\n[[plot]]\nsignals = [";
o << "\n[[plot]]\nweight = " << row.weight << "\nsignals = [";
for (std::size_t i = 0; i < row.signals.size(); ++i) {
if (i != 0) o << ", ";
o << toml_escape(row.signals[i]);
+7
View File
@@ -20,6 +20,9 @@ struct LayoutState {
struct Row {
/// One entry per slot; an empty string is an empty slot.
std::vector<std::string> signals;
/// The row's share of the figure's height, 1 for equal rows. Part of the layout because a sizing a
/// user dragged is an arrangement, and an arrangement that is not saved is one that is lost.
double weight = 1.0;
};
double window_s = 0.0; ///< 0 keeps the default window
@@ -126,6 +129,10 @@ std::string config_dir();
/// lives (the packet captures; item 9's event cache). Created on demand by callers.
std::string cache_dir();
/// Creates `path` and every missing component, like `mkdir -p`. Public because the preferences are
/// written under the config directory too.
void mkdirs(const std::string& path);
/// The *legacy* single-file path, `~/.config/udpscope/<host>_<port>.toml`, with the directory
/// created. Still the import source for a file written before configurations existed; nothing
/// writes here any more.
+5
View File
@@ -3,6 +3,7 @@
#include "acquisition.hpp"
#include "config.hpp"
#include "settings.hpp"
#include "waveform.hpp"
#include "model.hpp"
#include "plot_host.hpp"
@@ -16,6 +17,10 @@ int main(int argc, char** argv) {
udpscope::Config cfg;
try {
cfg = udpscope::parse_args(argc, argv);
// The preferences fill in what the command line did not decide. `--relative-time` is an explicit
// instruction for this run, so a preference can only turn the mode on, never off -- which is also
// why this is read here, before the buffers are planned: the mode changes how they are filled.
if (udpscope::load_settings().relative_time) cfg.relative_time = true;
} catch (const std::exception& e) {
std::fprintf(stderr, "udpscope: %s\n", e.what());
return 2;
+11
View File
@@ -526,6 +526,13 @@ double ScopeModel::channel_dt(std::size_t channel) const {
BufferStore& ScopeModel::live() { return *store_; }
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
// There may be **no store at all**: `rebuild_locked()` resets it for a stream with no channels, which
// is what the model is in until a CONFIG arrives. `live()` dereferences the pointer, so every path
// that can be reached without a stream has to ask first -- pausing a scope that is not connected did
// not, and walked into this function through `publish_pause_view()`, a null dereference.
first = 0;
last = 0;
if (store_ == nullptr) return;
BufferStore& src = live();
plot::data::RingBuffer<f64>& tsrc = src.time();
@@ -578,6 +585,10 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
}
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
// Nothing has arrived yet, so there is nothing to freeze: pausing a scope that is not connected is a
// legitimate gesture (the button works), and it must not take a window from a store that does not
// exist.
if (store_ == nullptr) return;
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
// doing it, and this one is the UI's.
const std::uint64_t t0 = now_ns();
+14
View File
@@ -252,9 +252,23 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
// `Figure::link_x()` refuses a member whose x axis still follows on its own.
}
void RowSet::set_row_weights(std::vector<float> weights) {
// The count is not checked here: `apply_row_weights()` sizes it to the rows that exist, so a layout
// with rows added or removed keeps working instead of silently losing its sizing.
row_weights_ = std::move(weights);
if (figure_ != nullptr) apply_row_weights();
}
void RowSet::apply_row_weights() {
if (row_weights_.empty() || figure_ == nullptr) return;
row_weights_.resize(static_cast<std::size_t>(rows_), 1.0f);
(void)figure_->set_row_weights(row_weights_);
}
void RowSet::rebuild_all() {
figure_ = std::make_unique<plot::Figure>(static_cast<plot::u32>(rows_));
figure_->set_row_gap(kRowGap);
apply_row_weights();
x_group_ = figure_->add_x_link();
for (int i = 0; i < kMaxRows; ++i) {
Row& r = row(i);
+17
View File
@@ -153,6 +153,15 @@ class RowSet {
/// belongs with `assign()` -- set it before the assignments that follow it, or they keep the alpha
/// the previous view left behind.
void set_trace_alpha(float alpha) noexcept { trace_alpha_ = alpha; }
/// The rows' shares of the figure's height, normalised by the plotter; an empty vector means equal
/// rows. Kept here, and re-applied after every rebuild: a `Figure` is built afresh whenever a row's
/// trace set or the row count changes, so weights that lived only in the figure would be lost on the
/// first drop or row change after a user had sized their layout.
void set_row_weights(std::vector<float> weights);
/// Sizes the stored weights to the rows that exist and hands them to the figure.
void apply_row_weights();
const std::vector<float>& row_weights() const noexcept { return row_weights_; }
/// Shows `rows` rows. A changed count rebuilds the figure, since a `Figure`'s row
/// count is fixed at construction.
void set_row_count(int rows);
@@ -376,6 +385,13 @@ class RowSet {
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
/// `fit_x()` branches on, which is why the fit probe reports it.
bool window_pinned() const noexcept { return pinned_; }
/// True when the pin already *is* this window. A caller that re-states a pin every frame -- the file
/// view does, from the ring's own extent -- uses this to update the pin's numbers with
/// `set_data_pin()` instead of writing the group's range again with `pin_event_window()`, which would
/// undo a gesture that `hold_pinned_window()` has not adopted yet.
bool pinned_window_is(double pre_s, double post_s) const noexcept {
return pinned_ && pinned_pre_ == pre_s && pinned_post_ == post_s;
}
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
/// a cursor), and nothing otherwise.
@@ -497,6 +513,7 @@ class RowSet {
plot::u32 x_group_ = 0;
int rows_ = 1;
float trace_alpha_ = 1.0f;
std::vector<float> row_weights_;
double window_s_ = 2.0;
double trigger_marker_x_ = 0.0;
bool trigger_marker_on_ = false;
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
#include "settings.hpp"
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <glib.h>
#include "layout_store.hpp" // `mkdirs()`, so a first run's config directory is created with parents
namespace udpscope {
namespace {
/// One `key = value` per line, `#` for a comment: the same shape the layout files use, and parsed just
/// as leniently -- an unknown key, an empty line or a file written by an older version is not an error.
bool parse_bool(const std::string& v, bool fallback) {
if (v == "true" || v == "1" || v == "yes") return true;
if (v == "false" || v == "0" || v == "no") return false;
return fallback;
}
std::string unquote(const std::string& v) {
if (v.size() >= 2 && v.front() == '"' && v.back() == '"') return v.substr(1, v.size() - 2);
return v;
}
} // namespace
Settings default_settings() { return Settings{}; }
std::string settings_path() {
const std::string dir = std::string(g_get_user_config_dir()) + "/udpscope";
mkdirs(dir); // every component: `<config>` itself may not exist on a fresh install
return dir + "/settings.toml";
}
Settings load_settings() {
Settings s; // the defaults, so the file only has to mention what a user changed
std::ifstream in(settings_path());
if (!in) return s;
std::string line;
while (std::getline(in, line)) {
const std::size_t hash = line.find('#');
if (hash != std::string::npos) line = line.substr(0, hash);
const std::size_t eq = line.find('=');
if (eq == std::string::npos) continue;
std::string key = line.substr(0, eq);
std::string value = line.substr(eq + 1);
const auto trim = [](std::string& t) {
const std::size_t a = t.find_first_not_of(" \t\r\n");
const std::size_t b = t.find_last_not_of(" \t\r\n");
t = a == std::string::npos ? std::string() : t.substr(a, b - a + 1);
};
trim(key);
trim(value);
value = unquote(value);
if (value.empty()) continue;
if (key == "event_cache_dir") {
s.event_cache_dir = value;
} else if (key == "event_cache_max") {
s.event_cache_max = std::max(0, std::atoi(value.c_str()));
} else if (key == "relative_time") {
s.relative_time = parse_bool(value, s.relative_time);
} else if (key == "theme") {
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
}
}
return s;
}
bool save_settings(const Settings& settings) {
std::ofstream out(settings_path(), std::ios::trunc);
if (!out) return false;
out << "# udpscope preferences -- written by the preferences dialog, read at start-up.\n";
out << "# A key that is absent means its default, so this file only has to mention what changed.\n";
out << "event_cache_dir = \"" << settings.event_cache_dir << "\"\n";
out << "event_cache_max = " << settings.event_cache_max << "\n";
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
out << "theme = \"" << settings.theme << "\"\n";
out.flush();
return out.good();
}
} // namespace udpscope
+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The preferences: what a user chose about this program rather than about a source, kept between runs
// in one file. Deliberately free of GTK and of the app's own state -- it is a handful of values, a
// file to read them from and a file to write them to -- so the dialog that edits them, the code that
// applies them and the tests that read them all go through the same struct.
#ifndef UDPSCOPE_SETTINGS_HPP
#define UDPSCOPE_SETTINGS_HPP
#include <string>
namespace udpscope {
/// Everything the preferences dialog can change.
///
/// The defaults are the *stated* ones: an absent file and a fresh install behave identically, so a
/// setting is only ever a user's decision and never an accident of what a file happened to contain.
struct Settings {
/// Where the event cache goes: the directory each source's folder is created in. Empty means the
/// standard `<cache>/udpscope/events`, and a path is used as given -- one directory for every
/// source, never one between them, because two sources writing one directory is two sources
/// overwriting each other's events.
std::string event_cache_dir;
/// How many events one source's cache keeps. 0 disables trimming, which is a decision about the
/// user's disk made deliberately rather than by default.
int event_cache_max = 32;
/// `--relative-time`'s setting, for a source whose wall-clock stamps jitter. A *startup* setting: the
/// buffers are planned before any stream is known, so it takes effect on the next start (the
/// preferences dialog says so, like the configurations dialog does).
bool relative_time = false;
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
std::string theme = "auto";
};
/// The defaults, as a value rather than as a comment: what the dialog's *Defaults* button writes and
/// what a missing file yields.
Settings default_settings();
/// The file the settings live in: `<config>/udpscope/settings.toml`. Never empty.
std::string settings_path();
/// Reads the settings, filling anything the file does not mention from the defaults. A file that
/// cannot be read is not an error: it is a first run.
Settings load_settings();
/// Writes them. Returns false when the file cannot be written, which the dialog reports rather than
/// swallowing -- a preference that silently does not stick is worse than one that says it did not.
bool save_settings(const Settings& settings);
} // namespace udpscope
#endif // UDPSCOPE_SETTINGS_HPP
+6 -3
View File
@@ -38,9 +38,11 @@ constexpr int kAccelCount = static_cast<int>(sizeof(kAccels) / sizeof(kAccels[0]
/// wheel's: '+' is shift+'=' on common layouts, so Shift could not mean anything there.
///
/// A space-separated accelerator is the two-key alternative form the shortcut label draws
/// as `+ / -`. The mouse rows carry no accelerator at all: neither GTK's accelerator
/// syntax nor libadwaita's knows a wheel or a drag, and asking either to parse "Wheel"
/// is a parse warning and an empty cell. Those rows are prose in the subtitle instead.
/// as `+ / -`. The mouse rows carry no accelerator at all: neither GTK's accelerator syntax nor
/// libadwaita's knows a wheel or a drag, and asking either to parse "Wheel" is a parse warning and an
/// empty cell. The overview draws them from `src/ui/shortcuts.ui`, which gives each one GTK's generic
/// gesture type and an icon where the keycaps would be. That file is the *presentation*; this table is
/// what the application handles, which is why `[help]`'s `ui=` is checked against `tables=`.
constexpr Gesture kGestures[] = {
{"plus minus", "", "Zoom in or out, both axes", "Plot keyboard"},
{"<Control>plus <Control>minus", "", "Zoom the time axis only", "Plot keyboard"},
@@ -55,6 +57,7 @@ constexpr Gesture kGestures[] = {
{"", "Drag", "Pan", "Plot mouse"},
{"", "Ctrl and drag", "Box zoom on time", "Plot mouse"},
{"", "Alt and drag", "Box zoom on values", "Plot mouse"},
{"", "Shift and drag", "Box zoom on time and values", "Plot mouse"},
};
constexpr int kGestureCount = static_cast<int>(sizeof(kGestures) / sizeof(kGestures[0]));
+71 -6
View File
@@ -6,6 +6,8 @@
#include "waveform.hpp"
#include "layout_store.hpp"
#include "settings.hpp"
#include "ui_prefs.hpp"
#include <adwaita.h>
#include <gtk/gtk.h>
@@ -32,7 +34,10 @@ void apply_source(Config& cfg, const KnownSource& src) {
// only moment a buffer size can be applied at all -- and the reason they are stored per
// configuration rather than only passed on the command line.
if (src.buffer_mb > 0.0) cfg.buffer_mb = src.buffer_mb;
cfg.relative_time = src.relative_time;
// The list's own copy of the clock, but only where nothing newer has said: with the preferences
// open from this dialog a user can state relative time a minute before clicking the row, and that
// statement must survive the click that follows it.
if (!cfg.relative_time_specified) cfg.relative_time = src.relative_time;
cfg.capture_packets = src.capture_packets;
}
@@ -100,6 +105,10 @@ struct Chooser {
/// list with it, so the row a report would name is gone by the time the probe reads it.
std::string saved_config;
std::string saved_note;
/// The preferences, for the button below. Same reasoning as `form` above: a member of the chooser
/// rather than a heap struct, so nothing can outlive the stack frame that owns it.
ui_impl::PrefsForm prefs;
};
void chooser_finish(Chooser& c, bool chosen) {
@@ -114,6 +123,13 @@ void chooser_finish(Chooser& c, bool chosen) {
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
gtk_window_destroy(GTK_WINDOW(w));
}
// The preferences window is transient for the chooser, so it has to go first: a window destroyed
// with a transient still up is the lifetime this project has already got wrong once.
if (c.prefs.window != nullptr) {
GtkWidget* w = c.prefs.window;
c.prefs.window = nullptr;
gtk_window_destroy(GTK_WINDOW(w));
}
if (c.window != nullptr) {
GtkWidget* w = c.window;
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
@@ -577,6 +593,23 @@ gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifier
return TRUE;
}
/// The chooser's way into the preferences -- and the reason the form has no owner: this dialog runs
/// before `App` exists. Relative time has to reach the run this chooser is about to start, because
/// `main()` plans the buffers only after this returns; the rest is read from the file when that run
/// starts, so it is honoured without anything here doing it.
void open_chooser_preferences(Chooser& c) {
ui_impl::prefs_open(c.prefs, c.window, load_settings(), [&c](const Settings& s, bool written) {
// The newest statement of intent there is for this run: it beats the list's own copy and the
// stored configuration, and it works in both directions -- turning it on *and* off.
c.cfg->relative_time = s.relative_time;
c.cfg->relative_time_specified = true;
if (!written) {
std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str());
std::fflush(stderr);
}
});
}
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
/// dialog built, filled and destroyed is as much the point as the values, the same way
@@ -588,6 +621,24 @@ gboolean pick_probe(gpointer user) {
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
c.pick_source = 0;
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
// which is the half of the contract the app's probe cannot see. The window is deliberately left
// open: the pick below destroys the chooser with it still up, which is the lifetime at risk.
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_PREFS") != nullptr) {
open_chooser_preferences(c);
std::fprintf(stderr, "[sources] prefs: opened=%d apply=%d\n", c.prefs.window != nullptr ? 1 : 0,
c.prefs.apply != nullptr ? 1 : 0);
ui_impl::prefs_probe_fill_and_apply(c.prefs);
const Settings back = load_settings();
std::fprintf(stderr,
"[sources] prefs: dir=%s keep=%d rel=%d theme=%s cfg_relative=%d\n",
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
back.theme.c_str(), c.cfg->relative_time ? 1 : 0);
std::fflush(stderr);
}
// `UDPSCOPE_PROBE_WAVEFORMS=1` is the waveforms page without a display: the recent list, and the
// page selected the way a click on its tab does. `UDPSCOPE_PROBE_WAVE=<path>` is the file dialog's
// answer without the dialog -- the same field, set the same way.
@@ -701,13 +752,14 @@ void apply_stored_settings(Config& cfg) {
if (want.empty()) return;
LayoutState st;
if (!load_layout(configuration_path(cfg.server, cfg.port, want), st)) return;
// Only where the command line was silent: a flag the user typed wins, because it is the more
// recent statement of intent -- and the file is not a place to hide a surprise from the ones who
// did type it.
// Only where nothing newer was said: a flag the user typed, or the preferences they just applied,
// wins -- because it is the more recent statement of intent, and the file is not a place to hide a
// surprise from whoever just made it.
if (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb;
if (st.relative_time) cfg.relative_time = true;
const bool relative = !cfg.relative_time_specified && st.relative_time;
if (relative) cfg.relative_time = true;
if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets;
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || st.relative_time ||
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || relative ||
st.capture_packets > 0;
if (applied) {
std::fprintf(stderr,
@@ -788,6 +840,18 @@ bool choose_source(Config& cfg) {
gtk_widget_set_margin_top(actions, 12);
gtk_widget_set_margin_bottom(actions, 12);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
// The preferences are reachable from here as well as from the main window's menu: this dialog is
// the only window that exists before anything is planned, so it is where a user still has the
// chance to say how this run should behave.
GtkWidget* prefs = gtk_button_new_with_label("Preferences\xe2\x80\xa6");
gtk_widget_set_tooltip_text(prefs,
"the event cache, relative time and the interface's theme -- the same "
"dialog as the main window's menu, and the only place relative time can "
"still change this run");
g_signal_connect(prefs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_chooser_preferences(*static_cast<Chooser*>(user));
}),
&c);
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_form(*static_cast<Chooser*>(user));
@@ -798,6 +862,7 @@ bool choose_source(Config& cfg) {
chooser_finish(*static_cast<Chooser*>(user), false);
}),
&c);
gtk_box_append(GTK_BOX(actions), prefs);
gtk_box_append(GTK_BOX(actions), new_host);
gtk_box_append(GTK_BOX(actions), cancel);
gtk_box_append(GTK_BOX(box), actions);
+199 -15
View File
@@ -613,7 +613,21 @@ void sync_layout_editor(App& app) {
for (GtkWidget* b : app.layout_buttons) {
const int idx = row_key(b);
const bool insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
if (insert) {
const bool resize = g_object_get_data(G_OBJECT(b), kLayoutResizeKey) != nullptr;
if (resize) {
// *On* the boundary, beside the `+` -- the same edge, one handle's width to its right. Below it sat
// inside the lower row, which read as a handle for that row: a boundary handle that looks like it
// belongs to the plot under it is a handle whose drag changes the wrong plot, whatever it does.
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
: (static_cast<double>(fl.rows.back().y) +
static_cast<double>(fl.rows.back().h)) / scale;
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
const double left = width * 0.5 + kLayoutButtonPx * 0.5 + 6.0;
gtk_widget_set_margin_start(
b, static_cast<int>(std::clamp(left, 0.0, std::max(0.0, width - kLayoutButtonPx)) + 0.5));
} else if (insert) {
// Centred on the boundary: the top edge of row `idx`, or the bottom edge of the
// last row for the one past the end.
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
@@ -637,6 +651,52 @@ void sync_layout_editor(App& app) {
}
}
/// A row may not be dragged below this, in logical pixels: a row of no height is a row a user cannot
/// grab again, and the plotter keeps a floor of its own -- this is the one the *drag* respects.
constexpr double kMinRowPx = 48.0;
void on_layout_drag_begin(GtkGestureDrag* gesture, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
app.layout_drag_row =
static_cast<int>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-drag")));
// A drag is *relative*: the weights it started from are kept, because reading them back while dragging
// would compound -- each update would build on the last frame's instead of on the grab.
app.layout_drag_start = app.rows->row_weights();
app.layout_drag_start.resize(static_cast<std::size_t>(app.rows->row_count()), 1.0f);
}
void on_layout_drag_update(GtkGestureDrag*, double, double offset_y, gpointer user) {
App& app = *static_cast<App*>(user);
const int at = app.layout_drag_row; // the boundary between rows `at - 1` and `at`
const int rows = app.rows->row_count();
if (at <= 0 || at >= rows) return; // the figure's own edges are not boundaries
if (app.layout_drag_start.size() != static_cast<std::size_t>(rows)) return;
const plot::FigureLayout& fl = app.rows->figure().layout();
if (static_cast<int>(fl.rows.size()) != rows) return;
const double scale =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
const double up = static_cast<double>(fl.rows[static_cast<std::size_t>(at - 1)].h) / scale;
const double lo = static_cast<double>(fl.rows[static_cast<std::size_t>(at)].h) / scale;
const double pair = up + lo;
if (!(pair > kMinRowPx * 2.0)) return;
// Where the boundary is asked to go, floored on both sides: the *pixels* say where, and the pair's
// weights are the shares that put it there.
const double want_up = std::clamp(up + offset_y / scale, kMinRowPx, pair - kMinRowPx);
std::vector<float> weights = app.layout_drag_start;
const float sum = weights[static_cast<std::size_t>(at - 1)] + weights[static_cast<std::size_t>(at)];
weights[static_cast<std::size_t>(at - 1)] = sum * static_cast<float>(want_up / pair);
weights[static_cast<std::size_t>(at)] = sum - weights[static_cast<std::size_t>(at - 1)];
app.rows->set_row_weights(std::move(weights));
}
void on_layout_drag_end(GtkGestureDrag*, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
app.layout_drag_row = -1;
app.layout_drag_start.clear();
// A dragged sizing is an arrangement, so it is saved like one.
schedule_layout_save(app);
}
/// Builds one `×` per row and one `+` per boundary, or takes them all off again when the
/// editor is switched off.
void rebuild_layout_editor(App& app) {
@@ -676,6 +736,39 @@ void rebuild_layout_editor(App& app) {
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
app.layout_buttons.push_back(b);
}
// ... and one arrow per boundary whose *only* job is resizing the two rows it sits between. A widget
// of its own rather than a second gesture on the `+`, so each handle does one thing -- and a handle
// that cannot be clicked cannot be clicked by accident. The figure's own edges are not boundaries
// (there is no pair of rows to share between them), so the first and last are inert.
for (int b_i = 0; b_i <= rows; ++b_i) {
GtkWidget* b = gtk_button_new_from_icon_name("udpscope-resize-rows-symbolic");
gtk_widget_set_tooltip_text(b, b_i == 0 ? "drag to resize the first row"
: b_i == rows ? "drag to resize the last row"
: "drag to resize the two rows here");
gtk_widget_add_css_class(b, "circular");
gtk_widget_add_css_class(b, "osd");
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
gtk_widget_set_halign(b, GTK_ALIGN_CENTER);
gtk_widget_set_valign(b, GTK_ALIGN_START);
// Hidden, not disabled, where it has no pair to share between: the figure's own edges. A greyed
// handle is a question ("why can I not drag this?"), and the answer is that there is nothing there.
gtk_widget_set_visible(b, b_i > 0 && b_i < rows);
gtk_widget_set_halign(b, GTK_ALIGN_START);
// The *row key* is what `sync_layout_editor()` places by -- without it every arrow was placed at
// index 0, the figure's top edge, whatever boundary it belonged to.
set_row_key(b, b_i);
g_object_set_data(G_OBJECT(b), kLayoutResizeKey, GSIZE_TO_POINTER(b_i));
GtkGesture* drag = gtk_gesture_drag_new();
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), 1);
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(drag), GTK_PHASE_CAPTURE);
g_object_set_data(G_OBJECT(drag), "udpscope-layout-drag", GSIZE_TO_POINTER(b_i));
g_signal_connect(drag, "drag-begin", G_CALLBACK(on_layout_drag_begin), &app);
g_signal_connect(drag, "drag-update", G_CALLBACK(on_layout_drag_update), &app);
g_signal_connect(drag, "drag-end", G_CALLBACK(on_layout_drag_end), &app);
gtk_widget_add_controller(b, GTK_EVENT_CONTROLLER(drag));
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
app.layout_buttons.push_back(b);
}
// The editor's way out, in the area's top-right corner: the toolbar's toggle is in the
// overflow menu, which is a long way from the plot you are editing. Aligned by margins
@@ -3149,7 +3242,13 @@ gboolean tick(gpointer user) {
// bound to nothing and the snapshot carries its own `x_offset`, so the epoch is 0 and
// `ScopeModel::stream_at_x0()` is what crosses between the clocks.
double want_epoch = 0.0;
if (display == DisplayMode::Capturing) {
if (!app->waveform_name.empty()) {
// A file *is* an event: an export's stamps are already trigger-relative, so the trigger instant
// is x = 0 and the epoch must not move it. The live rule below ("the newest sample sits at 0")
// would put the trigger at the right edge and push the whole pre-trigger half off the screen --
// which is what "I only see 0 to 0.2 of a -0.8 to 0.2 recording" was.
want_epoch = 0.0;
} else if (display == DisplayMode::Capturing) {
want_epoch = -cap_edge;
} else if (model.display_snapshot() == nullptr) {
// `live_edge()` reports in the epoch currently in force, so subtracting it is minus the
@@ -3166,13 +3265,53 @@ gboolean tick(gpointer user) {
// Outside the lock: it reads and writes the two plots' own windows, not the model.
sync_compare_axes(*app);
sync_events_divider(*app);
// The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a
// `RowSet` rebuilds its *figure* when a row's trace set changes, and the area has to be handed the
// new one. Nothing did that for the second plot, so it kept drawing the figure built when its slots
// were still empty -- every trace in the palette's first colour, which is blue. Idempotent: the
// area is handed the figure it already has on every other frame.
// A file on screen is an event, and gets what a held capture gets: the axis pinned to the event's own
// `[-pre, +post]` and the trigger instant marked at x = 0. That *is* the constraint -- the time base is
// the event's, so no gesture can take the view outside it -- and the window control reads the event's
// length. The extent comes from the ring itself, so nothing has to remember it: with the epoch at 0
// the stamps are the display x.
if (!app->waveform_name.empty() && !app->capturing && app->rows != nullptr) {
double lo = 0.0;
double hi = 0.0;
{
const auto& t = app->model->live().time();
const std::uint64_t n = t.size();
if (n > 0) {
lo = t[0];
hi = t[n - 1];
}
}
if (hi > lo) {
// State the pin **once**, then only its numbers. `pin_event_window()` writes the group's range as
// well as the pin, and doing that every frame undid the user's box zoom before the clamp below
// could adopt it -- the same defect the wheel had in single trigger mode, where re-stating the pin
// made it a no-op. The held-event branch above has this shape for this reason: `set_data_pin()`
// for the numbers, `pin_event_window()` when the view first becomes an event. A re-open into the
// same row set with a different extent *is* a new event and re-states it.
const auto pin_view = [lo, hi](RowSet& rows) {
if (rows.pinned_window_is(-lo, hi)) {
rows.set_data_pin(-lo, hi);
} else {
rows.pin_event_window(-lo, hi);
}
rows.set_trigger_marker(0.0, true);
};
pin_view(*app->rows);
if (app->rows_b != nullptr) pin_view(*app->rows_b);
sync_window_display(*app, hi - lo);
}
}
// The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a `RowSet`
// rebuilds its *figure* when a row's trace set changes, and the area has to be handed the new one.
// Only when it *did* change, though -- `set_figure()` creates **a controller per row**, so handing it
// the same figure every frame threw away the second plot's interaction state sixty times a second:
// its box-zoom and its crosshair never took, while the primary's (re-pointed only on a dirty row) did.
if (app->comparing && app->area_b != nullptr && app->rows_b != nullptr) {
app->area_b->set_figure(&app->rows_b->figure());
plot::Figure* want = &app->rows_b->figure();
if (app->area_b_figure != want) {
app->area_b->set_figure(want);
app->area_b_figure = want;
}
}
// The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a
@@ -3258,10 +3397,18 @@ gboolean tick(gpointer user) {
double hi = 0.0;
if (app->rows->event_data_window(lo, hi)) app->rows->set_data_pin(lo, hi);
app->rows->hold_pinned_window();
} else if (display == DisplayMode::Paused || app->capturing) {
// Pinned: the event (or the frozen pause), not a navigation surface. `clamp_to_data()` is
// the live-mode half of the same rule -- a live view must stay on the data -- and the two
// are complements, which is why this is one branch rather than two conditions.
} else if (display == DisplayMode::Paused || app->capturing || app->rows->window_pinned()) {
// Pinned: the event, the frozen pause *or an opened file*, not a navigation surface.
// `clamp_to_data()` is the live-mode half of the same rule -- a live view must stay on the data
// -- and the two are complements, which is why this is one branch rather than two conditions.
//
// A file belongs here and was the case that was missing. It pins its axis every frame (see the
// `waveform_name` block above), but a pinned axis does *not* mean this branch: only
// `hold_pinned_window()` adopts a gesture from the row's own axis, because a member's
// `set_x_range` is deliberately not adopted into a group that is not following. Falling through
// to `clamp_to_data()` therefore left a box zoom in a file view drawing its frame and changing
// nothing -- the gesture's range went onto the member, the group kept the pin, and the picture
// that was drawn came from the group.
app->rows->hold_pinned_window();
} else {
app->rows->clamp_to_data();
@@ -3405,7 +3552,11 @@ gboolean tick(gpointer user) {
// being captured or held. x is seconds from the event in both: the display epoch is
// minus the edge while the live ring is on a pinned axis, and the snapshot's own stamps
// are edge-relative, so the edge sits at exactly x = 0 wherever the user has zoomed.
if (!app->capturing && !(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) {
// ... and it is the *only* thing that hides it. A file's own event is on screen for as long as the
// file is, so the file branch above must survive this one: it used to run first and be undone here,
// which is why an opened event had no trigger line.
if (app->waveform_name.empty() && !app->capturing &&
!(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) {
app->rows->set_trigger_marker(0.0, false);
}
@@ -3588,6 +3739,15 @@ gboolean tick(gpointer user) {
const double dt_s = std::min(10.0, double(now_c - app->last_channels_meas) / 1.0e6);
next_channels = now_c + G_TIME_SPAN_SECOND;
app->last_channels_meas = now_c;
// The cache's own question, on the same cadence as the channel dump: a directory read per second,
// and the button's availability re-decided only when the answer changed.
if (app->event_store != nullptr) {
const bool cached = !app->event_store->list().empty();
if (cached != app->events_cached) {
app->events_cached = cached;
sync_events_availability(*app);
}
}
// Once a second, alongside the rate measurement: is the stream arriving at all? Nothing is
// owed in file mode -- there is no socket and no source -- so a file is never starved.
if (app->waveform_name.empty()) note_starvation(*app);
@@ -4074,6 +4234,13 @@ GtkWidget* build_toolbar(App& app) {
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), help_item);
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_preferences(*static_cast<App*>(user));
}),
&app);
gtk_box_append(GTK_BOX(menu_box), prefs_item);
gtk_box_append(GTK_BOX(menu_box), menu_separator());
GtkWidget* about_item = menu_button("About udpscope", nullptr);
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
@@ -4169,13 +4336,18 @@ GtkWidget* build_toolbar(App& app) {
if (open) refresh_events_pane(*a);
}),
&app);
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.events_toggle);
app.pause_button = gtk_toggle_button_new();
gtk_button_set_icon_name(GTK_BUTTON(app.pause_button), "media-playback-pause-symbolic");
gtk_widget_set_tooltip_text(app.pause_button, "pause acquisition");
g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app);
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.pause_button);
// A separator, because the events button is not another mode on the plot: what stands to its left is
// what the *view* is doing (the window, the trigger, the pause) and what follows is the cache of what
// those produced.
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.events_toggle);
// Packed here, after every widget above has been created: the *pack* order is what decides the
// layout, not the creation order, and packing before the widget exists passes NULL to the header
@@ -4941,6 +5113,11 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
// needs to be drawn at all. Idempotent, and `choose_source()` may already have run it.
adw_init();
// The preferences are read before anything is built: the theme decides what the window looks like,
// and the rest is in force from the first frame. A *local* until `App` exists below, which is why
// this one read happens here and the assignment after it.
const Settings startup_settings = load_settings();
apply_theme(startup_settings.theme);
// The app's own icons, before anything asks for one. They are a GResource compiled into the
// binary (see CMakeLists), and this is what makes the theme look there: an unregistered path
// is not an error, it is a missing-image placeholder.
@@ -4952,6 +5129,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()),
"/org/udpscope/icons");
App app;
app.settings = startup_settings;
app.model = &model;
app.acq = &acq;
app.rows = &rows;
@@ -4969,7 +5147,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// file-mode run has no cache either -- there are no latches in a file.
if (!app.source_host.empty() && app.source_port != 0) {
app.event_store = std::make_unique<EventStore>(app.source_host + "-" +
std::to_string(app.source_port));
std::to_string(app.source_port), app.settings.event_cache_dir);
}
refresh_events_pane(app);
@@ -5074,6 +5252,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_DEFAULTS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), defaults_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_BOXZOOM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), boxzoom_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PREFS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), prefs_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CAPTURE_MS")) {
capture_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
+333
View File
@@ -0,0 +1,333 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The shortcut overview, as a UI file instead of a couple of hundred lines of widget construction, and
in gedit's arrangement: one GtkShortcutsWindow holding a section per page (the window draws the
sidebar and the search box over them), each section holding a group, each group holding rows.
The mouse rows are the reason this file exists. An accelerator cannot spell "Ctrl and drag", and
libadwaita's AdwShortcutsItem, the modern replacement for this widget, has no icon property at all,
so its cell reads "No Shortcut". GtkShortcutsShortcut has exactly one gesture type whose *icon* is
drawn (shortcut-type "gesture" plus the icon property, per its own documentation), and that is what
puts the mouse glyph where the keycaps would otherwise be.
Keep this file in step with the tables in shortcuts.cpp: those are what the application handles,
this is what it shows. The `[help]` probe's `ui=` against `tables=` is the guard against the two
drifting apart.
-->
<interface>
<object class="GtkShortcutsWindow" id="help_overlay">
<property name="modal">1</property>
<child>
<object class="GtkShortcutsSection">
<property name="visible">1</property>
<property name="section-name">Acquisition</property>
<property name="title" translatable="yes">Acquisition</property>
<property name="max-height">12</property>
<child>
<object class="GtkShortcutsGroup">
<property name="visible">1</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">space</property>
<property name="title" translatable="yes">Pause or resume the display</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">a</property>
<property name="title" translatable="yes">Arm or disarm the trigger</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">t</property>
<property name="title" translatable="yes">Show or hide the trigger panel</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkShortcutsSection">
<property name="visible">1</property>
<property name="section-name">Panes and editors</property>
<property name="title" translatable="yes">Panes and editors</property>
<property name="max-height">12</property>
<child>
<object class="GtkShortcutsGroup">
<property name="visible">1</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">c</property>
<property name="title" translatable="yes">Show or hide the cursors pane</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">m</property>
<property name="title" translatable="yes">Show or hide the measurements pane</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">g</property>
<property name="title" translatable="yes">Show or hide the math pane</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">l</property>
<property name="title" translatable="yes">Show or hide the layout editor</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">s</property>
<property name="title" translatable="yes">Show or hide the side panel</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkShortcutsSection">
<property name="visible">1</property>
<property name="section-name">Files and windows</property>
<property name="title" translatable="yes">Files and windows</property>
<property name="max-height">12</property>
<child>
<object class="GtkShortcutsGroup">
<property name="visible">1</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;Control&gt;e</property>
<property name="title" translatable="yes">Export the visible range</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">F1</property>
<property name="title" translatable="yes">Open the help window</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;Control&gt;slash</property>
<property name="title" translatable="yes">Open this shortcut overview</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;Control&gt;q</property>
<property name="title" translatable="yes">Quit udpscope</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkShortcutsSection">
<property name="visible">1</property>
<property name="section-name">Plot keyboard</property>
<property name="title" translatable="yes">Plot keyboard</property>
<property name="max-height">12</property>
<child>
<object class="GtkShortcutsGroup">
<property name="visible">1</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">plus minus</property>
<property name="title" translatable="yes">Zoom in or out, both axes</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;Control&gt;plus &lt;Control&gt;minus</property>
<property name="title" translatable="yes">Zoom the time axis only</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;Alt&gt;plus &lt;Alt&gt;minus</property>
<property name="title" translatable="yes">Zoom the value axis only</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">Left Right</property>
<property name="title" translatable="yes">Pan along time</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">Up Down</property>
<property name="title" translatable="yes">Pan along values</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">f</property>
<property name="title" translatable="yes">Fit the view to the data</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">Escape</property>
<property name="title" translatable="yes">Cancel the gesture in progress</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkShortcutsSection">
<property name="visible">1</property>
<property name="section-name">Plot mouse</property>
<property name="title" translatable="yes">Plot mouse</property>
<property name="max-height">12</property>
<child>
<object class="GtkShortcutsGroup">
<property name="visible">1</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="shortcut-type">gesture</property>
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
reference to another object and fails the whole file with "Object with ID … not
found" (a Gtk-ERROR, which aborts the process by default). -->
<property name="icon">
<object class="GThemedIcon">
<property name="names">input-mouse-symbolic</property>
</object>
</property>
<property name="title" translatable="yes">Zoom about the pointer, both axes</property>
<property name="subtitle" translatable="yes">Wheel</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="shortcut-type">gesture</property>
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
reference to another object and fails the whole file with "Object with ID … not
found" (a Gtk-ERROR, which aborts the process by default). -->
<property name="icon">
<object class="GThemedIcon">
<property name="names">input-mouse-symbolic</property>
</object>
</property>
<property name="title" translatable="yes">Zoom the time axis only</property>
<property name="subtitle" translatable="yes">Shift and wheel</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="shortcut-type">gesture</property>
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
reference to another object and fails the whole file with "Object with ID … not
found" (a Gtk-ERROR, which aborts the process by default). -->
<property name="icon">
<object class="GThemedIcon">
<property name="names">input-mouse-symbolic</property>
</object>
</property>
<property name="title" translatable="yes">Zoom the value axis only</property>
<property name="subtitle" translatable="yes">Ctrl and wheel</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="shortcut-type">gesture</property>
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
reference to another object and fails the whole file with "Object with ID … not
found" (a Gtk-ERROR, which aborts the process by default). -->
<property name="icon">
<object class="GThemedIcon">
<property name="names">input-mouse-symbolic</property>
</object>
</property>
<property name="title" translatable="yes">Pan</property>
<property name="subtitle" translatable="yes">Drag</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="shortcut-type">gesture</property>
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
reference to another object and fails the whole file with "Object with ID … not
found" (a Gtk-ERROR, which aborts the process by default). -->
<property name="icon">
<object class="GThemedIcon">
<property name="names">input-mouse-symbolic</property>
</object>
</property>
<property name="title" translatable="yes">Box zoom on time</property>
<property name="subtitle" translatable="yes">Ctrl and drag</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="shortcut-type">gesture</property>
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
reference to another object and fails the whole file with "Object with ID … not
found" (a Gtk-ERROR, which aborts the process by default). -->
<property name="icon">
<object class="GThemedIcon">
<property name="names">input-mouse-symbolic</property>
</object>
</property>
<property name="title" translatable="yes">Box zoom on values</property>
<property name="subtitle" translatable="yes">Alt and drag</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="shortcut-type">gesture</property>
<!-- A GIcon property takes an object, not a name: GtkBuilder reads a bare string as a
reference to another object and fails the whole file with "Object with ID … not
found" (a Gtk-ERROR, which aborts the process by default). -->
<property name="icon">
<object class="GThemedIcon">
<property name="names">input-mouse-symbolic</property>
</object>
</property>
<property name="title" translatable="yes">Box zoom on time and values</property>
<property name="subtitle" translatable="yes">Shift and drag</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>
</interface>
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The application's UI files, compiled into the binary beside the icons and the help pages, so there
is still exactly one file to run. `preprocess="xml-stripblanks"` is what catches a malformed file:
gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does
not open, which is a silent failure at runtime and a loud one here.
-->
<gresources>
<gresource prefix="/org/udpscope/ui">
<file preprocess="xml-stripblanks">shortcuts.ui</file>
</gresource>
</gresources>
+39 -2
View File
@@ -27,6 +27,8 @@
#include "model.hpp"
#include "plot_host.hpp"
#include "search.hpp"
#include "settings.hpp"
#include "ui_prefs.hpp"
#include "shortcuts.hpp"
#include "status_text.hpp"
#include "waveform.hpp"
@@ -77,6 +79,10 @@ struct App {
/// The editor's buttons, in the order they were added, so they can be taken off the
/// overlay again. Rebuilt when the row count changes, repositioned when the rows move.
std::vector<GtkWidget*> layout_buttons;
/// The boundary a resize drag is on, and the weights it started from: a drag is *relative* to where it
/// began, so the beginning has to be kept -- reading the weights while dragging would compound.
int layout_drag_row = -1;
std::vector<float> layout_drag_start;
/// The editor's way out: one `Done` button in the plot area's top-right corner while
/// the editor is up. Not part of `layout_buttons`, which is one button per row and per
/// boundary -- the count the layout probe asserts.
@@ -362,6 +368,9 @@ struct App {
/// `event_recording` is the trigger panel's switch and defaults on -- the cache is only useful if
/// it was already on when the event you wanted happened.
std::unique_ptr<EventStore> event_store;
/// Whether the cache holds anything, refreshed once a second: what keeps the history button on the bar
/// for a source whose events are already written, even with recording switched off.
bool events_cached = false;
bool event_recording = false;
/// The events pane: its list, the revealer that holds it, the recording switch (in the trigger
/// panel, where the trigger's options live), and `events_thumb` -- the picture of the newest
@@ -385,6 +394,9 @@ struct App {
plot::Plot placeholder_plot_b;
plot::gtk4::PlotArea* area_b = nullptr;
GtkWidget* area_b_widget = nullptr;
/// The figure the second area was last pointed at. `set_figure()` rebuilds its per-row input
/// controllers, so it is only called when the `RowSet` really has a new figure.
plot::Figure* area_b_figure = nullptr;
std::unique_ptr<RowSet> rows_b;
GtkWidget* plot_split = nullptr;
bool comparing = false;
@@ -407,6 +419,15 @@ struct App {
/// live source. Nothing arrives in this mode, so everything that would report on a stream --
/// the popover's Stream row, the starvation notice -- reports on the file instead.
std::string waveform_name;
/// The preferences, as read at start-up and written by the dialog. Held here rather than read where
/// they are used: the dialog edits a copy, saves it and hands it back, so the file and the running
/// program cannot disagree about what a preference is.
Settings settings;
/// The preferences window while it is up, and its controls: one at a time, like the configurations
/// dialog -- a second window editing the same settings would let the two disagree.
/// The preferences window. A `PrefsForm` rather than fields here because the source chooser opens
/// the same dialog and runs before `App` exists -- see `ui_prefs.hpp`.
PrefsForm prefs;
/// The configurations dialog, while it is up: one at a time, and this is what makes a second
/// `Configurations…` a present rather than a second window.
GtkWidget* config_dialog = nullptr;
@@ -979,6 +1000,10 @@ constexpr int kRowKeyBias = 1;
constexpr int kLayoutButtonPx = 24;
constexpr int kLayoutButtonPad = 4;
constexpr const char* kLayoutKindKey = "udpscope-layout-insert";
/// The ↕ handles' own key: what they are is *not* what the `+` is, and `sync_layout_editor()`
/// places them differently -- one boundary, two controls.
constexpr const char* kLayoutResizeKey = "udpscope-layout-resize";
constexpr gint64 kMeasureRefreshUs = 100 * 1000;
constexpr gint64 kMeasureHeavyUs = 5 * 1000;
constexpr const char* kCursorModeNames[] = {"vertical", "horizontal", "both", "mix"};
@@ -992,8 +1017,8 @@ constexpr double kMathZoomStep = 1.1;
constexpr double kWirePick = 6.0;
constexpr int kZoomGestureSteps = 8;
constexpr int kZoomWindowSteps = 8;
constexpr int kEventThumbW = 126;
constexpr int kEventThumbH = 42;
constexpr int kEventThumbW = 138;
constexpr int kEventThumbH = 46;
inline int g_stats_phase = 0;
inline std::vector<std::uint64_t> g_stats_n_before;
inline double g_trigrenum_edge = 0.0;
@@ -1090,6 +1115,18 @@ void update_search_panel(App& app);
void sync_events_availability(App& app);
/// Restates the events pane's divider position once a frame; see the definition.
void sync_events_divider(App& app);
/// The preferences dialog: one window, opened from the overflow menu. `apply_theme()` is separate
/// because the theme is the one preference that takes effect without a restart.
void open_preferences(App& app);
gboolean prefs_probe(gpointer user);
gboolean boxzoom_probe(gpointer user);
/// A labelled row whose control keeps its natural width -- what a `GtkSwitch` needs and what
/// `labeled_box()`'s expanding child denies it.
GtkWidget* labeled_switch_row(const char* label, GtkWidget* control);
/// The layout editor's boundary drag: the press starts it, movement sizes the two rows it separates.
void on_layout_drag_begin(GtkGestureDrag* gesture, double x, double y, gpointer user);
void on_layout_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
void on_layout_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
void on_trigger_toggled(GtkToggleButton* button, gpointer user);
void on_go_live(GtkButton*, gpointer user);
plot::u32 measure_color(std::size_t index);
+77 -28
View File
@@ -7,6 +7,7 @@
#include <gtk/gtk.h>
#include <limits>
#include <string>
namespace udpscope {
@@ -47,7 +48,10 @@ void sync_events_availability(App& app) {
// Available while the cache can still grow (recording on) *or* while it is being read (an event on
// screen). Not trigger mode: a stored event being examined is a file, and it has no trigger -- if the
// pane's life depended on the trigger, opening one would close the list it was opened from.
const bool available = app.event_recording || app.replaying;
// ... or whenever the cache *has* something: an event on disk outlives the switch that wrote it, and
// the button that shows it must not go with the switch. `events_cached` is refreshed once a second
// from the tick -- the store's list is a directory read, not a per-frame question.
const bool available = app.event_recording || app.replaying || app.events_cached;
gtk_widget_set_visible(app.events_toggle, available);
if (!available) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.events_toggle), FALSE);
@@ -116,7 +120,12 @@ bool record_event(App& app) {
meta.samples = table.rows();
meta.seq = app.model->event_seq();
const std::string file = app.event_store->save(table, meta);
// 0 means "keep everything", which `save()` cannot be asked for with a count: it is the largest
// number a size_t holds, and trimming then never finds anything beyond it.
const std::size_t keep = app.settings.event_cache_max <= 0
? std::numeric_limits<std::size_t>::max()
: static_cast<std::size_t>(app.settings.event_cache_max);
const std::string file = app.event_store->save(table, meta, keep);
if (file.empty()) return false;
// The event is on screen; say it was kept, and where. A notice rather than a dialog: a latch every
// 200 ms must not be a wall of windows.
@@ -171,7 +180,8 @@ bool replay_event(App& app, const EventRecord& record) {
rebuild_signal_panels(app);
// Fit on *open*, and only then: the whole event framed the moment it is opened, the user's own zooms
// left alone afterwards -- restated here and never per frame.
if (report.span_s > 0.0) apply_time_window(app, report.span_s);
// The axis is the event's own and the tick pins it there (`pin_event_window`): nothing to frame or
// home here beyond the window control's own reading, which the tick keeps in step.
// The pane is available through a replay, and this is what keeps it open.
sync_events_availability(app);
set_notice(app, "replaying " + record.stem + " (" +
@@ -376,42 +386,72 @@ bool build_event_group(App& app, const std::vector<EventRecord>& records, EventG
app.rows_b->set_row_count(rows);
if (app.area_b != nullptr) app.area_b->set_figure(&app.rows_b->figure());
}
// The user's layout is read **first**, and then every slot is freed on both plots, before the group
// assigns its own. The order matters: the layout is what says which slots the group fills, so
// clearing first would clear the very thing being read (it did -- every slot came back empty and the
// comparison drew nothing). And the clear itself is what stops a row carrying the previous view's
// traces -- an accumulation's layers, a live session's signals -- into the new one, where a trace
// left behind is an extra legend entry in a colour belonging to nothing on screen.
struct SlotPlan {
std::string name;
std::string label;
int colour = 0;
};
// ... and only the *base* layout is read back: a slot is part of it when its name carries no layer
// suffix or layer 0's own (`(A)`). A group's *extra* layers -- `(B)`, `#2` -- are that group's own
// artefacts, and reading them back as the user's arrangement is how an accumulation's layers survived
// into the next comparison: extra legend entries, in colours belonging to nothing.
const auto base_slot = [](const std::string& name) {
return name == layer_base_name(name) ||
(name.size() > 4 && name.compare(name.size() - 4, 4, " (A)") == 0);
};
std::vector<std::array<SlotPlan, RowSet::kSlotsPerRow>> layout(static_cast<std::size_t>(rows));
for (int r = 0; r < rows; ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t at = static_cast<std::size_t>(s);
const std::string& name = app.rows->row(r).signal_name[at];
if (!base_slot(name)) continue;
layout[static_cast<std::size_t>(r)][at] =
SlotPlan{name, app.rows->row(r).name[at], app.rows->row(r).color_index[at]};
}
}
const auto clear = [](RowSet* rows) {
if (rows == nullptr) return;
for (int r = 0; r < rows->row_count(); ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) rows->assign(r, s, "", "", 0);
}
};
clear(app.rows);
clear(app.rows_b.get());
app.rows->set_trace_alpha(kind == EventGroupKind::Accumulate ? 0.35f : 1.0f);
if (app.rows_b != nullptr) app.rows_b->set_trace_alpha(1.0f);
for (int r = 0; r < rows; ++r) {
RowSet::Row& row = app.rows->row(r);
const std::array<std::string, RowSet::kSlotsPerRow> names = row.signal_name;
const std::array<std::string, RowSet::kSlotsPerRow> labels = row.name;
const std::array<int, RowSet::kSlotsPerRow> colours = row.color_index;
const std::array<SlotPlan, RowSet::kSlotsPerRow>& plan = layout[static_cast<std::size_t>(r)];
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t at = static_cast<std::size_t>(s);
if (names[at].empty()) continue;
const std::string base = base_name(names[at]);
app.rows->assign(r, s, base + suffix(0), labels[at], colours[at]);
if (plan[at].name.empty()) continue;
const std::string base = base_name(plan[at].name);
app.rows->assign(r, s, base + suffix(0), plan[at].label, plan[at].colour);
if (pair) {
// The *same* colour index: the two plots show the same signal of two different events, and a
// colour of its own for the second would say they were different signals.
app.rows_b->assign(r, s, base + suffix(1), labels[at] + suffix(1), colours[at]);
app.rows_b->assign(r, s, base + suffix(1), plan[at].label + suffix(1), plan[at].colour);
continue;
}
// The extra layers go into free slots of **this** row, and nowhere else. A row's signals are what
// its colours and its legend speak for, so a layer placed in another row put a signal there in the
// colour of the signal it came from -- a multi-row layout whose other rows came out "wrong". A row
// with no room accumulates as far as it can and stops.
for (std::size_t layer = 1; layer < layers; ++layer) {
int found_row = -1;
int found_slot = -1;
for (int pass = 0; pass < 2 && found_row < 0; ++pass) {
const int from = pass == 0 ? r : 0;
const int to = pass == 0 ? r + 1 : rows;
for (int rr = from; rr < to && found_row < 0; ++rr) {
for (int ss = 0; ss < RowSet::kSlotsPerRow; ++ss) {
if (!app.rows->row(rr).signal_name[static_cast<std::size_t>(ss)].empty()) continue;
found_row = rr;
if (!app.rows->row(r).signal_name[static_cast<std::size_t>(ss)].empty()) continue;
found_slot = ss;
break;
}
}
}
if (found_row < 0) break; // the layout is full: no room for another layer
app.rows->assign(found_row, found_slot, base + suffix(layer), labels[at] + suffix(layer),
colours[at]);
if (found_slot < 0) break; // this row is full: its signal accumulates as far as it can
app.rows->assign(r, found_slot, base + suffix(layer), plan[at].label + suffix(layer),
plan[at].colour);
}
}
}
@@ -435,7 +475,7 @@ bool build_event_group(App& app, const std::vector<EventRecord>& records, EventG
rebuild_layout_editor(app);
rebuild_signal_panels(app);
// Fit on open, once: the group's own span, framed.
apply_time_window(app, span);
// As above: the tick pins the axis to the group's own span and marks the trigger.
sync_events_availability(app);
return true;
}
@@ -680,16 +720,25 @@ void refresh_events_pane(App& app) {
gtk_box_append(GTK_BOX(box), name);
char sub[160];
std::snprintf(sub, sizeof(sub), "%zu signals · %zu samples · %.4g S/s", r.signals, r.samples,
r.rate_hz);
std::snprintf(sub, sizeof(sub), "%zu signals · %zu samples", r.signals, r.samples);
GtkWidget* detail = gtk_label_new(sub);
gtk_widget_add_css_class(detail, "dim-label");
gtk_widget_set_halign(detail, GTK_ALIGN_START);
gtk_label_set_max_width_chars(GTK_LABEL(detail), 22);
// Both labels ellipsize, so the text gives way to the picture rather than the picture to the
// text: a label's minimum width is its longest word otherwise, and the pane is 250 pixels.
gtk_label_set_ellipsize(GTK_LABEL(detail), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(box), detail);
// A third line: the rate it was recorded at, where its edge sits and which latch it was -- what
// tells two events of the same length apart once the picture is too small to read.
char third[160];
std::snprintf(third, sizeof(third), "%.4g S/s · edge %.4g s · #%llu", r.rate_hz, r.edge,
static_cast<unsigned long long>(r.seq));
GtkWidget* extra = gtk_label_new(third);
gtk_widget_add_css_class(extra, "dim-label");
gtk_widget_set_halign(extra, GTK_ALIGN_START);
gtk_label_set_max_width_chars(GTK_LABEL(extra), 22);
gtk_label_set_ellipsize(GTK_LABEL(extra), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(box), extra);
gtk_box_append(GTK_BOX(line), box);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
+11
View File
@@ -24,6 +24,8 @@ LayoutState collect_layout(App& app) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
row.signals.push_back(r.signal_name[static_cast<std::size_t>(s)]);
}
const std::vector<float>& weights = app.rows->row_weights();
if (static_cast<std::size_t>(i) < weights.size()) row.weight = weights[static_cast<std::size_t>(i)];
st.rows.push_back(std::move(row));
}
for (std::size_t si = 0; si < model.signals().size(); ++si) {
@@ -313,6 +315,15 @@ void apply_layout(App& app, const LayoutState& st) {
if (rows > RowSet::kMaxRows) rows = RowSet::kMaxRows;
if (rows > 0) app.rows->set_row_count(rows);
// The rows' shares of the height come from the same file as their signals: a sizing a user dragged is
// an arrangement, and the layout is where an arrangement lives. Absent from an older file, the weights
// are all 1 -- equal rows -- which is what such a file was saved with.
{
std::vector<float> weights;
weights.reserve(st.rows.size());
for (const LayoutState::Row& wr : st.rows) weights.push_back(static_cast<float>(wr.weight));
app.rows->set_row_weights(std::move(weights));
}
for (int i = 0; i < rows; ++i) {
int slot = 0;
for (const std::string& name : st.rows[static_cast<std::size_t>(i)].signals) {
+268
View File
@@ -0,0 +1,268 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The preferences form: the values `settings.hpp` holds, in one place, with the settings file behind
// them. It is a *view* of that struct -- it reads one, edits it and hands it back -- so nothing here
// decides what a preference means; `PrefsForm::on_applied` is where that happens, and it happens
// differently in the app and in the source chooser.
//
// Two rows can only take effect on the next start in the app's case (the cache directory and relative
// time: the buffers are planned and the cache is opened before any stream is known), and the dialog
// says so rather than pretending otherwise. From the chooser, both are honoured by the run the
// chooser is about to start, because that run has not been planned yet.
#include "ui_prefs.hpp"
#include "event_store.hpp" // `default_events_dir()`, so the field shows the path the cache uses
#include "ui_app.hpp" // the app's own half of the contract, at the bottom of this file
#include <adwaita.h>
#include <utility>
namespace udpscope {
namespace ui_impl {
namespace {
/// Turns the theme name into the colour scheme libadwaita understands. "auto" is `DEFAULT`, which is
/// the system's own answer -- the one a user who has not chosen expects.
AdwColorScheme theme_scheme(const std::string& name) {
if (name == "light") return ADW_COLOR_SCHEME_FORCE_LIGHT;
if (name == "dark") return ADW_COLOR_SCHEME_FORCE_DARK;
return ADW_COLOR_SCHEME_DEFAULT;
}
/// Reads the form into a `Settings`. It starts from the file rather than from an empty struct so that
/// a field this dialog does not show is carried through untouched instead of being reset by every
/// apply.
Settings read_form(const PrefsForm& f) {
Settings s = load_settings();
s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_entry));
// The field shows the *effective* directory, so the default is visible in full rather than described.
// Typing it back unchanged is not a choice to pin it: the file keeps `unset`, and the default stays
// the default of whatever cache directory the next run happens to have.
if (s.event_cache_dir == default_events_dir()) s.event_cache_dir.clear();
s.event_cache_max = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(f.keep_spin));
s.relative_time = gtk_switch_get_active(GTK_SWITCH(f.relative_switch));
const guint selected = gtk_drop_down_get_selected(GTK_DROP_DOWN(f.theme_drop));
s.theme = selected == 1 ? "light" : selected == 2 ? "dark" : "auto";
return s;
}
/// Cancelling must change *nothing*: the field keeps what it had and the status line stays quiet. The
/// null check is not defensive padding -- `prefs_close()` clears every widget pointer, and a folder
/// chooser still open when its window closes is exactly how a callback lands on a dead field.
void on_browse_done(GObject* source, GAsyncResult* result, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
GFile* folder = gtk_file_dialog_select_folder_finish(GTK_FILE_DIALOG(source), result, nullptr);
if (folder == nullptr) return;
char* path = g_file_get_path(folder);
g_object_unref(folder);
if (path == nullptr) return;
if (f.dir_entry != nullptr) gtk_editable_set_text(GTK_EDITABLE(f.dir_entry), path);
g_free(path);
}
/// `GtkFileDialog`, the same one the source chooser's *Open…* uses, rather than the older
/// `GtkFileChooserDialog`: on a desktop with a portal it hands over to the system's own picker instead
/// of drawing a GTK-shaped impression of one.
void on_browse(GtkButton*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
GtkFileDialog* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Event cache directory");
const char* current =
f.dir_entry != nullptr ? gtk_editable_get_text(GTK_EDITABLE(f.dir_entry)) : "";
if (current != nullptr && current[0] != '\0') {
GFile* folder = g_file_new_for_path(current);
gtk_file_dialog_set_initial_folder(dialog, folder);
g_object_unref(folder);
}
gtk_file_dialog_select_folder(dialog, f.window != nullptr ? GTK_WINDOW(f.window) : nullptr, nullptr,
on_browse_done, &f);
g_object_unref(dialog); // the call holds its own reference
}
void on_defaults(GtkButton*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
const Settings d = default_settings();
gtk_editable_set_text(GTK_EDITABLE(f.dir_entry),
(d.event_cache_dir.empty() ? default_events_dir() : d.event_cache_dir).c_str());
gtk_spin_button_set_value(GTK_SPIN_BUTTON(f.keep_spin), d.event_cache_max);
gtk_switch_set_active(GTK_SWITCH(f.relative_switch), d.relative_time);
gtk_drop_down_set_selected(GTK_DROP_DOWN(f.theme_drop), 0);
}
void on_apply(GtkButton*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
const Settings s = read_form(f);
const bool written = save_settings(s);
// The theme goes on even when the write failed: the user asked to see it, and the file is the
// memory of the choice rather than the choice itself. The status line is what says it will not
// survive a restart.
apply_theme(s.theme);
if (f.status != nullptr) {
gtk_label_set_text(GTK_LABEL(f.status),
written ? "" : "could not write the preferences file -- this session only");
gtk_widget_set_visible(f.status, !written);
}
if (f.on_applied) f.on_applied(s, written);
}
/// The one close path, whether it came from the button, the window manager or `prefs_close()`.
gboolean on_close_request(GtkWindow*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
f.window = nullptr;
f.dir_entry = nullptr;
f.browse = nullptr;
f.keep_spin = nullptr;
f.relative_switch = nullptr;
f.theme_drop = nullptr;
f.apply = nullptr;
f.status = nullptr;
return FALSE; // the default handler destroys the window
}
void on_close_clicked(GtkButton*, gpointer user) {
prefs_close(*static_cast<PrefsForm*>(user));
}
} // namespace
void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
std::function<void(const Settings&, bool written)> on_applied) {
if (form.window != nullptr) {
gtk_window_present(GTK_WINDOW(form.window));
return;
}
form.on_applied = std::move(on_applied);
GtkWidget* window = dialog_window(parent, "Preferences");
form.window = window;
g_signal_connect(window, "close-request", G_CALLBACK(on_close_request), &form);
GtkWidget* column = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_top(column, 12);
gtk_widget_set_margin_bottom(column, 12);
gtk_widget_set_margin_start(column, 12);
gtk_widget_set_margin_end(column, 12);
// --- the event cache ------------------------------------------------------
GtkWidget* cache_heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(cache_heading), "<b>Event cache</b>");
gtk_widget_set_halign(cache_heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(column), cache_heading);
form.dir_entry = gtk_entry_new();
gtk_editable_set_text(GTK_EDITABLE(form.dir_entry),
(current.event_cache_dir.empty() ? default_events_dir()
: current.event_cache_dir)
.c_str());
gtk_widget_set_tooltip_text(form.dir_entry,
"where events are cached: one directory in which every source gets a "
"folder of its own, never one directory between two sources. Left at "
"the default shown here, it follows the cache directory");
// A standard icon rather than the word: an icon-only button needs its meaning kept somewhere, so the
// name goes to assistive technology and the explanation to the tooltip. `folder-open-symbolic` is
// Adwaita's, not one of the six this program bundles -- it is the theme's own word for "choose a
// directory", and a second drawing of it here would only be a worse one.
form.browse = gtk_button_new_from_icon_name("folder-open-symbolic");
gtk_widget_set_tooltip_text(form.browse,
"choose the directory in a file chooser; what it returns is put in "
"the field beside it, which stays the value that is stored");
// Terminated by -1, not by a null: this call is varargs, and `nullptr` reads as property id 0 --
// which is `autocomplete`, not "no more properties", so GTK then reads the next word as its value.
gtk_accessible_update_property(GTK_ACCESSIBLE(form.browse), GTK_ACCESSIBLE_PROPERTY_LABEL,
"Choose the event cache directory", -1);
g_signal_connect(form.browse, "clicked", G_CALLBACK(on_browse), &form);
GtkWidget* dir_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_box_append(GTK_BOX(dir_row), form.dir_entry);
gtk_box_append(GTK_BOX(dir_row), form.browse);
gtk_box_append(GTK_BOX(column), labeled_box("directory", dir_row));
form.keep_spin = spin(0, 100000, 1, 0, current.event_cache_max);
gtk_widget_set_tooltip_text(form.keep_spin,
"how many events one source keeps; the oldest are deleted as new ones "
"arrive. 0 keeps every event, which is a decision about the disk");
gtk_box_append(GTK_BOX(column), labeled_box("keep events", form.keep_spin));
// --- the plot -------------------------------------------------------------
GtkWidget* plot_heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(plot_heading), "<b>Plot</b>");
gtk_widget_set_halign(plot_heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(column), plot_heading);
form.relative_switch = gtk_switch_new();
gtk_switch_set_active(GTK_SWITCH(form.relative_switch), current.relative_time);
gtk_widget_set_tooltip_text(form.relative_switch,
"ignore the stream's own clock and plot from the sample rate and the "
"sample index, for a source whose time stamps jitter");
gtk_box_append(GTK_BOX(column), labeled_switch_row("relative time", form.relative_switch));
const char* themes[] = {"auto", "light", "dark", nullptr};
form.theme_drop = gtk_drop_down_new_from_strings(themes);
gtk_drop_down_set_selected(GTK_DROP_DOWN(form.theme_drop),
current.theme == "light" ? 1 : current.theme == "dark" ? 2 : 0);
gtk_widget_set_tooltip_text(form.theme_drop,
"what the interface looks like; auto follows the system");
gtk_box_append(GTK_BOX(column), labeled_box("theme", form.theme_drop));
// --- the way out ----------------------------------------------------------
form.status = gtk_label_new(nullptr);
gtk_label_set_wrap(GTK_LABEL(form.status), TRUE);
gtk_label_set_xalign(GTK_LABEL(form.status), 0.0F);
gtk_widget_add_css_class(form.status, "error");
gtk_widget_set_visible(form.status, FALSE);
gtk_box_append(GTK_BOX(column), form.status);
GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_halign(buttons, GTK_ALIGN_END);
GtkWidget* defaults = gtk_button_new_with_label("Defaults");
g_signal_connect(defaults, "clicked", G_CALLBACK(on_defaults), &form);
gtk_box_append(GTK_BOX(buttons), defaults);
form.apply = gtk_button_new_with_label("Apply");
gtk_widget_add_css_class(form.apply, "suggested-action");
g_signal_connect(form.apply, "clicked", G_CALLBACK(on_apply), &form);
gtk_box_append(GTK_BOX(buttons), form.apply);
GtkWidget* close = gtk_button_new_with_label("Close");
g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &form);
gtk_box_append(GTK_BOX(buttons), close);
gtk_box_append(GTK_BOX(column), buttons);
gtk_window_set_child(GTK_WINDOW(window), column);
gtk_window_present(GTK_WINDOW(window));
}
void prefs_close(PrefsForm& form) {
if (form.window == nullptr) return;
GtkWidget* window = form.window;
form.window = nullptr; // before the close, so `close-request` cannot re-enter a stale window
gtk_window_close(GTK_WINDOW(window));
}
void apply_theme(const std::string& name) {
AdwStyleManager* manager = adw_style_manager_get_default();
if (manager != nullptr) adw_style_manager_set_color_scheme(manager, theme_scheme(name));
}
void prefs_probe_fill_and_apply(PrefsForm& form) {
if (form.window == nullptr) return;
gtk_editable_set_text(GTK_EDITABLE(form.dir_entry), "/tmp/udpscope-prefs-events");
gtk_spin_button_set_value(GTK_SPIN_BUTTON(form.keep_spin), 7);
gtk_switch_set_active(GTK_SWITCH(form.relative_switch), TRUE);
gtk_drop_down_set_selected(GTK_DROP_DOWN(form.theme_drop), 2);
if (form.apply != nullptr) g_signal_emit_by_name(form.apply, "clicked");
}
/// The app's entry point: the preferences button in the overflow menu. Kept here rather than in
/// `ui.cpp` with the rest of the chrome, because it is three lines of *this* form's contract -- what
/// the app must do when a preference changes -- and reading it next to the form is the only way to
/// see that the two agree.
void open_preferences(App& app) {
prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) {
app.settings = s;
set_notice(app, written ? "preferences saved" : "could not write the preferences file", 4);
});
}
} // namespace ui_impl
} // namespace udpscope
+70
View File
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The preferences form, without an owner.
//
// It used to be six widget fields on `App` and a function taking `App&`, which made it unreachable
// from the one other place that needs it: the source chooser, which runs *before* `App` exists --
// `main()` chooses a source, and only then builds the model and the UI. So the widgets and what they
// contain are a struct the caller owns, and what a change *means* is a callback, because it means
// different things on the two sides: the app follows its own switch and puts up a notice, the chooser
// hands relative time to the run it is about to start.
#ifndef UDPSCOPE_UI_PREFS_HPP
#define UDPSCOPE_UI_PREFS_HPP
#include <functional>
#include <string>
#include <gtk/gtk.h>
#include "settings.hpp"
namespace udpscope {
namespace ui_impl {
/// One preferences window's widgets, owned by whoever opened it.
///
/// Every pointer is valid only while `window` is non-null: the close path clears the lot, so a handler
/// that fires after the window went away reads null rather than freed memory.
struct PrefsForm {
GtkWidget* window = nullptr;
GtkWidget* dir_entry = nullptr;
/// The folder chooser beside it. A text field alone asks a user to know a path and type it correctly;
/// the chooser is how they find one, and it writes what it found into the same field -- so the text
/// stays the single value, and there is nothing to keep in step.
GtkWidget* browse = nullptr;
GtkWidget* keep_spin = nullptr;
GtkWidget* relative_switch = nullptr;
GtkWidget* theme_drop = nullptr;
GtkWidget* apply = nullptr;
/// Shown only when a write fails. A preference that silently does not stick is worse than one that
/// says so, and the two owners have nowhere in common to say it: the app has a status bar, the
/// chooser does not.
GtkWidget* status = nullptr;
/// Called on *Apply*, with what the form held and whether it reached the file -- which is already
/// written by the time this runs. Empty is allowed.
std::function<void(const Settings&, bool written)> on_applied;
};
/// Opens the preferences for `form`, transient for `parent` (which may be null), starting from
/// `current`. Called while the window is up it raises that window instead of building a second one:
/// two forms editing one file is how a user's change disappears.
void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
std::function<void(const Settings&, bool written)> on_applied);
/// Closes it. Safe to call when it is not open, and safe to call from inside the apply callback.
void prefs_close(PrefsForm& form);
/// Puts the interface into the named theme at once. Separate from the form because it is the one
/// preference with nothing to plan: it applies to windows that are already on screen.
void apply_theme(const std::string& name);
/// Fills every field the way a user would and presses *Apply*: the self-check's two entry points both
/// end up here, so the app's probe and the chooser's probe cannot drift apart in what they exercise.
void prefs_probe_fill_and_apply(PrefsForm& form);
} // namespace ui_impl
} // namespace udpscope
#endif // UDPSCOPE_UI_PREFS_HPP
+393 -11
View File
@@ -1505,6 +1505,22 @@ void layout_probe_report(App& a, const char* what) {
}
if (!any) traces += "-";
}
// Where the resize arrows are and whether they are on screen: a boundary handle placed at the wrong
// index is invisible in a button count, and the one thing this control must get right is *which*
// border it sits on.
std::string resize_tops;
for (GtkWidget* w : a.layout_buttons) {
if (g_object_get_data(G_OBJECT(w), "udpscope-layout-resize") == nullptr) continue;
if (gtk_widget_get_visible(w)) {
const int top = gtk_widget_get_margin_top(w);
resize_tops += (resize_tops.empty() ? "" : ",") + std::to_string(row_key(w)) + ":" +
std::to_string(top);
} else {
resize_tops += std::string(resize_tops.empty() ? "" : ",") + "(" + std::to_string(row_key(w)) +
":-)";
}
}
if (resize_tops.empty()) resize_tops = "-";
// Where the Done button ended up, in the overlay's own coordinates: the assertion is
// that it hugs the area's top-right corner, so a layout change that pushed it off the
// corner shows here as numbers rather than as a screenshot someone has to look at.
@@ -1520,13 +1536,175 @@ void layout_probe_report(App& a, const char* what) {
}
}
std::fprintf(stderr, "[layout-probe] %-12s rows=%d buttons=%zu done=%d done_box=[%s] "
"rows_y=[%s] close=[%s] add=[%s] traces=[%s]\n",
"rows_y=[%s] close=[%s] add=[%s] resize=[%s] traces=[%s]\n",
what, rows, a.layout_buttons.size(), a.layout_done_button != nullptr ? 1 : 0,
done_box.c_str(), geom.c_str(), close_tops.c_str(), add_tops.c_str(),
resize_tops.c_str(),
traces.c_str());
std::fflush(stderr);
}
/// The preferences dialog, driven the way a user drives it: opened, filled in, *Apply* clicked, then
/// the file read back from disk. Reading it back rather than checking `App::settings` is the point --
/// a dialog that edits its copy and never writes is the failure this has to catch.
/// `UDPSCOPE_PROBE_BOXZOOM_MS`: what a Ctrl box zoom puts on the plot, and what it does to the axis.
///
/// The gesture is injected straight into the row's controller -- the way a headless probe has to, since
/// the area's gestures arrive from GTK -- and both halves are read back a few ticks later. The *axis* is
/// the half a rect cannot report: it is the *settled* one that matters, after the pin or the data clamp
/// has had its say, and it is the half that silently does nothing in a view whose axis is pinned.
///
/// Three named steps rather than nested lambdas: `g_timeout_add` takes a plain function pointer, so a
/// lambda can carry no state, and the reading needs what the axis was before the drag.
struct BoxZoomProbe {
App* app = nullptr;
plot::Range before{0.0, 0.0};
};
plot::Range boxzoom_axis(App& app) {
const RowSet::Row& r = app.rows->row(0);
return r.plot != nullptr ? r.plot->x_range(r.axis_x) : plot::Range{0.0, 0.0};
}
gboolean boxzoom_probe_done(gpointer user) {
BoxZoomProbe& probe = *static_cast<BoxZoomProbe*>(user);
App& app = *probe.app;
const plot::Rect frame = app.rows->figure().plot(0u).selection_rect();
const plot::Range now = boxzoom_axis(app);
std::fprintf(stderr,
"[boxzoom-probe] closed: frame=%.0fx%.0f gone=%d axis_after=[%.6f..%.6f] zoomed=%d\n",
static_cast<double>(frame.w), static_cast<double>(frame.h),
(frame.w == 0.0f && frame.h == 0.0f) ? 1 : 0, static_cast<double>(now.min),
static_cast<double>(now.max),
(now.span() < probe.before.span() * 0.999) ? 1 : 0);
return G_SOURCE_REMOVE;
}
gboolean boxzoom_probe_release(gpointer user) {
App& app = *static_cast<BoxZoomProbe*>(user)->app;
plot::input::Controller* c = app.area->row_controller(0);
if (c != nullptr) {
const plot::u32 ctrl = static_cast<plot::u32>(plot::input::Mod::Ctrl);
c->on_pointer({300.0f, 160.0f, 0.0f, 0.0f, plot::input::Button::Left, ctrl, false, false, true});
}
g_timeout_add_full(G_PRIORITY_DEFAULT, 80, boxzoom_probe_done, user,
+[](gpointer p) { delete static_cast<BoxZoomProbe*>(p); });
return G_SOURCE_REMOVE;
}
gboolean boxzoom_probe_open(gpointer user) {
BoxZoomProbe& probe = *static_cast<BoxZoomProbe*>(user);
App& app = *probe.app;
plot::Plot& plot = app.rows->figure().plot(0u);
const plot::Rect frame = plot.selection_rect();
const plot::Rect axes = plot.layout().axes;
// The frame is the band the *release* will apply: the width the pointer swept, and the height of the
// axes rather than the ten pixels the pointer also moved -- Ctrl zooms x only.
const bool band = std::fabs(frame.w - 200.0f) < 1.0f && std::fabs(frame.h - axes.h) < 1.0f;
std::fprintf(stderr,
"[boxzoom-probe] open: swept=200x10 frame=%.0f,%.0f %.0fx%.0f axes_h=%.0f band=%d "
"axis_before=[%.6f..%.6f]\n",
static_cast<double>(frame.x), static_cast<double>(frame.y),
static_cast<double>(frame.w), static_cast<double>(frame.h),
static_cast<double>(axes.h), band ? 1 : 0, static_cast<double>(probe.before.min),
static_cast<double>(probe.before.max));
return boxzoom_probe_release(user);
}
gboolean boxzoom_probe(gpointer user) {
App& a = *static_cast<App*>(user);
if (a.area == nullptr || a.rows == nullptr) return G_SOURCE_REMOVE;
plot::input::Controller* c = a.area->row_controller(0);
if (c == nullptr) return G_SOURCE_REMOVE;
auto* probe = new BoxZoomProbe{&a, boxzoom_axis(a)};
const plot::u32 ctrl = static_cast<plot::u32>(plot::input::Mod::Ctrl);
c->on_pointer({100.0f, 150.0f, 0.0f, 0.0f, plot::input::Button::Left, ctrl, true, true, false});
c->on_pointer({300.0f, 160.0f, 200.0f, 10.0f, plot::input::Button::Left, ctrl, true, false, false});
// No destroy here: the *last* step owns the struct, because the middle step schedules it. A destroy
// on every step would free it when the first one returns `G_SOURCE_REMOVE` -- and the step it just
// armed would then read freed memory, which is a segfault in a self-check rather than a failure.
g_timeout_add(80, boxzoom_probe_open, probe);
return G_SOURCE_REMOVE;
}
gboolean prefs_probe(gpointer user) {
App& a = *static_cast<App*>(user);
open_preferences(a);
// `dir` is the directory the dialog is offering and `store` is the one the cache is really using:
// `store` must be `dir` plus the source's folder name, which is what says the field shows the path in
// force rather than a plausible-looking string.
const char* shown =
a.prefs.dir_entry != nullptr ? gtk_editable_get_text(GTK_EDITABLE(a.prefs.dir_entry)) : "-";
std::fprintf(stderr, "[prefs-probe] opened=%d apply=%d dir=%s store=%s\n",
a.prefs.window != nullptr ? 1 : 0, a.prefs.apply != nullptr ? 1 : 0,
a.prefs.dir_entry != nullptr ? shown : "-",
a.event_store != nullptr ? a.event_store->dir().c_str() : "-");
g_timeout_add(400, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
prefs_probe_fill_and_apply(app.prefs);
const Settings back = load_settings();
std::fprintf(stderr, "[prefs-probe] dir=%s keep=%d rel=%d theme=%s live=%d\n",
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
back.theme.c_str(),
app.settings.theme == "dark" ? 1 : 0);
// And typing the default back must not *pin* it: the file keeps "unset", so the default still
// follows whatever cache directory the next run has. An empty value here is the whole point --
// a path written into the file is a user's choice, and this one was not a choice.
gtk_editable_set_text(GTK_EDITABLE(app.prefs.dir_entry), default_events_dir().c_str());
g_signal_emit_by_name(app.prefs.apply, "clicked");
std::fprintf(stderr, "[prefs-probe] default restored: file='%s'\n",
load_settings().event_cache_dir.c_str());
// The folder chooser, opened from its button and then *cancelled from outside*: a callback that
// lands after its dialog's window is gone is what an async picker invites, and cancelling must
// change nothing at all. The preferences window is closed a beat later, so the cancel callback
// arrives while the form is still intact -- and the null checks in it are what makes the other
// order safe too.
if (app.prefs.browse != nullptr) {
g_signal_emit_by_name(app.prefs.browse, "clicked");
g_timeout_add(600, +[](gpointer u) -> gboolean {
App& app = *static_cast<App*>(u);
GListModel* tops = gtk_window_get_toplevels();
const guint n = g_list_model_get_n_items(tops);
guint extra = 0;
for (guint i = 0; i < n; ++i) {
GtkWidget* w = GTK_WIDGET(g_list_model_get_item(tops, i));
if (w != app.window && w != app.prefs.window) {
++extra;
gtk_window_close(GTK_WINDOW(w));
}
g_object_unref(w);
}
// The button is an icon and nothing else, so "the theme has this icon" is the difference
// between a control and a blank square: a name the icon theme does not know draws a
// missing-image placeholder and says nothing anywhere else.
std::fprintf(stderr, "[prefs-probe] browse: toplevels=%u extra=%u icon_ok=%d field='%s'\n", n,
extra,
app.prefs.browse != nullptr &&
gtk_icon_theme_has_icon(
gtk_icon_theme_get_for_display(
gtk_widget_get_display(app.prefs.browse)),
"folder-open-symbolic")
? 1
: 0,
app.prefs.dir_entry != nullptr
? gtk_editable_get_text(GTK_EDITABLE(app.prefs.dir_entry))
: "-");
g_timeout_add(600, +[](gpointer u2) -> gboolean {
prefs_close(static_cast<App*>(u2)->prefs);
return G_SOURCE_REMOVE;
}, &app);
return G_SOURCE_REMOVE;
}, &app);
return G_SOURCE_REMOVE;
}
prefs_close(app.prefs);
return G_SOURCE_REMOVE;
}, &a);
return G_SOURCE_REMOVE;
}
void layout_probe_next(App& a, int step, guint delay_ms) {
a.dbg_layout_step = step;
g_timeout_add(delay_ms, layout_probe_step, &a);
@@ -2670,6 +2848,12 @@ gboolean events_probe_step(gpointer user) {
// history button (which is only there once the first two have happened -- that is the item's rule,
// and this is where it is asserted).
gtk_switch_set_active(GTK_SWITCH(a->events_record_switch), TRUE);
// An 80% pre-trigger share, so the event that latches is *not* symmetric: the window a click
// opens must be the event's own span, and with a symmetric event the live window would look
// identical and say nothing.
if (a->trigger_pre_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(a->trigger_pre_spin), 80.0);
}
report_events_availability(*a, "recording on, no trigger");
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
@@ -2749,7 +2933,7 @@ gboolean events_probe_step(gpointer user) {
gtk_picture_get_paintable(GTK_PICTURE(a->events_thumb)) != nullptr;
std::fprintf(stderr,
"[events] activated row0: replaying=%d pane=%d pane_w=%d pane_box=%d tick=%d "
"window=%.3f "
"window=%.3f axis=[%.3f,%.3f] marker=%.3f "
"thumbnail=%d thumb_size=%dx%d thumb_visible=%d\n",
a->replaying ? 1 : 0,
a->events_revealer != nullptr && gtk_widget_get_visible(a->events_revealer) ? 1 : 0,
@@ -2760,7 +2944,19 @@ gboolean events_probe_step(gpointer user) {
a->events_revealer != nullptr && gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)) != nullptr
? gtk_widget_get_width(gtk_revealer_get_child(GTK_REVEALER(a->events_revealer)))
: -1,
signal_tick_visible(*a), a->rows->time_window(), picture ? 1 : 0,
signal_tick_visible(*a), a->rows->time_window(),
a->rows->row(0).plot != nullptr
? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).min
: 0.0,
a->rows->row(0).plot != nullptr
? a->rows->row(0).plot->x_range(a->rows->row(0).axis_x).max
: 0.0,
// The trigger line's own cursor: 0 is the trigger instant and a large negative is the
// park. A file that opens without its trigger line reads the parked value here.
a->rows->row(0).plot != nullptr
? a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x
: 0.0,
picture ? 1 : 0,
a->events_thumb != nullptr ? gtk_widget_get_width(a->events_thumb) : -1,
a->events_thumb != nullptr ? gtk_widget_get_height(a->events_thumb) : -1,
a->events_thumb != nullptr && gtk_widget_get_mapped(a->events_thumb) ? 1 : 0);
@@ -2875,6 +3071,17 @@ gboolean events_group_probe_step(gpointer user) {
const plot::Range bx = a->rows_b->row(0).plot->x_range(a->rows_b->row(0).axis_x);
std::fprintf(stderr, " list=%d x_a=[%.3f,%.3f] x_b=[%.3f,%.3f] same_x=%d", entries, ax.min,
ax.max, bx.min, bx.max, (ax.min == bx.min && ax.max == bx.max) ? 1 : 0);
// Which *channel* each plot's row 0 draws: the comparison is only a comparison if the second
// plot's slots resolved to the other event's channels (and not to the first event's).
std::fprintf(stderr, " channels_a=[");
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
std::fprintf(stderr, "%d%s", a->rows->channel_of(0, s), s + 1 < RowSet::kSlotsPerRow ? "," : "");
}
std::fprintf(stderr, "] channels_b=[");
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
std::fprintf(stderr, "%d%s", a->rows_b->channel_of(0, s), s + 1 < RowSet::kSlotsPerRow ? "," : "");
}
std::fprintf(stderr, "]");
// A y range nobody would arrive at by accident, set on the first plot only.
a->rows->row(0).plot->set_y_range(a->rows->row(0).axis_y, plot::Range{-3.5, 3.5});
}
@@ -2897,10 +3104,76 @@ gboolean events_group_probe_step(gpointer user) {
}
std::fprintf(stderr, "[events] compare sync: y_a=%.3f y_b=%.3f mirrored=%d\n", y_a, y_b,
(y_a == y_b && y_b == -3.5) ? 1 : 0);
events_group_probe_next(*a, 57, 400);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 57) {
// The drop, in a *file* view: the signal is not on any plot yet, so `on_drop_channel()` takes the
// path a drag takes -- and what it must not do is rebuild the model, which in this mode has no
// producer to refill it.
const std::size_t before = a->model->channels().size();
std::uint64_t samples_before = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (before > 0) samples_before = a->model->live().values(0).produced();
}
GValue value = G_VALUE_INIT;
g_value_init(&value, G_TYPE_STRING);
g_value_set_string(&value, "1"); // the second signal: on no row in the default layout
const gboolean ok = on_drop_channel(nullptr, &value, 40.0, 20.0, a);
g_value_unset(&value);
std::uint64_t samples_after = 0;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
if (before > 0) samples_after = a->model->live().values(0).produced();
}
std::fprintf(stderr,
"[events] drop in a file view: ok=%d channels=%zu slot0=['%s'] slot1=['%s'] "
"samples=%llu->%llu\n",
ok ? 1 : 0, a->model->channels().size(),
a->rows->row(0).signal_name[0].c_str(), a->rows->row(0).signal_name[1].c_str(),
static_cast<unsigned long long>(samples_before),
static_cast<unsigned long long>(samples_after));
// One step later, after the figure has been rebuilt and the area re-pointed: what the row now
// *draws* -- the slot's channel and the traces the plot reports as visible.
events_group_probe_next(*a, 58, 600);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 58) {
std::fprintf(stderr, "[events] drop result: slot1_channel=%d visible_traces=%d\n",
a->rows->channel_of(0, 1), group_visible_traces(*a, 0));
events_group_probe_next(*a, 5, 400);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 59) {
// A comparison *after* an accumulation: the accumulation left layers in slots a comparison does not
// use, and those used to stay -- extra legend entries, in colours belonging to nothing.
if (a->events_compare != nullptr) g_signal_emit_by_name(a->events_compare, "clicked");
events_group_probe_next(*a, 60, 700);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 60) {
const RowSet::Row& r0 = a->rows->row(0);
std::fprintf(stderr, "[events] compare after accumulate: kind=%d traces0=%d legend0=%zu slots=[",
static_cast<int>(a->group_kind), group_visible_traces(*a, 0),
r0.plot != nullptr ? r0.plot->layout().legend.rows.size() : 0UL);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
std::fprintf(stderr, "%s'%s'", s ? "," : "",
a->rows->row(0).signal_name[static_cast<std::size_t>(s)].c_str());
}
std::fprintf(stderr, "]\n");
std::fflush(stderr);
go_live_again(*a);
std::fprintf(stderr, "[events] group left: replaying=%d kind=%d waveform=%d\n", a->replaying ? 1 : 0,
static_cast<int>(a->group_kind), a->waveform_name.empty() ? 0 : 1);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
if (a->dbg_events_step == 5) {
if (a->events_accumulate != nullptr) g_signal_emit_by_name(a->events_accumulate, "clicked");
events_group_probe_next(*a, 55, 700);
@@ -2918,12 +3191,7 @@ gboolean events_group_probe_step(gpointer user) {
static_cast<unsigned long long>(a->model->live().values(0).produced()),
selected_events(*a).size(), group_visible_traces(*a, 0), a->rows->time_window());
}
// A group is a file view, so the way out of it is the way out of a replay.
go_live_again(*a);
std::fprintf(stderr, "[events] group left: replaying=%d kind=%d waveform=%d\n",
a->replaying ? 1 : 0, static_cast<int>(a->group_kind),
a->waveform_name.empty() ? 0 : 1);
std::fflush(stderr);
events_group_probe_next(*a, 59, 400);
return G_SOURCE_REMOVE;
}
return G_SOURCE_REMOVE;
@@ -3080,6 +3348,114 @@ gboolean keys_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// What the shortcut overview's UI file *declares*, read out of the resource rather than off the
/// built widgets: `GtkShortcutsWindow` reaches each row from more than one place in its own tree, so a
/// walk counts every row twice (52 for 26), and what a reader counts on the screen is the declaration.
///
/// The two numbers that matter are `with_accel`, the rows drawn as keycaps, and `with_icon`, the ones
/// drawn as an icon. `bare` is a row with *neither*, which is what a cell reading "No Shortcut" is --
/// the reason this overview is a GTK window built from a UI file rather than libadwaita's dialog, whose
/// items have no icon property at all. It must be zero.
struct UiShortcuts {
int rows = -1;
int with_accel = 0;
int with_icon = 0;
int bare = 0;
// Per-row state, and the depth that tells a row's own `</object>` from the one closing the
// `GThemedIcon` nested inside its icon property.
int depth = 0;
int row_depth = -1;
bool row_accel = false;
bool row_icon = false;
bool in_property = false;
std::string property;
};
const char* attribute(const gchar** names, const gchar** values, const char* want) {
for (int i = 0; names[i] != nullptr; ++i) {
if (g_strcmp0(names[i], want) == 0) return values[i];
}
return nullptr;
}
void ui_row_start(GMarkupParseContext*, const gchar* name, const gchar** names, const gchar** values,
gpointer user, GError**) {
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
if (g_strcmp0(name, "object") == 0) {
++out.depth;
if (g_strcmp0(attribute(names, values, "class"), "GtkShortcutsShortcut") == 0) {
out.row_depth = out.depth;
out.row_accel = false;
out.row_icon = false;
}
return;
}
if (out.row_depth < 0 || g_strcmp0(name, "property") != 0) return;
const char* prop = attribute(names, values, "name");
out.property = prop != nullptr ? prop : "";
// Only an accelerator is carried as text; an icon's value is a nested `GThemedIcon` object, so its
// *declaration* is the whole of what there is to see and it is marked here rather than in the text
// callback that would never fire for it.
out.in_property = out.property == "accelerator";
if (out.property == "icon") out.row_icon = true;
}
/// The text callbacks carry the property's *value*; an empty `accelerator` is not a keycap, so only
/// non-empty text counts.
void ui_row_text(GMarkupParseContext*, const gchar* text, gsize len, gpointer user, GError**) {
(void)text; // the length is what the callback is asked for; the bytes are not
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
if (!out.in_property || len == 0) return;
if (out.property == "accelerator") out.row_accel = true;
}
void ui_row_end(GMarkupParseContext*, const gchar* name, gpointer user, GError**) {
UiShortcuts& out = *static_cast<UiShortcuts*>(user);
if (g_strcmp0(name, "property") == 0) {
out.in_property = false;
return;
}
if (g_strcmp0(name, "object") != 0) return;
if (out.depth == out.row_depth) {
out.row_depth = -1;
++out.rows;
if (out.row_accel) {
++out.with_accel;
} else if (out.row_icon) {
++out.with_icon;
} else {
++out.bare;
}
}
--out.depth;
}
UiShortcuts ui_shortcut_rows(const char* path) {
UiShortcuts out;
GError* error = nullptr;
GBytes* bytes = g_resources_lookup_data(path, G_RESOURCE_LOOKUP_FLAGS_NONE, &error);
if (bytes == nullptr) {
if (error != nullptr) g_error_free(error);
return out;
}
gsize len = 0;
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &len));
GMarkupParser parser{&ui_row_start, &ui_row_end, &ui_row_text, nullptr, nullptr};
if (GMarkupParseContext* ctx = g_markup_parse_context_new(&parser, G_MARKUP_DEFAULT_FLAGS, &out,
nullptr);
ctx != nullptr) {
out.rows = 0;
if (!g_markup_parse_context_parse(ctx, data, static_cast<gssize>(len), &error) && error != nullptr) {
g_error_free(error);
out.rows = 0; // a file this probe cannot parse is not a file with no rows
}
g_markup_parse_context_free(ctx);
}
g_bytes_unref(bytes);
return out;
}
gboolean help_probe(gpointer user) {
App* a = static_cast<App*>(user);
const HelpReport report = help_self_check();
@@ -3098,11 +3474,17 @@ gboolean help_probe(gpointer user) {
int gesture_count = 0;
(void)app_accels(&accel_count);
(void)plot_gestures(&gesture_count);
// The tables are what the application handles, the UI file is what it shows. They are compared here
// because they are two lists of the same thing: a row in one and not the other is a gesture that
// works and is not documented, or is documented and does nothing.
const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui");
std::fprintf(stderr,
"[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d; "
"[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d tables=%d "
"ui=%d ui_accel=%d ui_icon=%d ui_bare=%d; "
"about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n",
report.pages, report.parsed, report.shortest_title.c_str(),
report.shortest_bytes, accel_count, gesture_count, build_info::kVersion,
report.shortest_bytes, accel_count, gesture_count, accel_count + gesture_count,
ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion,
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
about_icon.c_str());
std::fflush(stderr);
+15
View File
@@ -118,6 +118,21 @@ GtkWidget* menu_separator() {
return gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
}
/// A labelled row whose control keeps its *natural* width. A `GtkSwitch`'s natural width is its size,
/// so `labeled_box()`'s expanding child stretches it into a wide pill that reads as a slider -- this is
/// the row a switch wants, with the label taking the slack instead.
GtkWidget* labeled_switch_row(const char* label, GtkWidget* control) {
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* text = gtk_label_new(label);
gtk_widget_set_halign(text, GTK_ALIGN_START);
gtk_widget_set_hexpand(text, TRUE);
gtk_box_append(GTK_BOX(row), text);
gtk_widget_set_halign(control, GTK_ALIGN_END);
gtk_widget_set_valign(control, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(row), control);
return row;
}
GtkWidget* labeled_box(const char* label, GtkWidget* child) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* l = gtk_label_new(label);
+9 -4
View File
@@ -11,11 +11,11 @@
- time is simply: `((<N_frame> - <N_last>) * <n_samples_per_frame> - <sample_index>) / <sample rate>` (or something like that)
- [x] (3) ~~named configurations~~ **done**: one directory per source holding `<name>.toml` per configuration plus `active`; a configuration carries a note and the startup settings (`buffer_mb`, `relative_time`, `capture_packets`); the chooser lists them and the menu's _Configurations…_ opens/saves/deletes them.
- [x] (4) ~~optional udp frame storage for debugging of strange behaviours~~ **done**: `--capture-packets N`; see README and the design note in `docs/superpowers/specs/` for the ring file, the popover toggle and `tools/capture_to_pcap.py`.
- [x] (5) ~~open exported binary waveforms to view/analyse~~ **done**: `--open FILE` and the chooser's **Waveforms** page (the files opened before, newest first, plus *Open…*); the `.npy`'s dtype names the columns, so a load restores the signals, the samples and the axis the export had, and every instrument works on it — see README and `docs/claude/invariants-data-path.md`.
- [x] (5) ~~open exported binary waveforms to view/analyse~~ **done**: `--open FILE` and the chooser's **Waveforms** page (the files opened before, newest first, plus _Open…_); the `.npy`'s dtype names the columns, so a load restores the signals, the samples and the axis the export had, and every instrument works on it — see README and `docs/claude/invariants-data-path.md`.
- [x] (6) ~~imporve right click menu on the plots~~ **done**: the plot's context menu uses the main menu's row style (`menu_button` + `.udpscope-menu`, asserted by `UDPSCOPE_PROBE_ROWMENU_MS`: uniform `label_x=26`, `h=18`, the shortcut flush right), and deleting a signal is a **Remove signal** submenu listing that plot's traces plus _All traces on this plot_.
- [ ] (7) in layout edit mode add the option to drag plot border to resize them
- [x] (8) ~~add option to automatic save the last <N> triggered acquisitions in a cache folder and let the user replay them~~ **done**: every latch is written to `<cache>/<source>/events/` as a `.npy` (the export format), a `.txt` sidecar and a `.bmp` preview drawn from the samples; the **events pane** (revealed with trigger mode, switchable) lists them with time, duration, signals and rate, shows the preview, replays one through the `--open` loader and has *Live* to go back -- see README and `docs/claude/invariants-trigger-and-pinning.md`.
- [ ] (9) preferences dialog:
- [ ] (7) in layout edit mode add the option to drag plot border to resize them (sizing should be persistant between sessesions as the layout)
- [x] (8) ~~add option to automatic save the last <N> triggered acquisitions in a cache folder and let the user replay them~~ **done**: every latch is written to `<cache>/<source>/events/` as a `.npy` (the export format), a `.txt` sidecar and a `.bmp` preview drawn from the samples; the **events pane** (revealed with trigger mode, switchable) lists them with time, duration, signals and rate, shows the preview, replays one through the `--open` loader and has _Live_ to go back -- see README and `docs/claude/invariants-trigger-and-pinning.md`.
- [x] (9) ~~preferences dialog~~ **done**: `src/settings.{hpp,cpp}` (the store: load/save/defaults, GTK-free) + `src/ui_prefs.cpp` (the dialog, opened from the overflow menu); the cache directory and keep count reach `EventStore`, the cache directory (one `events/` directory for every source, each source a folder inside) and the keep count reach `EventStore`, and relative time is a start-up default; whether anything is cached at all stays the trigger pane's own switch. The same dialog opens from the **host selection dialog** too (a `PrefsForm` has no owner, so both windows hold one), where a change still reaches the run about to start.
- user can choose evnt cache directory (default <home>/.cache/udpscope/<source>/events/)
- maximum number of events to cache
- disable completly the events storage option
@@ -23,3 +23,8 @@
- interface dark/light/auto color
- defaults
- other parameters
10. [ ] move the interface into `.ui` resources, and to current GTK4/libadwaita widgets while doing it:
- the shortcut overview is **done** (`src/ui/shortcuts.ui`, with the mouse gestures drawn as icons, and `[help]`'s `ui=` against `tables=` as the guard)
- next: the preferences (`AdwPreferencesDialog` + `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`), the help window's chrome, the source chooser and its host form, then the main window's skeleton
- plan, conventions and acceptance for each slice: `docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md`