diff --git a/CLAUDE.md b/CLAUDE.md index c7caa47..83e888d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). @@ -76,6 +95,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 +488,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 + `` 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 +614,7 @@ 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='Overview' 1381 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. - `[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 `: : ->` — 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 +1297,50 @@ 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), whose `apps` context is where an icon theme keeps an + application's own icon and is what makes `Icon=org.udpscope.app` in the desktop entry resolve. + 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 + `` 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 +1394,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). diff --git a/README.md b/README.md index e9708cf..3d4f072 100644 --- a/README.md +++ b/README.md @@ -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 `` in the manifest carries `preprocess="xml-stripblanks"` and the build fails with a +file, line and column 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, 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`