The file was 1967 lines and is auto-loaded into every context, so every
session paid for the whole of it to reach one invariant. It now carries the
project's goal, the three facts that are always relevant, and a table of
contents; the body is fourteen files under docs/claude/ reached by link
rather than by import, so nothing is loaded until it is asked for.
The content is moved verbatim -- the only edits are each file's own header
and the cross-references that became cross-file ("see the invariant below"
is now a link to the file that owns it).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
14 KiB
14 KiB
Invariants: GTK traps, the status bar, the icons and the help
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages.
Part of CLAUDE.md. These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
- The status line's per-second fields are measured once a second, not per frame.
App::stored_rate/declared_rateand the label's tooltip are refreshed on the same tick as the channel dump; the status line only formats what is already there. The badge's markup is likewise rewritten on a change of phase (twice a second), never on all sixty frames. - A
notify::<prop>handler takes three parameters, avalue-changedhandler two.GObject::notifypasses(GObject*, GParamSpec*, gpointer)whileGtkSpinButton::value-changedpasses(GtkSpinButton*, gpointer), so one callback cannot serve both: on thenotify::connection theGParamSpec*lands in theuser_dataslot and the handler dereferences it as itsApp*.G_CALLBACKis a cast, so nothing warns. That is why the trigger panel's spin buttons and its dropdowns have separate thin entry points (on_trigger_field_changed,on_trigger_drop_changed) onto onepublish_trigger_fields(): changing the edge or Normal/Single dropdown used to segfault outright.UDPSCOPE_PROBE_FIELDS_MSis the guard. GtkDropDown'snotify::selectedarrives after theset_selected()that caused it returns, so theupdating_panelsflag — set around a programmatic write and cleared when it returns — does not suppress it. Where the write is part of a readout the handler reads back, that is a feedback loop:sync_window_display()writes a value rounded for reading into the window entry and picks the unit for it, and the late notify hadon_window_unit_changed()re-parse that rounded text, turning a 500 s request into 499.98 s. The guard isApp::window_sync_unit— the unit we last synced — and a selection equal to it is ours, since GTK only notifies on a change. Any new control that both writes and reads aGtkDropDownneeds the same shape; the flag alone is not enough.- The header bar's buttons are icon-only, so headless probes must reach them
through the
Apppointers (trigger_toggle,pause_button, …), not viafind_labelled_button(). The pane switches are toolbar buttons too (cursors_toggle,measures_toggle,math_toggle-- packed withheader_bar_pack_end(), where the first packed is the rightmost, and the bar is an ancestor rather than the parent because packing parents into an internal box), and the cursors pane owns its own Reset button rather than the overflow menu owning one for it. The dump printspanel controls: … ; toolbar, left to right: …to say where they are and in what order. The widget tree's child order is the visual order left to right, so that line reads as the reverse of the pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally settled the pack-order rule above (two comments inbuild_toolbar()disagreed with each other and one of them with reality): it is computed on the first tick, because the dump 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. - Six of this app's icons are its own, and they are a
GResourcecompiled 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.svgare the four action icons; beside them insrc/icons/scalable/apps/are the two application icons,org.udpscope.app.svg(128px colour artwork) andorg.udpscope.app-symbolic.svg(16px). Theappscontext 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 thatcmake/install_desktop_entry.cmakemirrors when it copies the same two files into$XDG_DATA_HOME/icons/hicolor/scalable/appsand.../symbolic/apps— those installed copies are what makesIcon=org.udpscope.appin the desktop entry resolve (a desktop shell cannot read a GResource compiled into the binary). All six are bundled bysrc/icons/udpscope.gresource.xml(CMake runsglib-compile-resources) and reached through onegtk_icon_theme_add_resource_path()inrun_ui(), right afteradw_init()— not at the top ofbuild_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) andrun_ui()is the one place ahead of both.adw_init()callsgtk_init(), which is what makesgdk_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
fillonpath,rectandcircle. It wraps the SVG in a stylesheet for those selectors, so astroke, alineor apolylinekeeps the colour written in the file and stays dark on a dark theme. In a symbolic icon everything is therefore a filledpath— 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.svgis 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.
- A malformed SVG is a build error, and that is the only place it can be caught. Every
<file>insrc/icons/udpscope.gresource.xmlcarriespreprocess="xml-stripblanks", which makesglib-compile-resourcespipe it throughxmllint— 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, sogtk_icon_theme_has_icon()returns true and the icon draws nothing.gtk_icon_theme_lookup_icon()cannot tell either (see theapp icons:line under Verification),GMarkupaccepts the double hyphen because it is a lenient subset parser, andgdk_pixbufgoes through glycin here, which sandboxes a subprocess per load and refuses this project's own icons outright. The cost is that thexmllintbinary 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 (seetoolbar icons:in probe-readings.md): a resource path that was never added does not raise an error, it drawsimage-missing.
- GTK's symbolic recolour only reaches
- A shortcut table row's
accelis GTK accelerator syntax, and a pointer gesture has none.src/shortcuts.cpp's strings are handed toAdwShortcutsItem, which parses them withgtk_accelerator_parse()— so they are"space","<Control>e","<Control>slash","plus minus"(a space-separated pair is the "either of these" form the label draws as+ / -), never the prose"Ctrl+E"or"+ / -". Neither GTK's syntax nor libadwaita's knows a wheel or a drag:"Wheel"costs aFailed to parsewarning and draws an empty cell. Those six rows therefore carryaccel = ""and put the prose insubtitle, which is the one field that is free text. A row added with a parse error is otherwise silent — the overview still opens and the row is simply blank — so read the probe run's stderr forFailed to parseafter touching either table. - The status bar is two tiers, and the dump reads the detail one.
format_glance()writes the bar andformat_detail()writes both the popover's prose andApp::status_detail, which is whatexit_probe_step3/exit_probe_step2copy intodbg_mode_live/dbg_mode_triggered. Those two used to read the label, and the label no longer carriespackets,gaps,plot 1:or the buffer figures — so agtk_label_get_text()there would silently reduce every documentedmode …:assertion to four fields. The glance line's state field is one word from a fixed set (connecting,live,paused,armed,capturing,event held,disarmed) for the layout reason above; a word wider than the widest already there, or a field whose width tracks the stream, breaks the row the plot is laid out under. The set may grow —connectingwas added for the no-CONFIG state — but only on that condition, and the check is the dump'smax_glance_chars.connectingis derived fromhave_configalone and sits ahead of every other branch, including the display mode's: with no signal list, every other word claims something that does not exist yet. Themodeprose branches the same way for the same reason, which is what keeps the two tiers from disagreeing. - The ⓘ popover's widgets are built once and only their text is set. It is visible when the
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
update_status_popover()only ever callsgtk_label_set_text(), andpopover_rebuildsin the dump is the assertion. - The About dialog, the help window and the shortcut overview are opened by
show_about(),show_help()andshow_shortcuts(), and each closes with the matching call. There is exactly oneGtkWindowamong the three — the paged help window. It closes withgtk_window_close(), which emitsclose-requestso 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 (anAdwAboutDialog) and the shortcut overview (anAdwShortcutsDialog) — areAdwDialogs and notGtkWindows: they close withadw_dialog_close()and clear on::closed, there being no::close-requeston anAdwDialog. Both share oneon_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. OneGtkWidget*(orAdwDialog*) per window, nulled in its own close handler, is the shape that prevents the double-open defect across all three. build_info.cppis generated, never committed.cmake/write_build_info.cmakeruns at configure time (soadd_executablecan name the file) and again on every build throughudpscope_build_info(so the commit and the timestamp belong to the binary that is running).gitabsent leaveskCommitat"unknown"and the build succeeds. The About dialog and the[help]probe both readkVersion,kCommitandkTimestampfrom it;settings=in the probe readsconfig_dir(), which is the path the session file is written to.- The keyboard zoom's modifiers are not the wheel's.
+/-with Ctrl restricts to x and with Alt to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The two cannot agree: Shift is unusable on the keyboard path becausekey_from()folds'+'and'='ontokKeyZoomIn—+is Shift+=on common layouts, so a bare zoom-in already arrives withMod::Shiftset. Ctrl+Alt together zooms neither axis, which needs no special case. The guards aredeps/plotter's ownctrl_plus_zooms_only_xandalt_plus_zooms_only_ytests, plusUDPSCOPE_PROBE_ZOOMKEYS_MS. - A help page is one
.txt, one<file>line in the GResource manifest, and onekPagesrow. The bodies are Pango markup in a second GResource (--c-name=udpscope_help, so its generated symbols cannot collide with the icons'), validated withpango_parse_markup()at load; a page that fails either lookup or parse renders a one-line stand-in and is excluded fromparsed.UDPSCOPE_PROBE_HELP_MS'sparsedmust equalpages. A page may also carry screenshots (HelpPage::images, a null-terminated list of resources). They areGtkPictures with a height request: one createdcan_shrinkasks 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 inGdkModifierType, so an unmasked compare matches no table row at all once Caps Lock is on.