Compare commits
11
Commits
8a37e9d412
...
1fc2a3afe2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc2a3afe2 | ||
|
|
02c53bb7a0 | ||
|
|
e2be597698 | ||
|
|
f2978fe742 | ||
|
|
a055325831 | ||
|
|
33eee0c8d0 | ||
|
|
6deb53c458 | ||
|
|
f3d68cfdd4 | ||
|
|
69eab02729 | ||
|
|
e17c650ea7 | ||
|
|
7816f11c81 |
No files matched your search
@@ -13,6 +13,25 @@ cmake --build build
|
||||
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||
```
|
||||
|
||||
The app can be launched from the shell rather than only from a terminal, but only on request:
|
||||
|
||||
```sh
|
||||
cmake --build build --target desktop-entry
|
||||
```
|
||||
|
||||
That writes `org.udpscope.app.desktop` plus both app icons under `$XDG_DATA_HOME` (default
|
||||
`~/.local/share`), runs `desktop-file-validate` as a **hard failure** (when it is on `PATH`;
|
||||
absent, it says so and installs anyway), refreshes `update-desktop-database` and
|
||||
`gtk4-update-icon-cache` when they are on `PATH`, and prints the three paths it wrote — which is
|
||||
the uninstall instructions, since there is no uninstall target. `cmake --build build` alone never
|
||||
touches `$XDG_DATA_HOME`.
|
||||
|
||||
The entry's `Exec` is the built binary's **absolute path in the build tree**, so **moving or
|
||||
deleting the build directory breaks it.** That is the price of having no install step and it is
|
||||
deliberate: the entry then always launches exactly the binary that was last built. No
|
||||
`StartupWMClass` is needed — GTK takes the WM class from the application ID, and the desktop
|
||||
file's basename is that same string, which is what associates a running window with the entry.
|
||||
|
||||
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
||||
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
||||
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
|
||||
@@ -25,16 +44,21 @@ form for a new host. Every probe below passes `--server`, so none of them sees i
|
||||
vendored C99 source pair compiled into a static lib.
|
||||
|
||||
**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`),
|
||||
for exactly one widget: the shortcut overview is an `AdwShortcutsDialog`. GTK4's own
|
||||
`GtkShortcutsWindow` is deprecated as of 4.18 and that is its replacement. Two consequences
|
||||
worth knowing before editing. `adw_init()` is called — in `run_ui()` and, since the chooser
|
||||
runs first and must be styled the same, in `choose_source()` in place of its `gtk_init()`
|
||||
(both are idempotent) — so **the whole app is drawn with libadwaita's stylesheet**, not
|
||||
GTK's: a metric change there reaches every widget, and `layout over N frames` /
|
||||
`axes.w changes` is what says whether the layout still settles. And an `AdwDialog` is
|
||||
**not** a `GtkWindow`: `show_shortcuts()` keeps an `AdwDialog*`, clears it on `::closed`
|
||||
(there is no `::close-request`) and closes it with `adw_dialog_close()`. Presenting it on
|
||||
this app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
|
||||
for two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog
|
||||
(`AdwAboutDialog`). GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and
|
||||
`AdwShortcutsDialog` is its replacement; `GtkAboutDialog` still exists but
|
||||
`adw_about_window_new()` is deprecated in favour of `AdwAboutDialog`, which shows the
|
||||
application icon as the page's own header. Two consequences worth knowing before editing.
|
||||
`adw_init()` is called — in `run_ui()` and, since the chooser runs first and must be styled
|
||||
the same, in `choose_source()` in place of its `gtk_init()` (both are idempotent) — so
|
||||
**the whole app is drawn with libadwaita's stylesheet**, not GTK's: a metric change there
|
||||
reaches every widget, and `layout over N frames` / `axes.w changes` is what says whether
|
||||
the layout still settles. And an `AdwDialog` is **not** a `GtkWindow`: both `show_about()`
|
||||
and `show_shortcuts()` keep an `AdwDialog*`, cleared on `::closed` (there is no
|
||||
`::close-request`) and closed with `adw_dialog_close()`. They share one `on_dialog_closed()`
|
||||
handler keyed by a pointer-to-pointer (`gpointer slot` cast to `AdwDialog**`), so one
|
||||
handler clears either pointer without knowing which dialog it is. Presenting either on this
|
||||
app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
|
||||
|
||||
`deps/plotter` is upstream's own repository and this app's figure support was added
|
||||
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
|
||||
@@ -76,6 +100,15 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Two baselines every run is read against. A clean build emits **five** warnings and no more: two
|
||||
`-Wmissing-field-initializers` on `MathNode::source`, one unused `output_built`, and
|
||||
`-Woverlength-strings` on **both** generated GResource `.c` files — the help pages' and the
|
||||
icons', whose blob crossed C99's 4095-byte string-literal limit when the two app icons joined the
|
||||
four action ones (a note about a generated file, which GCC itself supports). And an exit-dump run
|
||||
emits exactly **two** `Gtk-CRITICAL`s, `gtk_widget_get_height` and `gtk_widget_get_width`, from
|
||||
the dump asking a destroyed widget for its size. A sixth warning, or a third critical, is a
|
||||
finding.
|
||||
|
||||
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
|
||||
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
|
||||
frames, trace bindings, panel sizes, status line in both states).
|
||||
@@ -460,6 +493,28 @@ Two lines of the self-check output are assertions in disguise:
|
||||
substitutes a missing-image placeholder, the button still works, and every other line of the
|
||||
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
|
||||
is the only check that it names something that exists.
|
||||
- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump,
|
||||
beside `toolbar icons:`) — the application icon, named and present at the size its own root
|
||||
`<svg>` element declares. **Both entries must read `ok` at those sizes.** `ok` is
|
||||
`gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a
|
||||
`MISSING` is a resource path that was never registered or a file that was renamed, and a size
|
||||
other than 128 or 16 is a canvas that drifted off the HIG's. **Read it together with
|
||||
`toolbar icons:`, and trust that line further for the registration**: the resource-path
|
||||
registration is one call in `run_ui()` serving both the window's icon and the toolbar's buttons,
|
||||
so a `MISSING` on either line is the same defect — but on a machine where
|
||||
`--target desktop-entry` has been run, *this* line's `has_icon` half is also satisfied by the
|
||||
**installed** copy under `$XDG_DATA_HOME/icons/hicolor`, so a broken registration still reads
|
||||
`ok` here. That was measured: with the resource path deliberately misspelt, `toolbar icons:`
|
||||
reported four `MISSING` (nothing installs the action icons) while `app icons:` stayed `ok`
|
||||
until the two installed SVGs were moved aside, whereupon it read `MISSING 128x128` /
|
||||
`MISSING 16x16`. The line is still worth its place — the *present* half (the resource bytes) and
|
||||
the declared sizes are read from the binary and nothing else can satisfy them — but a developer
|
||||
machine is the one where it is least sensitive.
|
||||
**It deliberately does not claim the icon renders**, because no runtime GTK call can:
|
||||
`gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size
|
||||
it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all
|
||||
snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes
|
||||
`image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules.
|
||||
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
|
||||
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
|
||||
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
|
||||
@@ -564,7 +619,26 @@ 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='Overview' 1381 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=…; 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). `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. Two of the three are `GtkWindow`s and close with `gtk_window_close()`; the shortcut overview is an `AdwDialog` and closes 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' 1434 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.
|
||||
- `[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,
|
||||
`UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with
|
||||
the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a
|
||||
screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all
|
||||
seven — a menu whose checkable rows indent their text and whose others do not is exactly what
|
||||
"the text is not left-aligned" looks like), every shortcut ends at the row's own right edge
|
||||
(`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34,
|
||||
and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu`
|
||||
trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ
|
||||
bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a
|
||||
`GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed
|
||||
the bubble's height, because "below the button" is off the window's bottom edge — the bubble's
|
||||
top edge stayed at the button with its body clipped away, which reads as an arrow disconnected
|
||||
from its body. Run it **alone**: another probe that opens a window dismisses the popover, and
|
||||
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
|
||||
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
|
||||
it afterwards.
|
||||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
||||
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
|
||||
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set, the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the only field trigger mode adds is the elapsed time, which steps units rather than growing digits. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
|
||||
@@ -1247,25 +1321,54 @@ far the largest file.
|
||||
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
||||
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
||||
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
|
||||
- **Four of the toolbar icons are this app's own, and they are a `GResource` compiled into the
|
||||
- **Six of this app's icons are its own, and they are a `GResource` compiled into the
|
||||
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
|
||||
measurement ruler, and the stock names those buttons used to carry said something else
|
||||
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
|
||||
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
|
||||
are bundled by `src/icons/udpscope.gresource.xml` (CMake runs `glib-compile-resources`) and
|
||||
reached through the `gtk_icon_theme_add_resource_path()` at the top of `build_toolbar()`.
|
||||
are the four action icons; beside them in `src/icons/scalable/apps/` are the two
|
||||
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
|
||||
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
|
||||
application's own icon and is where the **in-process** window icon resolves from; it is also
|
||||
the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files
|
||||
into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed
|
||||
copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell
|
||||
cannot read a GResource compiled into the binary).
|
||||
All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs
|
||||
`glib-compile-resources`) and reached through **one**
|
||||
`gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top
|
||||
of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon,
|
||||
set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place
|
||||
ahead of both. `adw_init()` calls `gtk_init()`, which is what makes
|
||||
`gdk_display_get_default()` valid there.
|
||||
A resource rather than files beside the binary because udpscope is run straight out of the
|
||||
build tree and has no install step. Five authoring rules, each of which fails quietly:
|
||||
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
|
||||
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
|
||||
colour written in the file and stays dark on a dark theme. Everything is a filled `path`.
|
||||
colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is
|
||||
therefore a filled `path`** — including anything that wants to look stroked, which is drawn as
|
||||
a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on
|
||||
purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes
|
||||
its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the
|
||||
other.
|
||||
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
|
||||
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
|
||||
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
|
||||
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
|
||||
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
|
||||
cancel instead of uniting. All of them run clockwise.
|
||||
* `--` is illegal inside an XML comment and librsvg refuses the whole file for it.
|
||||
* **A malformed SVG is a build error, and that is the only place it can be caught.** Every
|
||||
`<file>` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which
|
||||
makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which
|
||||
is illegal and which librsvg refuses the whole file for, fails the build with a file, a line
|
||||
and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen
|
||||
within comment`). It has to be here because **no runtime check can see it**: librsvg refuses
|
||||
the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and
|
||||
the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under
|
||||
Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and
|
||||
`gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this
|
||||
project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it
|
||||
ships with libxml2, which GTK already requires.
|
||||
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
|
||||
`toolbar icons:` under Verification): a resource path that was never added does not raise an
|
||||
error, it draws `image-missing`.
|
||||
@@ -1319,15 +1422,18 @@ far the largest file.
|
||||
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
|
||||
the dump is the assertion.
|
||||
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
|
||||
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** The help window
|
||||
and the About dialog are `GtkWindow`s: they close with `gtk_window_close()`, which emits
|
||||
`close-request` so the handler that nulls the file-scope pointer runs. `gtk_window_destroy()`
|
||||
does not emit that signal and must not be used — the math dialog fell into exactly this trap,
|
||||
and the pointer then points at a dead widget until the next attempt to open the same window.
|
||||
The shortcut overview is an `AdwDialog` — it is **not** a `GtkWindow` — closed with
|
||||
`adw_dialog_close()` and cleared on `::closed` (there is no `::close-request` on `AdwDialog`).
|
||||
One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its close handler, is the shape that
|
||||
prevents the double-open defect across all three.
|
||||
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is
|
||||
exactly **one `GtkWindow`** among the three — the paged help window. It closes with
|
||||
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
|
||||
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
|
||||
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
|
||||
next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`)
|
||||
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
|
||||
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
|
||||
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
|
||||
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
|
||||
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is
|
||||
the shape that prevents the double-open defect across all three.
|
||||
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
|
||||
configure time (so `add_executable` can name the file) and again on every build through
|
||||
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
|
||||
@@ -1346,6 +1452,10 @@ far the largest file.
|
||||
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
|
||||
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
|
||||
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
|
||||
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
|
||||
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
||||
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
||||
with a silent gap where its screenshots should be — which is what the first version did.
|
||||
- **`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.
|
||||
|
||||
+30
-2
@@ -39,7 +39,7 @@ if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(udpsclient PRIVATE -Wall -Wextra)
|
||||
endif()
|
||||
|
||||
# --- icons: four symbolic SVGs compiled into the binary -----------------------
|
||||
# --- icons: six SVGs compiled into the binary --------------------------------
|
||||
# There is no stock icon for an oscilloscope trigger, a pair of cursors or a measurement
|
||||
# ruler, so this app draws its own. They go in a GResource rather than beside the binary
|
||||
# because udpscope is run straight out of the build tree and has no install step: a
|
||||
@@ -48,7 +48,8 @@ endif()
|
||||
find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED)
|
||||
set(UDPSCOPE_ICON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/icons)
|
||||
file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS
|
||||
${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg)
|
||||
${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg
|
||||
${UDPSCOPE_ICON_DIR}/scalable/apps/*.svg)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||
COMMAND ${GLIB_COMPILE_RESOURCES}
|
||||
@@ -125,3 +126,30 @@ add_dependencies(udpscope udpscope_build_info)
|
||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
|
||||
# --- the desktop entry: opt-in, never part of `cmake --build` ------------------
|
||||
# `Exec` is the built binary's absolute path in the build tree, so the entry always launches
|
||||
# exactly the binary that was last built -- and moving or deleting the build directory breaks
|
||||
# it. That is the price of having no install step, and it is the right one here: an install()
|
||||
# rule would create a second copy of the app that can diverge from the build tree, and a
|
||||
# documented `cp` would need the binary on PATH, which it is not.
|
||||
#
|
||||
# configure_file() cannot evaluate a generator expression and file(GENERATE) cannot substitute
|
||||
# an @VAR@, so the two run in sequence: the first writes the genex out literally, the second
|
||||
# evaluates it. This is the documented idiom.
|
||||
set(UDPSCOPE_EXEC "$<TARGET_FILE:udpscope>")
|
||||
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in
|
||||
${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in @ONLY)
|
||||
file(GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop
|
||||
INPUT ${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in)
|
||||
|
||||
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected.
|
||||
add_custom_target(desktop-entry
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DENTRY=${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop
|
||||
-DICON=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app.svg
|
||||
-DSYMBOLIC=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app-symbolic.svg
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_desktop_entry.cmake
|
||||
COMMENT "Installing the udpscope desktop entry into XDG_DATA_HOME"
|
||||
VERBATIM)
|
||||
add_dependencies(desktop-entry udpscope)
|
||||
@@ -252,6 +252,80 @@ The same reason the window control is a fixed-width label in the header bar
|
||||
not expand: a header-bar child that expands is measured into the title's centre
|
||||
box, so the control stretched to the title and every window resize moved it.
|
||||
|
||||
## The icon, the About dialog and the desktop entry
|
||||
|
||||
**The icon is the instrument face**: a dark panel carrying one bold green trace. That is the
|
||||
clearest metaphor available for this program -- it is what the thing being imitated looks like --
|
||||
and it is the kind of physical object the GNOME HIG's top-and-front depth language was written
|
||||
for, so the guidelines apply as written rather than by analogy. Its silhouette is also what makes
|
||||
it survive being scaled down, which is why two alternatives were dropped: a bare waveform (no
|
||||
silhouette at all, so nothing is left of it in a dock) and a colour version of the trigger glyph
|
||||
the toolbar already carries (a *control*, not the application, and it would mean two icons that
|
||||
have to stay in step).
|
||||
|
||||
It is built to the HIG's own numbers. A 128px canvas the artwork does not fill (it spans
|
||||
x 12..116, y 22..106), every feature on the 2px grid, and **one** depth treatment: a 4px front
|
||||
profile along the bottom, clipped to the chassis silhouette so it does not protrude past the
|
||||
rounded corners. The rest is flat colour -- no gradients, no shadows -- because every surface
|
||||
here is straight, and the palette is GNOME's (Dark 3 chassis, Dark 2 profile, Dark 4 screen,
|
||||
Green 1 trace).
|
||||
|
||||
**Two things were deliberately left out.** There is no graticule: the grid lines a real scope
|
||||
face carries cannot survive 32px, and the trace carries the metaphor without them. And the trace
|
||||
is one cycle -- a single crest, a single trough -- because at 32px the screen is about 22x16 px
|
||||
and anything busier reads as a smudge.
|
||||
|
||||
The two forms are authored differently on purpose. The colour icon **strokes** its sine, which is
|
||||
the sane way to draw a smooth curve; the 16px symbolic one cannot, because GTK's symbolic
|
||||
recolour wraps the file in a stylesheet that reaches `fill` on `path`, `rect` and `circle` only,
|
||||
so a stroked line keeps the colour written in the file and stays dark on a dark theme. Its wave is
|
||||
therefore a closed 2px band -- a sine along the top edge and the same sine 2px lower along the
|
||||
bottom. Do not "fix" one to match the other.
|
||||
|
||||
Both are compiled into the binary as a `GResource` under `scalable/apps/`, for the same reason
|
||||
the four action icons are: udpscope is run straight out of its build tree and has no install step.
|
||||
The one consequence worth knowing is that a **malformed** SVG cannot be caught at runtime -- the
|
||||
icon *name* still resolves, so GTK reports the icon as present and draws nothing -- which is why
|
||||
every `<file>` in the manifest carries `preprocess="xml-stripblanks"` and the build fails with a
|
||||
file and line number (plus a caret under the offending character) instead.
|
||||
|
||||
**The About dialog is an `AdwAboutDialog`** rather than a `GtkAboutDialog`, and that migration is
|
||||
what the app has an icon *for*: the dialog shows it in its own header, which `GtkAboutDialog`
|
||||
would too but only after being handed one, and nothing else in the program had asked for a
|
||||
128px rendering. Two further gains came with it. The debug information is a **Troubleshooting
|
||||
page with a copy button**, where `system_information` on the old dialog was inert text a user
|
||||
reporting a problem had to retype. And the string it shows is a `std::string`, which removed a
|
||||
`char[1024]` that truncated a long settings path in silence -- the one failure mode of a fixed
|
||||
buffer that no one notices until the path matters.
|
||||
|
||||
What it cost: an `AdwAboutDialog` is an `AdwDialog` and **not** a `GtkWindow`, so its close path
|
||||
differs from the help window sitting beside it -- `adw_dialog_close()` and `::closed`, rather than
|
||||
`gtk_window_close()` and `::close-request`. That is the same trap the shortcut overview already
|
||||
had, and the two now share one `::closed` handler keyed by the pointer it must null. What was
|
||||
deliberately left out: credits (one author, already named), release notes (there are no releases)
|
||||
and an issue-tracker URL (the website link reaches the repository, which carries the tracker).
|
||||
|
||||
**The desktop entry is opt-in.** `cmake --build build` never touches `$XDG_DATA_HOME`; asking for
|
||||
it does:
|
||||
|
||||
```sh
|
||||
cmake --build build --target desktop-entry
|
||||
```
|
||||
|
||||
That writes `org.udpscope.app.desktop` and both app icons under `$XDG_DATA_HOME` (default
|
||||
`~/.local/share`), validates the entry with `desktop-file-validate` as a hard failure when the tool is on `PATH`, refreshes
|
||||
`update-desktop-database` and `gtk4-update-icon-cache` if they are on `PATH`, and prints the three
|
||||
paths it wrote. Those three lines **are** the uninstall instructions: there is no uninstall
|
||||
target, because three `rm`s the build already told you about are less to maintain than a target
|
||||
that has to guess what a previous version installed.
|
||||
|
||||
The caveat is the entry's `Exec`, which is the built binary's **absolute path in the build tree**:
|
||||
moving or deleting `build/` breaks the launcher. That is the price of having no install step, and
|
||||
it is the right trade here -- the entry then always launches exactly the binary that was last
|
||||
built, which for a program under development is what you want. No `StartupWMClass` is needed:
|
||||
GTK takes the WM class from the application ID, and the desktop file's basename is that same
|
||||
string, which is what associates a running window with the entry.
|
||||
|
||||
## How the spec maps onto the code
|
||||
|
||||
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
|
||||
@@ -749,9 +823,21 @@ returning.
|
||||
*while watching a signal*: the time window, pause/resume, the trigger toggle, the
|
||||
side-panel toggle and an overflow menu. Those
|
||||
four buttons are icons with tooltips; nine text buttons was a wall of words to read
|
||||
past every time you wanted the one you needed. The overflow menu holds **Fit to
|
||||
data**, the **Layout editor**, **Cursors**, **Reset cursors**,
|
||||
**Export visible range…** and the **Signal list** toggle — the things you set up once. The side column and the two panel toggles stay hidden until a
|
||||
past every time you wanted the one you needed. The overflow menu holds **Fit to data**, the
|
||||
**Layout editor**, the **Signal list** toggle, **Export visible range...**, **Keyboard
|
||||
shortcuts**, **Help** and **About udpscope**, in that order — the shape a GNOME menu has, and
|
||||
drawn as one: every row's label starts at the same left edge (and the rows that can be checked
|
||||
keep that column whether or not they are checked), a shortcut sits at the row's right edge, a row
|
||||
is ~18 px tall rather than Adwaita's 34, and the groups are separated by rules. The ⓘ on the
|
||||
status bar opens its detail **above** the button: below is off the window's bottom edge, and a
|
||||
bubble whose body is clipped away while its top edge stays at the button is what "the arrow is
|
||||
detached from the body" looks like.
|
||||
|
||||
The **help window** (`F1`) carries the same screenshots as `docs/`: the overview page shows the
|
||||
main window and a held capture above its text, and the trigger and math pages carry theirs. A
|
||||
page's images are `GtkPicture`s **with a height request** — a shrinkable one asks for no height at
|
||||
all inside a box, so without it the page renders its text with a silent gap where the screenshots
|
||||
should be. The side column and the two panel toggles stay hidden until a
|
||||
CONFIG arrives — there is nothing to configure before the stream describes itself —
|
||||
and the first CONFIG reveals the signal panel (the trigger panel stays behind its
|
||||
own toggle).
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# The `desktop-entry` target's body. Copies the generated entry and the two app icons into
|
||||
# $XDG_DATA_HOME, validates the entry, refreshes the caches that make the shell notice, and
|
||||
# prints every path it wrote.
|
||||
#
|
||||
# This runs only when the target is named. The project deliberately has no install step -- that
|
||||
# is the stated reason its icons are a GResource -- and a target that does nothing until asked
|
||||
# preserves that while still producing a working entry.
|
||||
#
|
||||
# Expects -DENTRY=, -DICON= and -DSYMBOLIC= on the command line.
|
||||
|
||||
if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||
set(data_home "$ENV{XDG_DATA_HOME}")
|
||||
else()
|
||||
set(data_home "$ENV{HOME}/.local/share")
|
||||
endif()
|
||||
|
||||
# Fail visibly if both fallback paths are unusable. The script needs a directory to install into;
|
||||
# a missing $HOME in a sandboxed CI environment should not silently install to the filesystem root.
|
||||
if(NOT DEFINED ENV{XDG_DATA_HOME} OR "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||
if(NOT DEFINED ENV{HOME} OR "$ENV{HOME}" STREQUAL "")
|
||||
message(FATAL_ERROR "XDG_DATA_HOME and HOME are both unset or empty; cannot determine install directory")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(apps_dir "${data_home}/applications")
|
||||
set(icon_dir "${data_home}/icons/hicolor/scalable/apps")
|
||||
set(sym_dir "${data_home}/icons/hicolor/symbolic/apps")
|
||||
|
||||
# The icons are copied from the source tree rather than extracted from the GResource: the same
|
||||
# two files serve both the in-binary resource and the installed theme, so there is one copy to
|
||||
# edit. `file(COPY)` creates the directories.
|
||||
file(COPY "${ENTRY}" DESTINATION "${apps_dir}")
|
||||
file(COPY "${ICON}" DESTINATION "${icon_dir}")
|
||||
file(COPY "${SYMBOLIC}" DESTINATION "${sym_dir}")
|
||||
|
||||
get_filename_component(entry_name "${ENTRY}" NAME)
|
||||
get_filename_component(icon_name "${ICON}" NAME)
|
||||
get_filename_component(sym_name "${SYMBOLIC}" NAME)
|
||||
|
||||
# Validated here rather than left to the shell, which ignores a malformed entry in silence: the
|
||||
# failure belongs at the moment the entry is asked for.
|
||||
find_program(DESKTOP_FILE_VALIDATE desktop-file-validate)
|
||||
if(DESKTOP_FILE_VALIDATE)
|
||||
execute_process(COMMAND "${DESKTOP_FILE_VALIDATE}" "${apps_dir}/${entry_name}"
|
||||
RESULT_VARIABLE validate_result)
|
||||
if(NOT validate_result EQUAL 0)
|
||||
message(FATAL_ERROR "desktop-file-validate rejected ${apps_dir}/${entry_name}")
|
||||
endif()
|
||||
message(STATUS "desktop-file-validate: ok")
|
||||
else()
|
||||
message(STATUS "desktop-file-validate: not on PATH, entry not validated")
|
||||
endif()
|
||||
|
||||
find_program(UPDATE_DESKTOP_DATABASE update-desktop-database)
|
||||
if(UPDATE_DESKTOP_DATABASE)
|
||||
execute_process(COMMAND "${UPDATE_DESKTOP_DATABASE}" "${apps_dir}")
|
||||
endif()
|
||||
|
||||
find_program(GTK4_UPDATE_ICON_CACHE gtk4-update-icon-cache)
|
||||
if(GTK4_UPDATE_ICON_CACHE)
|
||||
execute_process(COMMAND "${GTK4_UPDATE_ICON_CACHE}" -f -t "${data_home}/icons/hicolor")
|
||||
endif()
|
||||
|
||||
# Printed because there is no uninstall target: undoing this is these three paths.
|
||||
message(STATUS "wrote ${apps_dir}/${entry_name}")
|
||||
message(STATUS "wrote ${icon_dir}/${icon_name}")
|
||||
message(STATUS "wrote ${sym_dir}/${sym_name}")
|
||||
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=udpscope
|
||||
GenericName=Oscilloscope
|
||||
Comment=An oscilloscope for UDPStreamer sources
|
||||
Exec=@UDPSCOPE_EXEC@
|
||||
Icon=org.udpscope.app
|
||||
Terminal=false
|
||||
Categories=Development;Science;Electronics;
|
||||
Keywords=oscilloscope;UDP;signal;waveform;trigger;
|
||||
StartupNotify=true
|
||||
@@ -0,0 +1,201 @@
|
||||
# udpscope — user guide
|
||||
|
||||
A viewer for a UDPStreamer source: it connects, decodes the CONFIG, plots the signals you enable,
|
||||
and lets you freeze, measure and derive from them. This is the *user* guide; `README.md` documents
|
||||
what was built and why, and `SPECS.md` is the requirement document.
|
||||
|
||||

|
||||
|
||||
*The main window with a source streaming: the signal panel on the right, the plot in the middle, the
|
||||
status line directly under it, and the toolbar above. The window control — `2 s` — is the first thing
|
||||
on the left.*
|
||||
|
||||
## 1. Connecting
|
||||
|
||||
```sh
|
||||
udpscope # asks which source to open
|
||||
udpscope --server 10.0.0.5 --port 8000 # connect straight away
|
||||
udpscope --server 239.1.2.3 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||
```
|
||||
|
||||
With no `--server` the app shows a chooser: the sessions it has stored, each naming its source, plus
|
||||
a form for a new host. A source opened from the list reconnects to the multicast group and interface
|
||||
it was stored with.
|
||||
|
||||
**A unicast source serves one client at a time.** A second viewer's `CONNECT` displaces the first, and
|
||||
they take turns — the status line says so when it happens. Watching one source from several instances
|
||||
needs `--multicast <group>` with `--interface <local address>`.
|
||||
|
||||
Every launch is its own process, so running two scopes side by side is fine.
|
||||
|
||||
## 2. The status line
|
||||
|
||||
Directly under the plot, it reads `● <state> · <rate> · <window>`, and the ⓘ beside it opens the
|
||||
detail: packets, frames, gaps, per-channel sample counts, the buffer's span and its decimation.
|
||||
|
||||
- **The state word is one word** from a fixed set: `live`, `paused`, `armed`, `capturing`,
|
||||
`event held`, `disarmed`. `no data` appears in the rate's place when nothing is arriving.
|
||||
- **The rate is the *stored* rate**, which is the wire rate only while the whole requested window
|
||||
fits in the buffer. A window wider than the budget allows is bought by averaging groups of samples,
|
||||
so the rate falls and the status line says so — that is the anti-alias filter doing its job.
|
||||
- **`connected, but no data is arriving`** appears after three seconds in which no frame arrived while
|
||||
connected: the usual cause is the unicast point above. It clears the moment data returns.
|
||||
- **`N reconnects`** is appended when the client has had to re-establish the session.
|
||||
|
||||
## 3. Signals
|
||||
|
||||
The signal panel lists every signal the CONFIG described, with a switch each. **Signals start
|
||||
disabled — enabling one is what starts acquiring it**, and a signal nobody enables costs nothing.
|
||||
Drag a signal from the panel onto a plot row to add a trace; drag it again to another row to plot the
|
||||
same signal twice. The colour is per *signal*, so the same signal is the same colour wherever it
|
||||
appears.
|
||||
|
||||
The panel also holds the per-signal calibration: gain and offset, applied at ingest, so the
|
||||
measurements and the export carry the same numbers the plot shows.
|
||||
|
||||
## 4. The window and navigation
|
||||
|
||||
The header's window control (`2 s`) sets the time span on screen; the popover takes a number and a
|
||||
unit, with a decade stepper either side. The trigger's event window is a share of *this* span, so a
|
||||
window change moves the trigger with it.
|
||||
|
||||
- **Wheel** zooms about the pointer; **Shift+wheel** on x only, **Ctrl+wheel** y only.
|
||||
- **Ctrl `+` / `-`** zooms x, **Alt `+` / `-`** y, about the plot centre.
|
||||
- **Drag** pans, **right-drag** too (the right button also grabs a cursor at the pointer).
|
||||
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
|
||||
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
|
||||
|
||||
The view is bounded by the **time window**, not by the buffer: zooming out stops at the span you set,
|
||||
and panning stops where the data starts. When the buffer holds *less* than the window — right after a
|
||||
signal is enabled — the window is left empty on its left, which is what a scope that has just started
|
||||
should look like.
|
||||
|
||||
## 5. Trigger
|
||||
|
||||

|
||||
|
||||
*Single mode with a capture held: the axis is the event's own `[-pre, +post]`, the trigger marker
|
||||
sits at the edge, and the status line reads `event held`.*
|
||||
|
||||
The trigger panel has the edge, the pre/post split (as a share of the event span), Normal or Single,
|
||||
and Arm/Disarm. Selecting a signal for the trigger enables it.
|
||||
|
||||
- **While a capture fills** the plot keeps drawing live data on the event's clock, so the post side
|
||||
fills in view; the axis does not move.
|
||||
- **A held event is navigable within its own data** — zooming in works, panning stops at the capture's
|
||||
edges — and it is never quietly replaced by live data.
|
||||
- **Disarming mid-capture** publishes what the capture got and holds that, so the picture on screen
|
||||
when you disarm is the picture you keep.
|
||||
- The event window is counted in **stream seconds** end to end, so a source whose clock advances in
|
||||
jumps still gets the window it asked for.
|
||||
|
||||
## 6. Pause
|
||||
|
||||
Pause freezes the *picture*, not the acquisition: the producer keeps filling the buffer behind it, and
|
||||
the frozen view is a snapshot of the window that was on screen. Resuming shows the data that arrived
|
||||
meanwhile — there is no hole. A paused view is held like a held event, so zooming in works and panning
|
||||
does not walk it off its snapshot.
|
||||
|
||||
## 7. Cursors
|
||||
|
||||
The cursors toolbar toggle switches on two instruments per row:
|
||||
|
||||
- **vertical** — two times, shared by every row;
|
||||
- **horizontal** — two rulers per row;
|
||||
- **both** — the two together;
|
||||
- **mix** — the two times, read off each *trace* where they cross it.
|
||||
|
||||
The pane beside it reports each trace's values at the markers, and **Reset** puts them back at the
|
||||
window's edges. Values read `--` when a marker is past the newest sample and `partial` when only one
|
||||
marker is over data.
|
||||
|
||||
## 8. Measurements
|
||||
|
||||
The measurements pane computes statistics of the **visible window** of a signal: average, top,
|
||||
bottom, min, max, peak to peak, amplitude, pulse duration, period and frequency. `+` adds one, the
|
||||
row's pencil edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
|
||||
|
||||
`top` and `bottom` take a *split* as a share of the window's own range — the percentage travels
|
||||
between signals where a threshold in volts would not. `period` and `frequency` deliberately have no
|
||||
split: they count rising crossings of the mid level, one edge per cycle, and a split there is a
|
||||
setting you can only get wrong.
|
||||
|
||||
The values are refreshed on a cadence — at most every 100 ms while the window is scrolling, once when
|
||||
it stops, and once per mode change — so a live view cannot cost you frames. The pane's cost is
|
||||
reported in the exit dump with the app's self-check.
|
||||
|
||||
## 9. Math channels
|
||||
|
||||

|
||||
|
||||
*The compact node editor: each node is a box, its inputs the squares on the left edge and its output
|
||||
the square on the right. The orange dot marks the node that carries the channel's value.*
|
||||
|
||||
A math channel is derived from the others and is then an ordinary channel: enable it and it is
|
||||
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
|
||||
as a source.
|
||||
|
||||
Operations: `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`,
|
||||
`band pass` (biquads, corner in Hz against the stored rate), `sum`, `difference`, `product`, `ratio`.
|
||||
|
||||
- **Click** a node to select it; its parameters appear in the form beside the canvas.
|
||||
- **Click an output square, then an input square**, to wire them. An input is only offered the nodes
|
||||
above it — the graph is a list walked in order, so a cycle cannot be drawn.
|
||||
- **Drag** a box to move it. **add node**, **delete node** and **set as output** are below the form.
|
||||
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
|
||||
*set as output* changes it. A graph whose output is the source is valid and just passes the input
|
||||
through — that is the usual reason an operation "does nothing".
|
||||
- **Save** refuses an incomplete graph and says why: an unconnected input, no output, or a source that
|
||||
is not a channel the scope is acquiring.
|
||||
|
||||
The samples are produced at ingest, once per stored slot, so the filters carry real history — a low
|
||||
pass is the filter you asked for rather than a window recomputed every frame.
|
||||
|
||||
## 10. Sessions
|
||||
|
||||
Everything above is saved per source in `~/.config/udpscope/<host>_<port>.toml`: the window, the rows
|
||||
and which signal each slot holds, the calibration, **which signals were enabled**, **the math channels
|
||||
with their graphs** and **the measurements**. Reopening the same source brings the scope back as you
|
||||
left it.
|
||||
|
||||
The file is plain TOML and safe to edit — a malformed line is skipped rather than refusing the whole
|
||||
file. Deleting it starts from the defaults.
|
||||
|
||||
## 11. Export
|
||||
|
||||
**Export visible range…** writes the plotted signals over the visible x range at the *stored* rate to
|
||||
`.npy` or `.csv`, picked by the file name's extension. The table is collected when you click, not when
|
||||
the chooser closes, so a live view cannot scroll under the dialog. The `.npy` is a 2-D array: one row
|
||||
per sample, `time`, `stream_time`, then one column per signal.
|
||||
|
||||
## 12. Keyboard
|
||||
|
||||
| key | action |
|
||||
|---|---|
|
||||
| `space` | pause / resume |
|
||||
| `a` | arm / disarm the trigger |
|
||||
| `t` | the trigger panel |
|
||||
| `l` | the layout editor |
|
||||
| `g` | go live |
|
||||
| `f` | fit to data |
|
||||
| `d` | add the first free signal to the focused row |
|
||||
| `c` | cursors on/off |
|
||||
| `m` | measurements pane |
|
||||
| `e` | export |
|
||||
| `?` | shortcuts |
|
||||
|
||||
`Ctrl+E` exports, `Ctrl+Q` quits, `Ctrl+plus`/`Ctrl+minus` zoom x and `Alt+plus`/`Alt+minus` zoom y.
|
||||
A keystroke is ignored while a text field has focus, so typing in the window control never arms the
|
||||
trigger.
|
||||
|
||||
## 13. Troubleshooting
|
||||
|
||||
| symptom | cause |
|
||||
|---|---|
|
||||
| `connected, but no data is arriving` | another client holds a unicast source — use `--multicast` |
|
||||
| the plot is empty, the status reads `no data` | nothing is enabled: enable a signal in the panel |
|
||||
| the trace is blank while the trigger is armed | `Waiting` — armed and nothing latched yet |
|
||||
| the axis has a margin at the right | a capture is filling: the post side arrives as it is acquired |
|
||||
| a math channel equals its input | its output node is the source — see §9 |
|
||||
| a very wide window shows a low rate | the ingest's anti-alias filter, not a fault |
|
||||
| a level reads `--` | the measurement is invalid in this window (e.g. a period with one edge) |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,252 @@
|
||||
# App icon, Adwaita About dialog, and desktop entry
|
||||
|
||||
Date: 2026-10-04
|
||||
|
||||
## Goal
|
||||
|
||||
Three related pieces of desktop integration udpscope currently lacks:
|
||||
|
||||
1. An application icon drawn to the GNOME HIG, in both full colour and symbolic form.
|
||||
2. An About dialog that shows it, built on `AdwAboutDialog` rather than `GtkAboutDialog`.
|
||||
3. A `.desktop` entry, so the app can be launched from the shell rather than only from a terminal.
|
||||
|
||||
## Decisions taken before design
|
||||
|
||||
**The application ID stays `org.udpscope.app`.** GNOME convention is that the application ID, the
|
||||
icon name and the desktop file's basename are one string, so that string is `org.udpscope.app`
|
||||
throughout: the icon is named `org.udpscope.app`, the desktop file is
|
||||
`org.udpscope.app.desktop`, and `gtk_application_new()` is left exactly as it is. The alternative
|
||||
(moving to `it.ferrarinas.Udpscope`, the reverse-DNS of the project's actual forge domain) was
|
||||
offered and declined. Nothing else in the tree depends on the ID — the config directory is
|
||||
`~/.config/udpscope` and the app is `G_APPLICATION_NON_UNIQUE` — so this choice costs nothing
|
||||
beyond the naming itself.
|
||||
|
||||
**The icon is the instrument face**, not a bare waveform and not a colour version of the existing
|
||||
trigger icon. A dark panel carrying one bold trace is the clearest available metaphor, it is the
|
||||
kind of physical object the HIG's top-and-front depth language was written for, and its silhouette
|
||||
survives being scaled down better than either alternative.
|
||||
|
||||
**The desktop entry is installed by an opt-in CMake target**, not by `install()` rules and not by
|
||||
documented `cp` commands. The project deliberately has no install step — that is the stated reason
|
||||
its icons are a GResource — and a target that does nothing until asked preserves that while still
|
||||
producing a working entry. `install()` rules were rejected because they would create a second copy
|
||||
of the app that can diverge from the build tree; manual `cp` was rejected because `Exec` would need
|
||||
the binary on `PATH`, which it is not.
|
||||
|
||||
## The HIG constraints this is built to
|
||||
|
||||
Taken from <https://developer.gnome.org/hig/guidelines/app-icons.html>:
|
||||
|
||||
- Nominal canvas **128×128**, which the artwork should *not* fill.
|
||||
- A **2px grid** for all detail.
|
||||
- Depth comes from combining the top and front of an object: a front profile along the bottom,
|
||||
**no taller than 2 detail units (4 nominal px)**.
|
||||
- Flat colour on straight surfaces; **gradients reserved for curved surfaces**.
|
||||
- Shadows **avoided** where possible.
|
||||
- The standard GNOME palette.
|
||||
- A **symbolic version is required**, reusing the same metaphor, following the UI-icon (single
|
||||
colour) rules.
|
||||
- The icon must still read at **64px** and **32px**.
|
||||
|
||||
## Component 1 — the artwork
|
||||
|
||||
Two new source files, both added to the existing `src/icons/udpscope.gresource.xml` under the
|
||||
`scalable/apps/` tree (the icons already there are `scalable/actions/`).
|
||||
|
||||
### `src/icons/scalable/apps/org.udpscope.app.svg`
|
||||
|
||||
Full colour, 128×128, every coordinate on the 2px grid:
|
||||
|
||||
| element | geometry | palette colour |
|
||||
|---|---|---|
|
||||
| chassis | rounded rect (12, 22)–(116, 106), radius 8 | Dark 3 `#3d3846` |
|
||||
| front profile | the bottom band of that same silhouette, y 102–106 | Dark 2 `#5e5c64` |
|
||||
| screen | rounded rect (20, 30)–(108, 94), radius 4 | Dark 4 `#241f31` |
|
||||
| trace | one sine cycle, centred y = 62, amplitude 18, over x 24–104, 6 px thick | Green 1 `#8ff0a4` |
|
||||
|
||||
The chassis is **one** rounded rect and the front profile is the bottom 4px *of that silhouette*,
|
||||
drawn as a path that follows its bottom corners — not a separate full-width rectangle underneath it.
|
||||
A plain rect from (12, 102) to (116, 106) would protrude past the radius-8 corners, which at 128px
|
||||
reads as a misdrawn bump and at 32px as a dirty edge.
|
||||
|
||||
That band is exactly the HIG's 4px maximum and is the only depth treatment: no gradients (every
|
||||
surface here is straight, so flat colour is what the HIG asks for) and no shadows.
|
||||
|
||||
The artwork occupies x 12–116 and y 22–106, leaving 12px side margins and 22px top and bottom, so it
|
||||
does not fill the canvas. Every coordinate above is even.
|
||||
|
||||
Two deliberate omissions. There is **no graticule** — the grid lines a real scope face carries
|
||||
cannot survive 32px, and the trace already carries the metaphor without them. And the trace is
|
||||
**one** cycle, a single trough and a single crest, rather than several: at 32px the screen is about
|
||||
22×16 px, and anything busier reads as a smudge.
|
||||
|
||||
### `src/icons/scalable/apps/org.udpscope.app-symbolic.svg`
|
||||
|
||||
16×16, the same metaphor reduced to a screen frame with a wave inside it. Unlike the colour icon
|
||||
this is **filled paths only, with an `evenodd` hole for the screen's interior**, because GTK's
|
||||
symbolic recolour only reaches `fill` on `path`, `rect` and `circle` — a stroke keeps the colour
|
||||
written in the file and stays dark on a dark theme. Whole-pixel coordinates throughout.
|
||||
|
||||
**This asymmetry is the trap worth documenting:** CLAUDE.md's existing icon-authoring rules say
|
||||
"everything is a filled `path`" without qualifying it, because every icon in the tree so far has
|
||||
been symbolic. A 128px colour icon is never recoloured, so it may stroke freely — and stroking is
|
||||
the sane way to draw a smooth sine. The rule becomes "fills only in a symbolic icon".
|
||||
|
||||
## Component 2 — the About dialog
|
||||
|
||||
`adw_about_dialog_new()` returns an `AdwDialog*`. An `AdwDialog` is **not** a `GtkWindow`, which is
|
||||
the trap CLAUDE.md already documents for the shortcuts overview, so in `src/help_window.cpp`:
|
||||
|
||||
- `g_about` changes from `GtkWidget*` to `AdwDialog*`.
|
||||
- It is cleared on `::closed`, not `::close-request` (`AdwDialog` has no such signal).
|
||||
- It closes with `adw_dialog_close()`, not `gtk_window_close()`.
|
||||
- It is presented with `adw_dialog_present()`.
|
||||
- Both `AdwDialog`s can share one `::closed` handler keyed by a pointer-to-pointer, leaving
|
||||
`on_window_close` to the help window alone.
|
||||
|
||||
`close_help_windows()` therefore becomes one `GtkWindow` (help) plus two `AdwDialog`s (About,
|
||||
shortcuts), and both its doc comment and the CLAUDE.md invariant that says "two of the three are
|
||||
`GtkWindow`s" have to be revised to "one of the three".
|
||||
|
||||
Field mapping:
|
||||
|
||||
| `GtkAboutDialog` | `AdwAboutDialog` |
|
||||
|---|---|
|
||||
| `program_name` | `application_name` |
|
||||
| `version` | `version` |
|
||||
| `comments` | `comments` |
|
||||
| `license_type` | `license_type` |
|
||||
| `system_information` | `debug_info` |
|
||||
| — | `application_icon` = `"org.udpscope.app"` |
|
||||
| — | `developer_name`, `website` |
|
||||
|
||||
Two gains are taken while mapping. `debug_info` renders as a Troubleshooting page with a copy
|
||||
button, where `system_information` was inert text. And the string behind it is built as a
|
||||
`std::string` rather than the `char details[1024]` a previous review flagged for silent truncation
|
||||
on a long settings path — removing the ceiling rather than widening it, in code already being
|
||||
rewritten.
|
||||
|
||||
Credits, release notes and an issue URL are deliberately **not** added; nothing asked for them.
|
||||
|
||||
One ordering detail: `gtk_icon_theme_add_resource_path()` currently runs at the top of
|
||||
`build_toolbar()`. The main window's own icon is now set from a name in the same resource, so the
|
||||
registration moves up into `run_ui()`, before both `gtk_window_set_icon_name()` and the
|
||||
`build_toolbar()` call — otherwise whichever of the two runs first resolves its name to nothing.
|
||||
|
||||
## Component 3 — the desktop entry
|
||||
|
||||
### `data/org.udpscope.app.desktop.in`
|
||||
|
||||
```
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=udpscope
|
||||
GenericName=Oscilloscope
|
||||
Comment=An oscilloscope for UDPStreamer sources
|
||||
Exec=@UDPSCOPE_EXEC@
|
||||
Icon=org.udpscope.app
|
||||
Terminal=false
|
||||
Categories=Development;Science;Electronics;
|
||||
Keywords=oscilloscope;UDP;signal;waveform;trigger;
|
||||
StartupNotify=true
|
||||
```
|
||||
|
||||
`@UDPSCOPE_EXEC@` is configured to the built binary's absolute path in the build tree. The
|
||||
consequence to accept knowingly: **moving or deleting the build directory breaks the entry.** That
|
||||
is the price of having no install step, and it is the right price here because the entry then always
|
||||
launches exactly the binary that was last built.
|
||||
|
||||
No `StartupWMClass` is needed. GTK takes the WM class from the application ID, and the desktop
|
||||
file's basename already matches it, which is what associates a running window with this entry.
|
||||
|
||||
### The `desktop-entry` target
|
||||
|
||||
A custom target driving `cmake/install_desktop_entry.cmake`, which writes three files under
|
||||
`$XDG_DATA_HOME` (defaulting to `~/.local/share`):
|
||||
|
||||
- `applications/org.udpscope.app.desktop`
|
||||
- `icons/hicolor/scalable/apps/org.udpscope.app.svg`
|
||||
- `icons/hicolor/symbolic/apps/org.udpscope.app-symbolic.svg`
|
||||
|
||||
The two SVGs are copied from `src/icons/scalable/apps/` in the source tree, not extracted from the
|
||||
GResource: the same two files serve both the in-binary resource and the installed icon theme, so
|
||||
there is one copy to edit.
|
||||
|
||||
It then refreshes `update-desktop-database` and `gtk4-update-icon-cache` if either is on `PATH`, runs
|
||||
`desktop-file-validate` if it is available, and prints every path it wrote.
|
||||
|
||||
`cmake --build build` is unaffected: the target runs only when named. No uninstall target is
|
||||
provided — the three paths are printed, so undoing it is three `rm`s.
|
||||
|
||||
## Verification
|
||||
|
||||
Two silent failures are possible, and they fail differently.
|
||||
|
||||
A GResource path that was never registered draws `image-missing` while every other line of the dump
|
||||
passes; the existing `toolbar icons:` line already guards that class with
|
||||
`gtk_icon_theme_has_icon()`. But a **malformed SVG is worse**: librsvg refuses the whole file,
|
||||
`has_icon()` still returns true because the *name* resolves, and the icon draws nothing. `has_icon`
|
||||
cannot see it — and these SVGs have already been bitten once by librsvg, which rejects a file for a
|
||||
`--` inside an XML comment.
|
||||
|
||||
The two are therefore caught in two different places, because **no runtime GTK call can see the
|
||||
malformed one.** This was measured rather than assumed, and three candidate checks were tried and
|
||||
rejected:
|
||||
|
||||
- `gtk_icon_theme_lookup_icon()` never returns `NULL` and its paintable's intrinsic size is the
|
||||
size that was *asked for*, so it reads `128x128` for a valid icon, a malformed one and a name
|
||||
that does not exist at all. The same three cases snapshot to an identical `GSK_TEXTURE_NODE` of
|
||||
the requested bounds, since GTK substitutes `image-missing`. There is nothing to compare.
|
||||
- `GMarkup` accepts `<!-- a -- b -->` without complaint. It is a deliberately lenient subset
|
||||
parser, so it cannot stand in for the strict one that refuses the file.
|
||||
- `gdk_pixbuf_new_from_file()` on an SVG goes through glycin on this system, which sandboxes a
|
||||
subprocess per load, emits fontconfig errors, and refuses this project's existing icons outright
|
||||
for reasons of its own. Not a usable oracle.
|
||||
|
||||
So the malformed file is caught at **build time**, which is better than a probe anyway: every
|
||||
`<file>` line in `src/icons/udpscope.gresource.xml` gains `preprocess="xml-stripblanks"`, which
|
||||
makes `glib-compile-resources` pipe each SVG through `xmllint`. A double hyphen inside a comment
|
||||
then **fails the build** with the file, line and column, instead of drawing nothing at runtime.
|
||||
The four existing icons already pass it. The cost is that the `xmllint` binary becomes a build
|
||||
dependency; it ships with libxml2, which GTK already requires.
|
||||
|
||||
The runtime line then asserts what a runtime check genuinely can:
|
||||
|
||||
```
|
||||
app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)
|
||||
```
|
||||
|
||||
`ok` is `gtk_icon_theme_has_icon()` **and** the resource bytes being present under the registered
|
||||
path, and the size is the one the file's root `<svg>` element declares, read out of those bytes.
|
||||
**Both entries must read `ok` at those sizes.** That covers the unregistered-path case, a renamed
|
||||
or mis-pathed file, and a canvas size that drifted off the HIG's 128 — and it is computed on the
|
||||
first tick beside `toolbar icons:`, not in the dump, which runs after the window is destroyed.
|
||||
|
||||
Alongside it:
|
||||
|
||||
- The `[help]` probe gains `about icon=org.udpscope.app`, read back through
|
||||
`adw_about_dialog_get_application_icon()`, so the dialog is asserted to carry the icon rather than
|
||||
merely to open.
|
||||
- That probe's existing `closed ok` becomes a stronger crash test, because About is now an
|
||||
`AdwDialog` closed by a different call than the help window beside it. It must still produce no
|
||||
`Gtk-CRITICAL` beyond the two already baselined.
|
||||
- `desktop-file-validate` runs inside the `desktop-entry` target, so a malformed entry fails at the
|
||||
moment it is asked for rather than being silently ignored by the shell later.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **CLAUDE.md**: the `app icons:` dump line as a documented assertion, and the `xml-stripblanks`
|
||||
build-time check beside the five existing icon-authoring rules — the sixth rule is that the
|
||||
malformed-SVG failure is a *build* error now and no runtime check can replace it; the windows invariant revised
|
||||
to one `GtkWindow` and two `AdwDialog`s; the `desktop-entry` target and the build-path caveat; and
|
||||
the icon-authoring rules amended so "filled paths only" is scoped to symbolic icons.
|
||||
- **README.md**: the feature narrative — the icon's HIG constraints and the two omissions
|
||||
(graticule, multiple cycles) with the 32px reasoning, the move to `AdwAboutDialog` and what it
|
||||
gained, and how to get the desktop entry.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- An AppStream `metainfo.xml`. Nothing asked for it, and it is only required for store submission.
|
||||
- Redrawing the four existing action icons.
|
||||
- A nightly/devel icon variant.
|
||||
- An uninstall target for the desktop entry.
|
||||
+64
-18
@@ -1,26 +1,72 @@
|
||||
<b>What udpscope is</b>
|
||||
|
||||
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each
|
||||
with up to 4 traces; every row shares one time axis, so the whole figure scrolls together.
|
||||
Acquisition runs on its own thread and writes a ring buffer sized from --buffer-mb; the
|
||||
drawing thread reads whatever that ring holds when a frame starts.
|
||||
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each with up
|
||||
to 4 traces, and every row shares one time axis, so the whole figure scrolls together.
|
||||
Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buffer-mb</tt>;
|
||||
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
|
||||
never makes the window stutter.
|
||||
|
||||
The stream is the clock. Every time you read on screen is a timestamp the source sent, never
|
||||
your machine's, so a stalled source freezes the view instead of scrolling it -- and a source
|
||||
whose clock steps still gets the window you asked for.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it
|
||||
with no <tt>--server</tt> and pick one from the chooser. The signals arrive disabled; turn
|
||||
one on in the side panel (<tt>s</tt>) and drag it onto a row.
|
||||
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it with
|
||||
no <tt>--server</tt> and pick one from the chooser, which lists the sessions you have opened
|
||||
before. A unicast source serves one client at a time: if a second viewer connects, they take
|
||||
turns and the status bar says so. To watch one source from several instances, use
|
||||
<tt>--multicast</tt> with the group and <tt>--interface</tt> with the local address.
|
||||
|
||||
The signals arrive <i>disabled</i>. Nothing is acquired until you turn one on in the signal
|
||||
list, and enabling is what starts acquisition -- so a freshly opened scope with an idle side
|
||||
panel is working correctly, not failing to connect.
|
||||
|
||||
<b>The window</b>
|
||||
|
||||
• <b>The header bar</b> -- left to right: the time window (<tt>2 s</tt>), the trigger and the
|
||||
pause, then the pane switches -- signal list, cursors, measurements, math, side panel -- and
|
||||
the menu, which holds Fit to data, the layout editor, Export, Keyboard shortcuts, Help and
|
||||
About.
|
||||
|
||||
• <b>The side panel</b> -- the trigger, cursors, measurements and math panes at the top, then
|
||||
the signal list: one row per signal with its colour, an enable switch, its name and its unit.
|
||||
Signals are dragged from here onto a plot row.
|
||||
|
||||
• <b>The plot</b> -- up to 8 rows, each with its own y axis and up to 4 traces, all on one
|
||||
shared time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).
|
||||
|
||||
• <b>The status bar</b>, directly above the plot's bottom edge -- the state word, the stored
|
||||
sample rate and the window, and the ⓘ beside it for packets, gaps and the buffer's own
|
||||
figures. The second screenshot above is what a held capture looks like: <tt>event held</tt>,
|
||||
the axis pinned to the event, and the trigger marker at the edge.
|
||||
|
||||
<b>The first five minutes</b>
|
||||
|
||||
1. <b>Connect.</b> With no <tt>--server</tt>, pick a stored session or type a host and port.
|
||||
2. <b>Enable a signal</b> in the signal list. The plot stays empty for the first fraction of a
|
||||
second while the first window fills -- the buffer holds only what has arrived.
|
||||
3. <b>Drag it onto a row.</b> Drop a second signal on another row, or the same signal again for
|
||||
a second view of it. The colour is per signal, so it is the same one wherever it appears.
|
||||
4. <b>Set the window</b> with the control in the header bar: type a number, pick a unit, or step
|
||||
a decade. The trigger's event window is a share of this span, so a window change moves it.
|
||||
5. <b>Pause</b> (<tt>space</tt>) to look at a still picture; the producer keeps filling the
|
||||
buffer behind it, and resuming shows what arrived meanwhile rather than a hole.
|
||||
6. <b>Arm the trigger</b> (<tt>a</tt>) and wait for a latch: the view freezes on one event, and
|
||||
you can zoom in on it. Disarming mid-capture keeps what the capture got.
|
||||
7. <b>Measure it</b> -- the measurements pane computes a statistic of the visible window, and
|
||||
<b>show on plot</b> marks it as a ruler or a pair of markers.
|
||||
8. <b>Save the data</b> with Export visible range, which writes the plotted signals over the
|
||||
window at the stored rate to <tt>.npy</tt> or <tt>.csv</tt>.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• Nothing is acquired until a signal is enabled. Enabling is what starts acquisition, so a
|
||||
freshly opened scope with an idle side panel is working correctly.
|
||||
• The stream carries its own clock. Every time on screen is a stream timestamp, never the
|
||||
local wall clock, which is why a stalled source freezes the view instead of scrolling it.
|
||||
• The window remembers itself. Rows, traces, the time window, calibration, math channels
|
||||
and measurements are written to a session file per host and port, and reloaded next time.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
A 3-signal source at 10 kS/s with a 200 MB buffer holds 1677 s of history. Enable all three,
|
||||
drop them on row 1, and the status bar reads <tt>● live · 10 kS/s · 2 s window</tt>.
|
||||
• A signal nobody enables costs nothing -- no buffer, no socket work, no measurements.
|
||||
• The window remembers itself. Rows, traces, the time window, calibration, which signals were
|
||||
enabled, the math channels with their graphs, and the measurements are written per host and
|
||||
port, and reloaded the next time you open that source.
|
||||
• A very wide window on a fast stream is drawn from averaged groups of samples, so the rate in
|
||||
the status bar falls: that is the anti-alias filter, not a fault -- striding would fold a fast
|
||||
signal back as a slow one, which looks entirely plausible on screen.
|
||||
• Every instrument the app computed -- the trigger marker, the measurement marks -- is locked to
|
||||
the pointer's reach. The cursors are yours to drag; those are not.
|
||||
@@ -33,3 +33,19 @@ orange dot marks the output node.
|
||||
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
|
||||
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
|
||||
the shared clock has time slots.
|
||||
|
||||
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
|
||||
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
|
||||
and the last node added becomes the output -- but a graph whose output is left on the source is
|
||||
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
|
||||
have no effect. <i>Set as output</i> moves the dot.
|
||||
|
||||
<b>Save refuses an incomplete graph</b>, and says why: an input that is not connected, no output
|
||||
chosen, or a <i>channel</i> node naming a signal the scope is not acquiring. Only the channels being
|
||||
acquired are offered, because a source that is not acquired reads as no data for ever -- and a graph
|
||||
that reads NaN is harder to diagnose than one that would not save.
|
||||
|
||||
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
|
||||
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
|
||||
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
|
||||
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
|
||||
@@ -23,5 +23,12 @@
|
||||
<file>12-export.txt</file>
|
||||
<file>13-sessions.txt</file>
|
||||
<file>14-status.txt</file>
|
||||
|
||||
<!-- The screenshots live with the user documentation (docs/); the resource aliases
|
||||
them under `img/` so a help page can name one by resource path. The path is relative
|
||||
to this manifest's directory. -->
|
||||
<file alias="img/main-window.png">../../docs/img/main-window.png</file>
|
||||
<file alias="img/trigger-capture.png">../../docs/img/trigger-capture.png</file>
|
||||
<file alias="img/math-editor.png">../../docs/img/math-editor.png</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
+93
-33
@@ -13,13 +13,14 @@
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// Nulled by the `close-request` handler so a second open cannot present a dead widget.
|
||||
/// 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.
|
||||
GtkWidget* g_about = nullptr;
|
||||
///
|
||||
/// 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()`.
|
||||
GtkWidget* g_help = nullptr;
|
||||
|
||||
/// The shortcut overview is an `AdwDialog`, not a window, so it has its own pointer and
|
||||
/// its own clearing signal (`::closed` rather than `::close-request`).
|
||||
AdwDialog* g_about = nullptr;
|
||||
AdwDialog* g_shortcuts = nullptr;
|
||||
|
||||
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||
@@ -28,32 +29,49 @@ gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||
return FALSE; // let the default handler destroy it
|
||||
}
|
||||
|
||||
void on_shortcuts_closed(AdwDialog* dialog, gpointer user) {
|
||||
/// Shared by both `AdwDialog`s: the pointer to clear is the `user_data`, so there is one
|
||||
/// handler rather than one per dialog.
|
||||
void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
|
||||
(void)dialog;
|
||||
(void)user;
|
||||
g_shortcuts = nullptr;
|
||||
*static_cast<AdwDialog**>(slot) = nullptr;
|
||||
}
|
||||
|
||||
struct HelpPage {
|
||||
const char* section; ///< the group heading in the sidebar
|
||||
const char* title; ///< the row's label and the page's heading
|
||||
const char* resource; ///< a path under /org/udpscope/help
|
||||
/// The screenshots shown above the text, as resource paths and a null terminator. A page
|
||||
/// with none is the common case and the reason this is a list rather than one field: the
|
||||
/// overview carries two, the window and a held capture.
|
||||
const char* const* images = nullptr;
|
||||
};
|
||||
|
||||
/// Adding a page is one .txt file, one <file> line in the manifest and one row here.
|
||||
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
|
||||
/// How tall a page's screenshots are drawn. They are scaled to fit this *height* and centre
|
||||
/// themselves in it, so a 1500x950 capture is readable without a horizontal scrollbar.
|
||||
constexpr int kHelpShotHeight = 260;
|
||||
|
||||
/// The screenshots each page carries, in the order it shows them.
|
||||
constexpr const char* kOverviewShots[] = {"/org/udpscope/help/img/main-window.png",
|
||||
"/org/udpscope/help/img/trigger-capture.png",
|
||||
nullptr};
|
||||
constexpr const char* kTriggerShots[] = {"/org/udpscope/help/img/trigger-capture.png",
|
||||
nullptr};
|
||||
constexpr const char* kMathShots[] = {"/org/udpscope/help/img/math-editor.png", nullptr};
|
||||
|
||||
constexpr HelpPage kPages[] = {
|
||||
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt"},
|
||||
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt", kOverviewShots},
|
||||
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
|
||||
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
|
||||
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
|
||||
{"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
|
||||
{"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
|
||||
{"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"},
|
||||
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt"},
|
||||
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt", kTriggerShots},
|
||||
{"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"},
|
||||
{"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"},
|
||||
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt"},
|
||||
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt", kMathShots},
|
||||
{"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"},
|
||||
{"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"},
|
||||
{"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"},
|
||||
@@ -92,37 +110,69 @@ void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label)
|
||||
bool ok = false;
|
||||
const std::string body = load_page(kPages[idx], &ok);
|
||||
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
|
||||
// The screenshots above it, if this page has any. The column is the label's previous sibling
|
||||
// inside the page's box, which is what lets this handler stay a matter of the label alone --
|
||||
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
|
||||
// also the only way to be sure the pictures are this page's and not the last one's.
|
||||
GtkWidget* label = GTK_WIDGET(body_label);
|
||||
GtkWidget* parent = gtk_widget_get_parent(label);
|
||||
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
|
||||
if (shots != nullptr && GTK_IS_BOX(shots)) {
|
||||
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
|
||||
gtk_box_remove(GTK_BOX(shots), old);
|
||||
}
|
||||
for (const char* const* img = kPages[idx].images; img != nullptr && *img != nullptr; ++img) {
|
||||
GtkWidget* shot = gtk_picture_new_for_resource(*img);
|
||||
gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE);
|
||||
// A height, and `CONTAIN` (the default) to fit the image inside it: a picture that may
|
||||
// shrink asks for no height at all in a box, so without this it is allocated nothing and
|
||||
// the page shows its text with a gap where the screenshots should be.
|
||||
gtk_widget_set_size_request(shot, -1, kHelpShotHeight);
|
||||
gtk_box_append(GTK_BOX(shots), shot);
|
||||
}
|
||||
gtk_widget_set_visible(shots, gtk_widget_get_first_child(shots) != nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void show_about(GtkWindow* parent, const std::string& session_path) {
|
||||
if (g_about != nullptr) {
|
||||
gtk_window_present(GTK_WINDOW(g_about));
|
||||
adw_dialog_present(g_about, GTK_WIDGET(parent));
|
||||
return;
|
||||
}
|
||||
|
||||
// GtkAboutDialog is not deprecated in GTK 4 (unlike GtkShortcutsWindow), so the
|
||||
// stock dialog is used here and the overview is hand-rolled.
|
||||
GtkWidget* dialog = gtk_about_dialog_new();
|
||||
// `AdwAboutDialog` rather than `GtkAboutDialog`: it shows the application icon as the page's
|
||||
// own header, which is the whole reason the app has one, and `adw_about_window_new()` is
|
||||
// deprecated in favour of it. It is an `AdwDialog`, so it is modal on what it is presented
|
||||
// on and needs no transient-for or modal call of its own.
|
||||
AdwDialog* dialog = adw_about_dialog_new();
|
||||
g_about = dialog;
|
||||
GtkAboutDialog* about = GTK_ABOUT_DIALOG(dialog);
|
||||
gtk_about_dialog_set_program_name(about, "udpscope");
|
||||
gtk_about_dialog_set_version(about, build_info::kVersion);
|
||||
gtk_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources.");
|
||||
gtk_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11);
|
||||
AdwAboutDialog* about = ADW_ABOUT_DIALOG(dialog);
|
||||
adw_about_dialog_set_application_name(about, "udpscope");
|
||||
adw_about_dialog_set_application_icon(about, "org.udpscope.app");
|
||||
adw_about_dialog_set_developer_name(about, "Martino Ferrari");
|
||||
adw_about_dialog_set_version(about, build_info::kVersion);
|
||||
adw_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources.");
|
||||
adw_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11);
|
||||
adw_about_dialog_set_website(about, "https://git.ferrarinas.it/martino/udpscope");
|
||||
|
||||
char details[1024];
|
||||
std::snprintf(details, sizeof(details),
|
||||
"Commit: %s\nBuilt: %s\nSettings: %s\nSession file: %s",
|
||||
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
|
||||
session_path.c_str());
|
||||
gtk_about_dialog_set_system_information(about, details);
|
||||
// `debug_info` rather than `system_information`: it renders as a Troubleshooting page with a
|
||||
// copy button, where the old field was inert text a user had to retype. A `std::string`
|
||||
// rather than a fixed buffer, because a long settings path used to truncate it silently.
|
||||
const std::string debug = "Commit: " + std::string(build_info::kCommit) +
|
||||
"\nBuilt: " + build_info::kTimestamp +
|
||||
"\nSettings: " + config_dir() +
|
||||
"\nSession file: " + session_path;
|
||||
adw_about_dialog_set_debug_info(about, debug.c_str());
|
||||
|
||||
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
|
||||
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
|
||||
g_signal_connect(dialog, "close-request", G_CALLBACK(on_window_close), &g_about);
|
||||
gtk_window_present(GTK_WINDOW(dialog));
|
||||
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_about);
|
||||
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
||||
}
|
||||
|
||||
const char* about_icon_name() {
|
||||
if (g_about == nullptr) return "";
|
||||
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
|
||||
}
|
||||
|
||||
HelpReport help_self_check() {
|
||||
@@ -168,7 +218,17 @@ void show_help(GtkWindow* parent) {
|
||||
GtkWidget* body_scroll = gtk_scrolled_window_new();
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
|
||||
GTK_POLICY_AUTOMATIC);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), body);
|
||||
// The page's own column: its screenshots above its text. Built once and *refilled* by the page
|
||||
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
|
||||
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||
gtk_widget_set_margin_start(shot_box, 18);
|
||||
gtk_widget_set_margin_end(shot_box, 18);
|
||||
gtk_widget_set_margin_top(shot_box, 14);
|
||||
gtk_widget_set_visible(shot_box, false);
|
||||
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_box_append(GTK_BOX(page_box), shot_box);
|
||||
gtk_box_append(GTK_BOX(page_box), body);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
|
||||
gtk_widget_set_hexpand(body_scroll, TRUE);
|
||||
|
||||
GtkWidget* list = gtk_list_box_new();
|
||||
@@ -278,13 +338,13 @@ void show_shortcuts(GtkWindow* parent) {
|
||||
gestures[i].description);
|
||||
}
|
||||
|
||||
g_signal_connect(dialog, "closed", G_CALLBACK(on_shortcuts_closed), nullptr);
|
||||
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts);
|
||||
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
||||
}
|
||||
|
||||
void close_help_windows() {
|
||||
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
|
||||
if (g_about != nullptr) gtk_window_close(GTK_WINDOW(g_about));
|
||||
if (g_about != nullptr) adw_dialog_close(g_about);
|
||||
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
|
||||
}
|
||||
|
||||
|
||||
+13
-6
@@ -8,10 +8,16 @@
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// The About dialog: version, commit, build timestamp and where the session files live.
|
||||
/// Modal on `parent`. `session_path` is this run's own session file.
|
||||
/// The About dialog: the app icon, version, commit, build timestamp and where the session
|
||||
/// files live. An `AdwAboutDialog`, so it is modal on `parent` by being presented on it, and
|
||||
/// it is an `AdwDialog` rather than a `GtkWindow`. `session_path` is this run's own session
|
||||
/// file; it goes on the Troubleshooting page, which has a copy button.
|
||||
void show_about(GtkWindow* parent, const std::string& session_path);
|
||||
|
||||
/// The icon name the open About dialog carries, or "" when it is not open. The probe reads it
|
||||
/// so the dialog is asserted to *show* the app icon rather than merely to open.
|
||||
const char* about_icon_name();
|
||||
|
||||
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while
|
||||
/// the scope runs. Already open: presented.
|
||||
void show_help(GtkWindow* parent);
|
||||
@@ -33,10 +39,11 @@ HelpReport help_self_check();
|
||||
/// deprecated as of GTK 4.18 and this project builds warning-free.
|
||||
void show_shortcuts(GtkWindow* parent);
|
||||
|
||||
/// Closes whichever of the three windows are open. The About dialog and the help window are
|
||||
/// GtkWindows and close with gtk_window_close(), so their close-request handlers run; the
|
||||
/// shortcut overview is an AdwDialog and closes with adw_dialog_close(), which emits ::closed.
|
||||
/// Either way the file-scope pointer is nulled by the handler. Used by the probe.
|
||||
/// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help
|
||||
/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The
|
||||
/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(),
|
||||
/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is
|
||||
/// what prevents a second open presenting a dead widget. Used by the probe.
|
||||
void close_help_windows();
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The symbolic form of the app icon: the same metaphor reduced to a screen frame with one
|
||||
wave inside it, following the UI-icon rules rather than the app-icon ones.
|
||||
|
||||
Filled paths only, with no stroke anywhere. GTK's symbolic recolour wraps the file in a
|
||||
stylesheet that reaches `fill` on `path`, `rect` and `circle` only, so a stroked line keeps
|
||||
the colour written here and stays dark on a dark theme. The wave is therefore a closed
|
||||
2px band: a sine along its top edge and the same sine 2px lower along its bottom, which is
|
||||
how a stroke's look is had from a fill.
|
||||
|
||||
`fill-rule="evenodd"` over three subpaths. The frame is the outer rect minus the inner one,
|
||||
so the screen's interior is two crossings deep and empty; the wave inside it is three, which
|
||||
is odd, so it fills. Whole-pixel coordinates throughout, because this is drawn at 16px far
|
||||
more often than not and a half-pixel feature is a grey smear at 1x.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="#2e3436" fill-rule="evenodd"
|
||||
d="M 1 2 H 15 V 14 H 1 Z
|
||||
M 2 3 H 14 V 13 H 2 Z
|
||||
M 3 7 C 5 3 6 3 8 7 C 10 11 11 11 13 7 L 13 9 C 11 13 10 13 8 9 C 6 5 5 5 3 9 Z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The application icon: an oscilloscope's face, which is the clearest metaphor available and
|
||||
the kind of physical object the HIG's top-and-front depth language was written for.
|
||||
|
||||
Drawn to <https://developer.gnome.org/hig/guidelines/app-icons.html>. The artwork spans
|
||||
x 12..116 and y 22..106, so it does not fill the 128px canvas, and every feature sits on
|
||||
the 2px grid. Depth is the 4px front profile along the bottom and nothing else: that is
|
||||
the HIG's maximum of 2 detail units, and since every surface here is straight, the rest
|
||||
is flat colour with no gradients and no shadows.
|
||||
|
||||
The profile is the bottom 4px of the chassis silhouette, clipped to it. A plain rect from
|
||||
(12, 102) to (116, 106) would protrude past the radius-8 corners, which reads as a
|
||||
misdrawn bump at 128px and as a dirty edge at 32px; the clip follows the corners exactly
|
||||
and keeps every coordinate even, which hand-computing the arc would not.
|
||||
|
||||
Two deliberate omissions. There is no graticule: the grid lines a real scope face carries
|
||||
cannot survive 32px, and the trace carries the metaphor without them. And the trace is
|
||||
one cycle, a single crest and a single trough, because at 32px the screen is about
|
||||
22x16 px and anything busier reads as a smudge.
|
||||
|
||||
Colours are the GNOME palette: Dark 3 chassis, Dark 2 profile, Dark 4 screen, Green 1 trace.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<clipPath id="chassis-silhouette">
|
||||
<rect x="12" y="22" width="104" height="84" rx="8"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="12" y="22" width="104" height="84" rx="8" fill="#3d3846"/>
|
||||
<g clip-path="url(#chassis-silhouette)">
|
||||
<rect x="12" y="102" width="104" height="4" fill="#5e5c64"/>
|
||||
</g>
|
||||
<rect x="20" y="30" width="88" height="64" rx="4" fill="#241f31"/>
|
||||
<path d="M 24 62 C 37 38 51 38 64 62 C 77 86 91 86 104 62"
|
||||
fill="none" stroke="#8ff0a4" stroke-width="6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
@@ -2,16 +2,26 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The four icons this app had to draw for itself, compiled into the binary so there is
|
||||
still exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an
|
||||
icon theme has, so the prefix is followed by `<size>/<context>`; `scalable` is the one
|
||||
that means "an SVG, use it at any size".
|
||||
The icons this app had to draw for itself, compiled into the binary so there is still
|
||||
exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an icon theme
|
||||
has, so the prefix is followed by `<size>/<context>`; `scalable` is the one that means
|
||||
"an SVG, use it at any size", and `apps` is where an application icon belongs.
|
||||
|
||||
Every file carries `preprocess="xml-stripblanks"`, which makes glib-compile-resources pipe
|
||||
it through xmllint. That is the only thing that catches a malformed SVG: librsvg refuses
|
||||
such a file outright, but the icon *name* still resolves, so `gtk_icon_theme_has_icon()`
|
||||
returns true and the icon simply draws nothing. No runtime GTK call can tell the two apart
|
||||
(`lookup_icon()` never returns NULL and always reports the size it was asked for), so the
|
||||
check belongs here, where a malformed SVG fails the build with a file and line number
|
||||
(plus a caret under the offending character) instead.
|
||||
-->
|
||||
<gresources>
|
||||
<gresource prefix="/org/udpscope/icons">
|
||||
<file>scalable/actions/udpscope-trigger-symbolic.svg</file>
|
||||
<file>scalable/actions/udpscope-cursors-symbolic.svg</file>
|
||||
<file>scalable/actions/udpscope-measure-symbolic.svg</file>
|
||||
<file>scalable/actions/udpscope-math-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
|
||||
<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/apps/org.udpscope.app.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
+390
-61
@@ -148,6 +148,7 @@ struct App {
|
||||
/// registered is *silent* -- GTK substitutes a missing-image placeholder, the button still
|
||||
/// works, and every other line of the dump still passes.
|
||||
std::string dbg_toolbar_icons;
|
||||
std::string dbg_app_icons;
|
||||
/// The header bar, so the dump can report the order the controls are in: a header bar is a
|
||||
/// *titlebar*, not a child of the window, so walking down from the window does not reach it.
|
||||
GtkWidget* toolbar = nullptr;
|
||||
@@ -333,8 +334,9 @@ struct App {
|
||||
/// second ago. A source that serves one client at a time shows up as a reconnect plus
|
||||
/// no frames for the second between two samples (see `note_starvation`).
|
||||
std::uint64_t starve_frames = 0;
|
||||
std::uint64_t starve_reconnects = 0;
|
||||
bool starve_have_prev = false;
|
||||
/// Consecutive one-second samples with `frames` unchanged while connected and running.
|
||||
int starve_still = 0;
|
||||
/// Set while the starvation notice is up, so the stderr line prints on the edges.
|
||||
bool starve_reported = false;
|
||||
/// Frames drawn with every bound trace slot empty although its channel has
|
||||
@@ -495,6 +497,8 @@ struct App {
|
||||
/// The last glance line, for the exit dump's `status bar:` line — the dump runs after the
|
||||
/// window is destroyed, so it cannot ask the label.
|
||||
std::string dbg_glance_last;
|
||||
/// Which step of the menu probe runs next (`UDPSCOPE_PROBE_MENUS_MS`).
|
||||
int dbg_menus_step = 0;
|
||||
|
||||
GtkWidget* status_info_button = nullptr; ///< the ⓘ
|
||||
GtkWidget* status_popover = nullptr;
|
||||
@@ -517,6 +521,15 @@ struct App {
|
||||
void install_css() {
|
||||
static const char kCss[] =
|
||||
"row.udpscope-compact { min-height: 0; padding: 1px 0; }\n"
|
||||
// A popover menu row: Adwaita gives a button a 34 px minimum height and 8 px of padding,
|
||||
// which is what made the overflow read as a stack of dialogs rather than a menu. The
|
||||
// shortcut label is a plain .dim-label at the row's right, so all that is left to trim
|
||||
// is the row itself.
|
||||
"popover.udpscope-menu button { min-height: 0; padding: 3px 8px; }\n"
|
||||
"popover.udpscope-menu button > box { min-height: 0; }\n"
|
||||
"popover.udpscope-menu button label { min-height: 0; }\n"
|
||||
"popover.udpscope-menu image { min-width: 12px; min-height: 0; }\n"
|
||||
"popover.udpscope-menu separator { margin: 4px 0; }\n"
|
||||
"row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n"
|
||||
"row.udpscope-compact checkbutton { min-height: 20px; }\n";
|
||||
GtkCssProvider* p = gtk_css_provider_new();
|
||||
@@ -527,6 +540,78 @@ void install_css() {
|
||||
g_object_unref(p);
|
||||
}
|
||||
|
||||
/// Keeps a menu row's check in step with its toggle. The check keeps its allocation when it is
|
||||
/// off (opacity, not visibility), so a checked and an unchecked row's labels line up.
|
||||
void on_menu_row_toggled(GtkToggleButton* button, gpointer) {
|
||||
GtkWidget* check = GTK_WIDGET(g_object_get_data(G_OBJECT(button), "udpscope-check"));
|
||||
if (check != nullptr) {
|
||||
gtk_widget_set_opacity(check, gtk_toggle_button_get_active(button) ? 1.0 : 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// One row of a popover menu, laid out the way a GNOME menu lays one out: the label at the
|
||||
/// left, the shortcut at the right when the action has one, and -- for a row that toggles
|
||||
/// something -- a check in its own column. Returned as a box for the caller to hand to
|
||||
/// `gtk_button_set_child()`; `.udpscope-menu` in install_css() is what trims the height.
|
||||
GtkWidget* menu_row(const char* label, const char* accel, bool with_check,
|
||||
GtkWidget** out_check) {
|
||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
|
||||
if (with_check) {
|
||||
GtkWidget* check = gtk_image_new_from_icon_name("object-select-symbolic");
|
||||
gtk_widget_set_opacity(check, 0.0);
|
||||
gtk_box_append(GTK_BOX(box), check);
|
||||
if (out_check != nullptr) *out_check = check;
|
||||
}
|
||||
GtkWidget* text = gtk_label_new(label);
|
||||
gtk_label_set_xalign(GTK_LABEL(text), 0.0f);
|
||||
gtk_widget_set_hexpand(text, true);
|
||||
gtk_box_append(GTK_BOX(box), text);
|
||||
if (accel != nullptr) {
|
||||
GtkWidget* key = gtk_label_new(accel);
|
||||
gtk_label_set_xalign(GTK_LABEL(key), 1.0f);
|
||||
gtk_widget_add_css_class(key, "dim-label");
|
||||
gtk_box_append(GTK_BOX(box), key);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/// A plain menu row. `accel` is what the app binds the action to (see shortcuts.cpp) -- it is
|
||||
/// shown, never bound here: the key handling stays in the one controller that refuses a key
|
||||
/// while a text field has focus.
|
||||
GtkWidget* menu_button(const char* label, const char* accel) {
|
||||
GtkWidget* button = gtk_button_new();
|
||||
// The check column is reserved even though this row cannot be checked: a menu whose labels
|
||||
// start at two different x -- one for the rows that can be checked and one for the rest --
|
||||
// is what "the text is not left-aligned" looks like.
|
||||
gtk_button_set_child(GTK_BUTTON(button), menu_row(label, accel, true, nullptr));
|
||||
gtk_widget_add_css_class(button, "flat");
|
||||
gtk_widget_set_hexpand(button, true);
|
||||
gtk_widget_set_halign(button, GTK_ALIGN_FILL);
|
||||
return button;
|
||||
}
|
||||
|
||||
/// A menu row that toggles something, with the check menu_row() reserves the column for. The
|
||||
/// initial state is set *before* the handler is connected: a programmatic `set_active()` emits
|
||||
/// `toggled`, the handler acts on widgets the caller has not built yet (the signal list's
|
||||
/// revealer is created later in run_ui()), and a row that is created on must not reach it.
|
||||
GtkWidget* menu_toggle(const char* label, const char* accel, bool active) {
|
||||
GtkWidget* button = gtk_toggle_button_new();
|
||||
GtkWidget* check = nullptr;
|
||||
gtk_button_set_child(GTK_BUTTON(button), menu_row(label, accel, true, &check));
|
||||
gtk_widget_add_css_class(button, "flat");
|
||||
gtk_widget_set_hexpand(button, true);
|
||||
gtk_widget_set_halign(button, GTK_ALIGN_FILL);
|
||||
g_object_set_data(G_OBJECT(button), "udpscope-check", check);
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), active);
|
||||
on_menu_row_toggled(GTK_TOGGLE_BUTTON(button), nullptr);
|
||||
g_signal_connect(button, "toggled", G_CALLBACK(on_menu_row_toggled), nullptr);
|
||||
return button;
|
||||
}
|
||||
|
||||
GtkWidget* menu_separator() {
|
||||
return gtk_separator_new(GTK_ORIENTATION_HORIZONTAL);
|
||||
}
|
||||
|
||||
GtkWidget* labeled_box(const char* label, GtkWidget* child) {
|
||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
GtkWidget* l = gtk_label_new(label);
|
||||
@@ -1660,18 +1745,18 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
|
||||
|
||||
// The overflow: information and calibration are set once per session, so they
|
||||
// cost a click here instead of a button on every row forever.
|
||||
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
||||
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_widget_set_margin_top(menu_box, 4);
|
||||
gtk_widget_set_margin_bottom(menu_box, 4);
|
||||
gtk_widget_set_margin_start(menu_box, 4);
|
||||
gtk_widget_set_margin_end(menu_box, 4);
|
||||
|
||||
GtkWidget* info = gtk_button_new_with_label("Information");
|
||||
GtkWidget* info = menu_button("Information", nullptr);
|
||||
g_object_set_data(G_OBJECT(info), "udpscope-signal", GSIZE_TO_POINTER(i));
|
||||
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), info);
|
||||
|
||||
GtkWidget* cal = gtk_button_new_with_label("Calibrate...");
|
||||
GtkWidget* cal = menu_button("Calibrate...", nullptr);
|
||||
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
|
||||
g_object_set_data(G_OBJECT(cal), "udpscope-signal", GSIZE_TO_POINTER(i));
|
||||
g_signal_connect(cal, "clicked",
|
||||
@@ -1685,6 +1770,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
|
||||
gtk_box_append(GTK_BOX(menu_box), cal);
|
||||
|
||||
GtkWidget* popover = gtk_popover_new();
|
||||
gtk_widget_add_css_class(popover, "udpscope-menu");
|
||||
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
|
||||
GtkWidget* more = gtk_menu_button_new();
|
||||
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(more), "view-more-symbolic");
|
||||
@@ -4386,6 +4472,61 @@ void rebuild_signal_panels(App& app) {
|
||||
app.signal_generation = app.model->generation();
|
||||
}
|
||||
|
||||
/// The width and height the root `<svg>` element of `data` declares, or 0x0 when it has none.
|
||||
/// GMarkup rather than a string scan because the attributes may be in any order and on any
|
||||
/// line; it is *not* a validity check -- GMarkup is a deliberately lenient subset parser and
|
||||
/// accepts a double hyphen inside a comment, which is exactly the file librsvg refuses. That
|
||||
/// check is `xml-stripblanks` in the GResource manifest, at build time.
|
||||
void svg_declared_size(const char* data, gsize size, int* w, int* h) {
|
||||
struct Found { int w = 0; int h = 0; };
|
||||
Found found;
|
||||
GMarkupParser parser{};
|
||||
parser.start_element = [](GMarkupParseContext*, const char* element, const char** names,
|
||||
const char** values, gpointer user, GError**) {
|
||||
if (std::strcmp(element, "svg") != 0) return;
|
||||
Found* f = static_cast<Found*>(user);
|
||||
for (int i = 0; names[i] != nullptr; ++i) {
|
||||
if (std::strcmp(names[i], "width") == 0) f->w = std::atoi(values[i]);
|
||||
if (std::strcmp(names[i], "height") == 0) f->h = std::atoi(values[i]);
|
||||
}
|
||||
};
|
||||
GMarkupParseContext* ctx = g_markup_parse_context_new(&parser, GMarkupParseFlags(0), &found,
|
||||
nullptr);
|
||||
if (g_markup_parse_context_parse(ctx, data, static_cast<gssize>(size), nullptr)) {
|
||||
g_markup_parse_context_end_parse(ctx, nullptr);
|
||||
}
|
||||
g_markup_parse_context_free(ctx);
|
||||
*w = found.w;
|
||||
*h = found.h;
|
||||
}
|
||||
|
||||
/// One entry of the `app icons:` line: the icon is *named* (the theme resolved it, so the
|
||||
/// resource path was registered) and *present* (the bytes the name resolved to are really in
|
||||
/// the binary), at the size its own root element declares.
|
||||
///
|
||||
/// It deliberately does not claim the file renders. `gtk_icon_theme_lookup_icon()` never
|
||||
/// returns NULL, its paintable's intrinsic size is the size that was asked for, and a valid
|
||||
/// icon, a malformed one and a name that does not exist at all all snapshot to the same
|
||||
/// texture node -- GTK substitutes `image-missing` and there is nothing left to compare. The
|
||||
/// malformed case is a build error instead; see the GResource manifest.
|
||||
std::string icon_report(GtkIconTheme* theme, const char* name, const char* resource) {
|
||||
const bool named = gtk_icon_theme_has_icon(theme, name) != FALSE;
|
||||
GBytes* bytes = g_resources_lookup_data(resource, G_RESOURCE_LOOKUP_FLAGS_NONE, nullptr);
|
||||
const bool present = bytes != nullptr;
|
||||
int w = 0;
|
||||
int h = 0;
|
||||
if (present) {
|
||||
gsize size = 0;
|
||||
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
|
||||
svg_declared_size(data, size, &w, &h);
|
||||
g_bytes_unref(bytes);
|
||||
}
|
||||
std::string out = std::string(name) + "(";
|
||||
out += (named && present) ? "ok " : "MISSING ";
|
||||
out += std::to_string(w) + "x" + std::to_string(h) + ")";
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Where the panel controls live, as their parents say: the three pane toggles are toolbar buttons
|
||||
/// and Reset is inside the cursors pane. Recorded on the first tick -- the widgets exist and the
|
||||
/// window is alive, neither of which is true when the dump runs -- so the dump can print a fact
|
||||
@@ -4440,9 +4581,11 @@ void note_panel_controls(App& app) {
|
||||
|
||||
// ... and the icon each of them carries, asked of the theme rather than of our own
|
||||
// string. Four of these are drawn for this app and live in a GResource inside the
|
||||
// binary, which the theme only looks in because `build_toolbar()` adds the resource
|
||||
// path -- and a path that was never added fails *silently*: the button keeps working,
|
||||
// the layout is unchanged, and GTK draws a missing-image placeholder no probe can see.
|
||||
// binary, which the theme only looks in because `run_ui()` adds the resource path
|
||||
// before building anything -- and a path that was never added fails *silently*: the
|
||||
// button keeps working, the layout is unchanged, and GTK draws a missing-image
|
||||
// placeholder no probe can see. The two app icons below come out of the same
|
||||
// registration, which is why a MISSING on either line is one defect.
|
||||
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
|
||||
for (const auto& [widget, name] : named) {
|
||||
const char* icon = nullptr;
|
||||
@@ -4455,6 +4598,15 @@ void note_panel_controls(App& app) {
|
||||
app.dbg_toolbar_icons += std::string(app.dbg_toolbar_icons.empty() ? "" : " ") + name + "=" +
|
||||
icon + (gtk_icon_theme_has_icon(theme, icon) ? "(ok)" : "(MISSING)");
|
||||
}
|
||||
// The application icon, the same two ways and for the same reason: a resource path that was
|
||||
// never registered is not an error, it is a missing-image placeholder. This one is read on
|
||||
// the first tick like the line above, because the dump runs after the window is destroyed.
|
||||
app.dbg_app_icons =
|
||||
icon_report(theme, "org.udpscope.app",
|
||||
"/org/udpscope/icons/scalable/apps/org.udpscope.app.svg") +
|
||||
" " +
|
||||
icon_report(theme, "org.udpscope.app-symbolic",
|
||||
"/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg");
|
||||
}
|
||||
|
||||
/// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window
|
||||
@@ -5416,17 +5568,23 @@ void note_starvation(App& app) {
|
||||
// No `have_config` here: the losing client of a single-client source need never see a
|
||||
// CONFIG at all (the server answered the first one), and "connected, kicked, nothing
|
||||
// arriving" is the state worth naming either way.
|
||||
bool starved = false;
|
||||
if (app.starve_have_prev && connected && !app.acq->paused()) {
|
||||
starved = reconnects > app.starve_reconnects && frames == app.starve_frames;
|
||||
}
|
||||
// "Connected, but nothing is arriving" is a *rate* fact, not a reconnect one: a source that
|
||||
// answers CONNECT and then goes quiet -- the unicast case where another client took the session --
|
||||
// never reconnects while it is silent, so requiring a reconnect made the notice invisible in
|
||||
// exactly the state it describes. What it takes is `frames` not moving across three consecutive
|
||||
// one-second samples: one sample would call a source slower than the sample interval starved, and
|
||||
// three still clears the moment anything arrives.
|
||||
const bool idle = app.starve_have_prev && frames == app.starve_frames;
|
||||
app.starve_still = (connected && !app.acq->paused() && idle) ? app.starve_still + 1 : 0;
|
||||
app.starve_frames = frames;
|
||||
app.starve_reconnects = reconnects;
|
||||
app.starve_have_prev = true;
|
||||
|
||||
if (!starved) {
|
||||
if (app.starve_still < 3) {
|
||||
app.starve_reported = false;
|
||||
// (the zoom-in is judged on the next frame, after the layout pushes it)
|
||||
// **A `return`, not just a branch.** The notice and its line used to sit outside this test, so
|
||||
// they were re-armed every frame and the status bar carried "no data is arriving" while the
|
||||
// stream was arriving perfectly well.
|
||||
return;
|
||||
}
|
||||
app.notice =
|
||||
"connected, but no data is arriving: another reader may hold the source (a unicast "
|
||||
@@ -5582,12 +5740,6 @@ GtkWidget* build_toolbar(App& app) {
|
||||
GtkWidget* bar = gtk_header_bar_new();
|
||||
app.toolbar = bar;
|
||||
|
||||
// The app's own icons, before the first button 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.
|
||||
gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()),
|
||||
"/org/udpscope/icons");
|
||||
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar),
|
||||
labeled_box_fixed("window", build_window_control(app)));
|
||||
refresh_window_limits(app);
|
||||
@@ -5601,27 +5753,68 @@ GtkWidget* build_toolbar(App& app) {
|
||||
|
||||
|
||||
// --- the overflow menu ----------------------------------------------------
|
||||
// Plain buttons in a popover rather than a GMenu: these act on the `RowSet`
|
||||
// directly and two of them are toggles whose state the rest of the UI reads, so
|
||||
// a GAction model would only add a second copy of that state.
|
||||
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
||||
// Rows rather than plain buttons: the label at the left, the shortcut at the right and a
|
||||
// check on the rows that toggle something, which is the shape every GNOME menu has and the
|
||||
// reason this popover no longer reads as a stack of dialogs (see menu_row(), and the
|
||||
// `.udpscope-menu` rules in install_css()). It stays hand-built instead of a `GMenu` model
|
||||
// because these act on the `RowSet` directly and two of them are toggles whose state the
|
||||
// rest of the UI reads, so an action model would only add a second copy of that state.
|
||||
//
|
||||
// The order is a GNOME menu's: what you do to the *view*, then the data, then the help
|
||||
// trio. Nothing here needs a separator of its own -- the groups are the separators.
|
||||
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_widget_set_margin_top(menu_box, 6);
|
||||
gtk_widget_set_margin_bottom(menu_box, 6);
|
||||
gtk_widget_set_margin_start(menu_box, 6);
|
||||
gtk_widget_set_margin_end(menu_box, 6);
|
||||
|
||||
GtkWidget* fit = gtk_button_new_with_label("Fit to data");
|
||||
GtkWidget* fit = menu_button("Fit to data", "F");
|
||||
gtk_widget_set_tooltip_text(fit, "auto-scale every axis and frame the buffered data");
|
||||
g_signal_connect(fit, "clicked", G_CALLBACK(on_fit_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), fit);
|
||||
|
||||
app.layout_toggle = gtk_toggle_button_new_with_label("Layout editor");
|
||||
app.layout_toggle = menu_toggle("Layout editor", "L", false);
|
||||
gtk_widget_set_tooltip_text(app.layout_toggle,
|
||||
"add and remove plot rows in place: \u00d7 on a row removes "
|
||||
"it, + between rows inserts one there");
|
||||
g_signal_connect(app.layout_toggle, "toggled", G_CALLBACK(on_layout_editor_toggled), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), app.layout_toggle);
|
||||
|
||||
app.signal_toggle = menu_toggle("Signal list", nullptr, true);
|
||||
gtk_widget_set_tooltip_text(app.signal_toggle, "show or hide the signal list");
|
||||
g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), app.signal_toggle);
|
||||
|
||||
gtk_box_append(GTK_BOX(menu_box), menu_separator());
|
||||
|
||||
GtkWidget* export_btn = menu_button("Export visible range...", "Ctrl+E");
|
||||
gtk_widget_set_tooltip_text(export_btn,
|
||||
"write every plotted signal over the visible x range, at the "
|
||||
"stored sample rate, to a .npy or .csv file");
|
||||
g_signal_connect(export_btn, "clicked", G_CALLBACK(on_export_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), export_btn);
|
||||
|
||||
gtk_box_append(GTK_BOX(menu_box), menu_separator());
|
||||
|
||||
GtkWidget* shortcuts_item = menu_button("Keyboard shortcuts", "Ctrl+/");
|
||||
gtk_widget_set_tooltip_text(shortcuts_item,
|
||||
"every key and mouse gesture, including the plot's own");
|
||||
g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), shortcuts_item);
|
||||
|
||||
GtkWidget* help_item = menu_button("Help", "F1");
|
||||
gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
|
||||
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), help_item);
|
||||
|
||||
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);
|
||||
gtk_box_append(GTK_BOX(menu_box), about_item);
|
||||
|
||||
// --- the pane toggles, which live on the bar rather than in the menu ------
|
||||
// Created here because they share the popover's styling helpers; packed onto the header bar
|
||||
// below, where the pack order (not this order) decides where they sit.
|
||||
app.measures_toggle = gtk_toggle_button_new();
|
||||
gtk_button_set_icon_name(GTK_BUTTON(app.measures_toggle), "udpscope-measure-symbolic");
|
||||
gtk_widget_set_tooltip_text(app.measures_toggle,
|
||||
@@ -5637,7 +5830,6 @@ GtkWidget* build_toolbar(App& app) {
|
||||
}
|
||||
}),
|
||||
&app);
|
||||
// (packed after math and before cursors below: see the order note at the menu)
|
||||
|
||||
app.math_toggle = gtk_toggle_button_new();
|
||||
gtk_button_set_icon_name(GTK_BUTTON(app.math_toggle), "udpscope-math-symbolic");
|
||||
@@ -5663,39 +5855,8 @@ GtkWidget* build_toolbar(App& app) {
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.cursors_toggle), false);
|
||||
g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app);
|
||||
|
||||
GtkWidget* export_btn = gtk_button_new_with_label("Export visible range...");
|
||||
gtk_widget_set_tooltip_text(export_btn,
|
||||
"write every plotted signal over the visible x range, at the "
|
||||
"stored sample rate, to a .npy or .csv file");
|
||||
g_signal_connect(export_btn, "clicked", G_CALLBACK(on_export_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), export_btn);
|
||||
|
||||
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||||
|
||||
app.signal_toggle = gtk_toggle_button_new_with_label("Signal list");
|
||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), true);
|
||||
g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), app.signal_toggle);
|
||||
|
||||
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||||
|
||||
GtkWidget* help_item = gtk_button_new_with_label("Help (F1)");
|
||||
gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
|
||||
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), help_item);
|
||||
|
||||
GtkWidget* shortcuts_item = gtk_button_new_with_label("Keyboard shortcuts (Ctrl+/)");
|
||||
gtk_widget_set_tooltip_text(shortcuts_item,
|
||||
"every key and mouse gesture, including the plot's own");
|
||||
g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), shortcuts_item);
|
||||
|
||||
GtkWidget* about_item = gtk_button_new_with_label("About udpscope");
|
||||
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);
|
||||
gtk_box_append(GTK_BOX(menu_box), about_item);
|
||||
|
||||
GtkWidget* popover = gtk_popover_new();
|
||||
gtk_widget_add_css_class(popover, "udpscope-menu");
|
||||
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
|
||||
app.menu_button = gtk_menu_button_new();
|
||||
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app.menu_button), "open-menu-symbolic");
|
||||
@@ -5764,6 +5925,10 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
}),
|
||||
app);
|
||||
gtk_window_set_title(GTK_WINDOW(app->window), "udpscope");
|
||||
// The shell takes a window's icon from this name, and GTK takes the WM class from the
|
||||
// application ID -- which is the same string, which is what associates this window with the
|
||||
// desktop entry.
|
||||
gtk_window_set_icon_name(GTK_WINDOW(app->window), "org.udpscope.app");
|
||||
gtk_window_set_default_size(GTK_WINDOW(app->window), 1280, 800);
|
||||
gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app));
|
||||
|
||||
@@ -5838,8 +6003,17 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
app->status_info_button = gtk_menu_button_new();
|
||||
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app->status_info_button),
|
||||
"help-about-symbolic");
|
||||
// Above the button, always. The status row is the last thing in the window, so "below"
|
||||
// is off the window's bottom edge and, on a window that reaches the bottom of the screen,
|
||||
// off the screen: the bubble's top edge and its arrow stayed at the button while the body
|
||||
// was clipped away -- what reads as an arrow disconnected from its body. GTK still slides
|
||||
// it sideways to fit, which is why only the vertical placement has to be stated.
|
||||
gtk_menu_button_set_popover(GTK_MENU_BUTTON(app->status_info_button),
|
||||
app->status_popover);
|
||||
// After `set_popover()`: a `GtkMenuButton` states a position of its own (below, which is
|
||||
// where a menu button in a header bar wants it), and it is the *last* word that counts.
|
||||
gtk_popover_set_position(GTK_POPOVER(app->status_popover), GTK_POS_TOP);
|
||||
gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE);
|
||||
gtk_widget_set_tooltip_text(app->status_info_button, "Stream and buffer detail");
|
||||
gtk_widget_set_has_tooltip(app->status_info_button, TRUE);
|
||||
gtk_box_append(GTK_BOX(status_row), app->status_info_button);
|
||||
@@ -7422,6 +7596,143 @@ gboolean zoomkeys_probe(gpointer user) {
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/// How tall a menu row may be. Adwaita's own minimum for a button is 34 px, and a popover of
|
||||
/// seven such rows is what made the overflow read as a stack of dialogs; the `.udpscope-menu`
|
||||
/// rules trim it to a menu row and this is what says they are still applied.
|
||||
constexpr int kMenuRowMaxPx = 30;
|
||||
|
||||
gboolean menus_probe_step(gpointer user);
|
||||
|
||||
void menus_probe_next(App& a, int step, guint delay_ms) {
|
||||
a.dbg_menus_step = step;
|
||||
g_timeout_add(delay_ms, menus_probe_step, &a);
|
||||
}
|
||||
|
||||
/// The label and the shortcut of one row, in the order menu_row() appends them. The optional
|
||||
/// check image comes first and is not a label, so this counts labels rather than positions.
|
||||
void menu_row_labels(GtkWidget* box, GtkWidget** label, GtkWidget** accel) {
|
||||
*label = nullptr;
|
||||
*accel = nullptr;
|
||||
for (GtkWidget* child = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
|
||||
child != nullptr; child = gtk_widget_get_next_sibling(child)) {
|
||||
if (!GTK_IS_LABEL(child)) continue;
|
||||
if (*label == nullptr) {
|
||||
*label = child;
|
||||
} else {
|
||||
*accel = child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The two popovers, measured, with the side panel hidden -- the state the report is about.
|
||||
/// What it asserts, row by row, is what a screenshot can only show an eye: the label at the
|
||||
/// left of its row, the shortcut at the right of it, a row under kMenuRowMaxPx, the rows in
|
||||
/// the intended order, and an info bubble placed above its button with room for its height.
|
||||
gboolean menus_probe_step(gpointer user) {
|
||||
App* a = static_cast<App*>(user);
|
||||
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||
|
||||
if (a->dbg_menus_step == 0) {
|
||||
// Hiding the panel is the case that was reported: the info button then sits at the
|
||||
// window's right edge, and a bubble placed below it has nowhere to go.
|
||||
run_app_action(*a, AppAction::SidePanel);
|
||||
// Autohide off *for the probe only*: a popover dismisses itself when it loses the grab, and
|
||||
// under a bare `xvfb-run` there is no window manager to hand it one, so the rows would be
|
||||
// measured before they were ever mapped. The app's own popovers keep autohide on.
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
|
||||
if (popover != nullptr) gtk_popover_set_autohide(popover, FALSE);
|
||||
gtk_menu_button_popup(GTK_MENU_BUTTON(a->menu_button));
|
||||
menus_probe_next(*a, 1, 300);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
if (a->dbg_menus_step == 1) {
|
||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
|
||||
GtkWidget* box = popover != nullptr ? gtk_popover_get_child(popover) : nullptr;
|
||||
static const char* const kExpected[] = {"Fit to data", "Layout editor", "Signal list",
|
||||
"Export visible range...", "Keyboard shortcuts",
|
||||
"Help", "About udpscope"};
|
||||
const int expected = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
|
||||
int rows = 0;
|
||||
int bad = 0;
|
||||
int first_label_x = 0;
|
||||
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
|
||||
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
|
||||
if (!GTK_IS_BUTTON(row)) continue;
|
||||
GtkWidget* label = nullptr;
|
||||
GtkWidget* accel = nullptr;
|
||||
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
|
||||
const int row_w = gtk_widget_get_width(row);
|
||||
const int row_h = gtk_widget_get_height(row);
|
||||
const graphene_point_t origin = GRAPHENE_POINT_INIT(0.0f, 0.0f);
|
||||
graphene_point_t at = GRAPHENE_POINT_INIT(0.0f, 0.0f);
|
||||
const int label_x = label != nullptr && gtk_widget_compute_point(label, row, &origin, &at)
|
||||
? static_cast<int>(at.x) : -1;
|
||||
const int accel_right =
|
||||
accel != nullptr && gtk_widget_compute_point(accel, row, &origin, &at)
|
||||
? static_cast<int>(at.x) + gtk_widget_get_width(accel) : -1;
|
||||
const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "";
|
||||
const char* key = accel != nullptr ? gtk_label_get_text(GTK_LABEL(accel)) : "";
|
||||
const bool named = rows < expected && std::strcmp(text, kExpected[rows]) == 0;
|
||||
// Every label starts at the same x -- that is what "left-aligned" means for a menu whose
|
||||
// rows differ in whether they carry a check.
|
||||
if (rows == 0) first_label_x = label_x;
|
||||
const bool left = label_x >= 0 && label_x <= 40 && label_x == first_label_x;
|
||||
const bool right = accel == nullptr || (accel_right > 0 && row_w - accel_right <= 12);
|
||||
const bool compact = row_h > 0 && row_h <= kMenuRowMaxPx;
|
||||
const bool ok = named && left && right && compact;
|
||||
if (!ok) ++bad;
|
||||
std::fprintf(stderr,
|
||||
"[menu] %d '%s' accel='%s' h=%d label_x=%d accel_right=%d/%d order=%d "
|
||||
"ok=%d\n", rows, text, key, row_h, label_x, accel_right, row_w,
|
||||
named ? 1 : 0, ok ? 1 : 0);
|
||||
++rows;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
"[menu] rows=%d expected=%d order=%d rows_bad=%d popover_visible=%d "
|
||||
"mapped=%d button_mapped=%d\n", rows, expected, rows == expected ? 1 : 0, bad,
|
||||
popover != nullptr ? gtk_widget_get_visible(GTK_WIDGET(popover)) : -1,
|
||||
popover != nullptr ? gtk_widget_get_mapped(GTK_WIDGET(popover)) : -1,
|
||||
a->menu_button != nullptr ? gtk_widget_get_mapped(a->menu_button) : -1);
|
||||
if (popover != nullptr) gtk_popover_set_autohide(popover, TRUE);
|
||||
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->menu_button));
|
||||
if (a->status_info_button != nullptr) {
|
||||
GtkPopover* info = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->status_info_button));
|
||||
if (info != nullptr) gtk_popover_set_autohide(info, FALSE);
|
||||
gtk_menu_button_popup(GTK_MENU_BUTTON(a->status_info_button));
|
||||
}
|
||||
menus_probe_next(*a, 2, 300);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// The info bubble: its own size against the room above the button it points at. GTK slides a
|
||||
// popover sideways to fit, so only the vertical placement has to hold -- and it is the one
|
||||
// that was wrong: below the button is off the window's bottom edge.
|
||||
GtkWidget* bubble = a->status_popover;
|
||||
const int bubble_w = bubble != nullptr ? gtk_widget_get_width(bubble) : -1;
|
||||
const int bubble_h = bubble != nullptr ? gtk_widget_get_height(bubble) : -1;
|
||||
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0, 0, 0, 0);
|
||||
const bool have_button = a->status_info_button != nullptr &&
|
||||
gtk_widget_compute_bounds(a->status_info_button, a->window, &bounds);
|
||||
const int button_top = have_button ? static_cast<int>(bounds.origin.y) : -1;
|
||||
const int button_right =
|
||||
have_button ? static_cast<int>(bounds.origin.x + bounds.size.width) : -1;
|
||||
const int pos = bubble != nullptr
|
||||
? static_cast<int>(gtk_popover_get_position(GTK_POPOVER(bubble))) : -1;
|
||||
const int fits = (pos == GTK_POS_TOP && bubble_h > 0 && bubble_w > 0 &&
|
||||
button_top >= bubble_h + 14) ? 1 : 0;
|
||||
std::fprintf(stderr, "[menu] status pos=%s bubble=%dx%d button_top=%d button_right=%d "
|
||||
"fits=%d\n", pos == GTK_POS_TOP ? "top" : "other", bubble_w, bubble_h,
|
||||
button_top, button_right, fits);
|
||||
if (a->status_info_button != nullptr) {
|
||||
GtkPopover* info = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->status_info_button));
|
||||
if (info != nullptr) gtk_popover_set_autohide(info, TRUE);
|
||||
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->status_info_button));
|
||||
}
|
||||
std::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
/// Fires a representative accel from each group and reports the state it changed, then
|
||||
/// checks the GtkEditable refusal. It goes through run_app_action() for the actions and
|
||||
/// through the real handler for the refusal, because the refusal lives in the handler.
|
||||
@@ -7503,6 +7814,9 @@ gboolean help_probe(gpointer user) {
|
||||
show_help(GTK_WINDOW(a->window));
|
||||
show_about(GTK_WINDOW(a->window), session);
|
||||
show_shortcuts(GTK_WINDOW(a->window));
|
||||
// Read while it is open: the icon is the point of the dialog carrying one, and
|
||||
// `close_help_windows()` is right below.
|
||||
const std::string about_icon = about_icon_name();
|
||||
close_help_windows();
|
||||
|
||||
int accel_count = 0;
|
||||
@@ -7511,10 +7825,11 @@ gboolean help_probe(gpointer user) {
|
||||
(void)plot_gestures(&gesture_count);
|
||||
std::fprintf(stderr,
|
||||
"[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d; "
|
||||
"about version=%s commit=%s built=%s settings=%s; closed ok\n",
|
||||
"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,
|
||||
build_info::kCommit, build_info::kTimestamp, config_dir().c_str());
|
||||
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
|
||||
about_icon.c_str());
|
||||
std::fflush(stderr);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
@@ -7525,6 +7840,16 @@ 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 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.
|
||||
//
|
||||
// It is here rather than in `build_toolbar()` because there are now two consumers -- the
|
||||
// window's own icon and the toolbar's buttons -- and this is the one place that is ahead of
|
||||
// both. `adw_init()` calls `gtk_init()`, which opens the default display, so the line above
|
||||
// is what makes `gdk_display_get_default()` valid here.
|
||||
gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()),
|
||||
"/org/udpscope/icons");
|
||||
App app;
|
||||
app.model = &model;
|
||||
app.acq = &acq;
|
||||
@@ -7608,6 +7933,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_MENUS_MS")) {
|
||||
menus_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
|
||||
}
|
||||
@@ -7799,6 +8127,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
// critical (which is how the first version of this line earned four of them).
|
||||
std::printf("[udpscope] panel controls: %s\n", app.dbg_panel_controls.c_str());
|
||||
std::printf("[udpscope] toolbar icons: %s\n", app.dbg_toolbar_icons.c_str());
|
||||
std::printf("[udpscope] app icons: %s\n", app.dbg_app_icons.c_str());
|
||||
// The measurements pane, with the numbers the engine computed: a probe adds measurements and
|
||||
// this is where their values are read, so a wrong threshold or a window that moved under a
|
||||
// stale result is visible in the run rather than only on screen.
|
||||
|
||||
Reference in new issue
Block a user