With the axis titles gone the left gutter was 200 px of an 870 px viewport and the right
side had no margin at all, so the newest tick's label was drawn half outside the plot and
the newest sample's ink landed on the frame.
Two causes on the left. The reservation covered `format_si()`'s `%.3g` fallback
(`-1.23e-45`), which no signal on an `Si` axis produces -- the widest real label for a
unitless axis is `-1.23m` -- and `note_y_gutter()` added 6 px of slack to a figure that is
already the maximum of the labels and last frame's floor, making the gutter a ratchet that
climbed to its 200 px cap in ~19 frames. It settles at 104 px now, with `axes.w changes: 1`
where it read 17.
The right side did not exist: a plot's `axes` rect reached the viewport's right edge by
construction. `deps/plotter` gains `Plot::set_right_gutter()` and `Figure::set_right_gutter()`
-- a want rather than a measurement, since nothing is drawn there, imposed figure-wide so
the rows end on one column as they already start on one. udpscope asks for half the widest
time label plus the label padding (59 px), x labels being centred on their tick.
Separately: the exit dump asked two destroyed widgets for their size, which is where the
"exactly two Gtk-CRITICALs" baseline came from. The pointer is freed memory by then, so a
run that had destroyed other windows first (UDPSCOPE_PROBE_HELP_MS opens three) segfaulted
in the middle of the dump instead. Both sizes are captured on the tick; a clean run now
carries no critical.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A stream that answered CONNECT but has sent no CONFIG has no signal list, so
nothing can be enabled and no DATA frame is owed. Calling that "connected, but
no data is arriving: another reader may hold the source" blamed a second reader
that does not exist, and the badge blinked green over it -- a working pipeline
is exactly what it did not have.
The glance word is `connecting` (ten characters, no wider than `event held`,
so the fixed-set layout rule still holds) and the badge takes a fourth, steady
yellow phase, both from the first frame rather than after the three-second
stall test. The stall notice itself now splits on `have_config`: the unicast
contention wording where a signal list exists, "trying to connect" where it
does not, with `[udpscope] connecting:` on stderr beside the old `starved:`.
Three asked-for changes, one commit because they are one coordinate system.
The live axis is a fixed [-window, 0] with the newest sample at 0, so x reads
as an age and the labels stand still while the trace rolls under them. That
makes the display clock distinct from the stream clock for the first time, so
`ScopeModel::stream_at_x0()` becomes the single conversion between them
(`display_x = stream_t - stream_at_x0()` in all four modes) and a snapshot
carries its own origin in `EventSnapshot::x_offset`. The pause view is now
rebased like an event's, reversing an earlier deliberate choice: a frozen
absolute-stamped picture would sit a whole uptime right of a newest-relative
axis. That also fixed a latent bug, the export adding `trigger_time()` to a
*paused* view's stamps.
The status bar says how long you have been paused -- wall time, since the
producer never stops, through `format_elapsed()` so the field cannot widen.
The export writes two time columns: `time`, the axis as displayed, and
`abs_seconds`, the stream's own clock for the same sample. f64 seconds and
not the wire's integer, because `udps_frame_t::values` is a `const double*`
and the integer never exists in this process.
Cursors and measurement marks move in opposite directions under the rolling
axis, deliberately: a cursor is a position on screen and stays put, a mark
names a sample and travels with it. `App::measure_window` is stream-clocked
for a sharper reason -- a display-coordinate live window never moves, so the
"the window moved" recompute would never fire again.
Moves the deps/plotter pin to 58473db, which stops a time label's mantissa
reaching its own radix: a one-second tick differenced off a live axis read
`1e+3ms`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
gather() staged x as f32 relative to the series' own x0() -- the start of
the whole retained window -- so the quantisation was a fraction of the
ring rather than of the view: 1.9 us at 20 s of retained data, which at a
10 us view across a 716 px axis is over 100 px between where a sample is
drawn and the x a readout quotes for it (crosshair_point() reads x_at()
in exact f64). It halved on every zoom out, which is what made it read as
"zooming in makes the crosshair select a point beside the data".
The origin is now the caller's (plotter c2c8c59): the renderer passes the
visible range's minimum, so the error is a fixed 2^-24 of what is on
screen -- about 4e-5 px over a 700 px axis -- at any zoom. The parameter
sits before out_xy so every call site is a compile error rather than a
silent change of behaviour.
The renderer no longer reads series.x0() at paint time at all, which
removes two of the three symptoms of the torn window_x0_ read by
construction; the ink probe's now-vacuous kept_fixed_x0 walk goes with
them, replaced by a drawn-vs-quoted pixel figure. neighbours() still
tests the visible range against x_at(0), so the cache stays load-bearing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The caption that names the relative x labels' anchor was an `Annotation`, whose anchor
is a data coordinate -- so it crawled across the plot under a pan and drifted under a
zoom, which is how it was reported. It is now `Plot::set_x_note()`: the bottom row's
caption, drawn by the renderer in the axes' bottom-left corner in pixels, on the
legend's own backplate.
The text stays the host's, because only the host knows that label + caption has to be
the exact absolute time (`%.6g`, where `format_time()` would round to three figures).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A `t0 = ...` caption appeared on every sub-second window, including a
triggered one whose axis is already relative to the edge -- so the ticks
were re-anchored away from the one position a triggered axis exists to
show, and the caption had nothing to explain.
The form is now decided by the tick step against `TickFormat::Time`'s own
resolution at the axis' magnitude (three significant figures below a
minute, whole seconds through `m:ss` to a day, three figures of days
above). The span said nothing about that and was wrong both ways: a 2 s
window at twenty minutes of uptime reads `20:00` on every tick and stayed
absolute.
The step is read back from the last layout's majors (a tick value depends
on the range and the axis width, not on the label form); with no ticks yet
the form is left alone rather than guessed, since the hysteresis would
latch a guess. Entering the relative form carries a ULP of slack, because
`step == res` is readable and a bare comparison of two differently
computed doubles flipped the form between frames.
`[timelabels-probe]` and the dump gain `distinct=`, which asserts that no
two major labels read the same -- the thing the relative form is for, and
the only reading that holds whatever the stream's uptime is.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The SI prefix ladder in format_measure() was gated on a non-empty unit, so two
decimals of a small unitless value printed `0.00` -- the reading gone, not merely
coarse. That is a whole class of channels rather than a corner: a CONFIG need not
declare a unit, and a math channel never has one. Below 1e-12 the same loss
happened even with a unit, so that range falls back to three significant digits.
With no unit the prefix goes tight against the number (`585.24m`), since the space
exists to separate the number from its unit and a spaced `m` alone reads as metres.
The mock declared `unit="V"` for every data signal, which is why no probe could see
this; it now takes `--unit ''`.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous commit took "fit x sets the window properly" to mean a fit should
frame the data and propagate that as the new time scale. It should not: a fit is
a plot interaction, and the window is also the trigger's event span and how the
samples are stored, so a fit that framed the data could re-plan the rings and
drop the history on a menu click. What a fit means here is the undo of a zoom --
a 2 s window zoomed into reads 2 s again.
`fit_x()` therefore reads `window_s_` and never writes it. Live it takes the
window-control path with the window already set, which re-arms the follow and
parks the newest span at the live edge. Pinned -- a held event, a filling capture
or a paused view -- it restores the pin's full range, which is the snapshot's own
span and may be narrower than the requested window, and it writes the per-row
member axes as well as the link group: `hold_pinned_window()` prefers a member
that differs from `pinned_stated_`, so writing the group alone is reverted to the
zoomed range one frame later. `apply_window_scale()` is folded back into
`apply_time_window()`, which the window control is again the only caller of.
The probe now zooms in before fitting, since otherwise there is nothing to undo,
and reports `pinned=`. `window` and `event_span` must be unchanged on every line.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The time scale is more than the axis: it is also the trigger's event span, the
buffer partition and the control's readout. A zoom or a pan was already clean --
the controller writes the member axis and `clamp_to_data()` caps the span -- but
both fits moved `window_s_` alone and only restated the control. The event span
then stayed on the previous window until the next decade step or unit change
applied the fitted value for real, so one action changed the time scale twice.
`apply_window_scale()` is split out of `apply_time_window()` for the fits to use:
it moves the partition, the event span, the control and the session, and leaves
the view alone, because `set_time_window()` re-arms the live follow and would
throw a held event or a paused view back onto the stream. The fits report the
span they framed, `fit_all()` delegates its x half to `fit_x()` so the two cannot
drift, and the probe drives the menu handlers rather than `RowSet` so it sees the
half that lives there. `[fit-probe]` gained `event_span=`, which must equal
`window=` on every line.
Also moves the plotter pin to 03c41ad (three build warnings, no behaviour).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The value was already the nearest stored sample's, but every mark drawn for it was
placed at the *marker's* x with that sample's y -- so the dot the eye follows and the
number in the pane were a different datum whenever the marker fell between two samples.
Half a sample period is nothing across a whole window and the width of the panel once a
few samples span the axes, which is the only zoom at which anyone is asking which point
a number came from.
`place_crossings()` now goes through the plotter's new `Plot::crosshair_point()`, which
returns the quoted sample's x alongside its y -- one lookup for the mark and the number,
so they cannot name different samples. The dot consequently sits a little off its own
vertical cursor line: the line is where the marker is, the dot is the datum being quoted,
and between two samples those are two places.
Pin moves to the upstream commit that does the same for the library's own hover crosshair
and draws its mark larger than a trace's sample dots, and that keeps the old blend
reachable as `CrosshairValue::Interpolated` -- which udpscope never states.
Verified: plotter suite 370 cases 0 failed; `[cursors-probe]` lines=3 marks=6 with
`first mark at x=3.292400 y=-0.525175` against the pane's `-525.17 mV`; `[help]`
pages=14 parsed=14 closed ok.
The crosshair and the cursors pane's mix values are one library call, and it
interpolated linearly between the two samples bracketing the pointer. That is
wrong twice over here. Ingest decimation is a boxcar average over `time_stride`
samples, so a number computed between two stored samples is a second, undeclared
estimate laid over the first that nothing on screen or in an export holds. And
past the sample spacing the polyline is a line the renderer drew, so quoting a
point on it reports the drawing rather than the data.
The fix is upstream and unconditional (plotter c028f48): `crosshair_span()`'s `t`
selects the nearer end instead of blending, with a tie reading the earlier
sample. `CrosshairRow::marker` is placed from the same y, so the dot follows the
quoted sample.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The time ring was f32 like the values, holding each stamp relative to an epoch
fixed at the first sample ever seen -- so the number stored grew for as long as
the app ran while the interval it had to resolve stayed one sample period. At
1 MS/s the f32 ULP reaches the sample period after about sixteen seconds, so two
to four consecutive samples rounded onto one stamp after half a minute and eight
after a minute. On screen that is a plateau the LOD envelope draws as a
zero-width segment -- a vertical spike where the waveform should be -- and in an
export it was three distinct diffs at 2, 2.5 and 3 ULP instead of one clock.
A value's precision is a property of the signal; a timestamp's is a property of
how long the scope has been running. Only the clock widens: the values are the
memory budget. That makes the two slices different sizes per slot, so
plan_partition() weighs its growth loop in bytes rather than in slots (and
bytes_for() is what bounds the doubling); live_window_locked() loses the f32
round-trip of its bounds, which existed only to keep a sample sitting exactly on
one; and EventSnapshot::stamps is f64 because a pause view is not rebased and so
carries full stream time.
Verified against the fast mock: np.diff over two million exported rows now holds
exactly one step (1e-06, 1999999 times), no duplicate or backwards stamps, and
the 200 MB ring still spans 8.39 s. Every documented probe assertion unchanged.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A UDPS_TIME_FIRST_SAMPLE time signal carries one meaningful value -- the
batch's first sample -- but a scalar signal in Accumulate mode still occupies
num_samples slots on the wire, which a producer fills by repeating that
anchor. Deriving the step from value_of(n - 1) therefore came out as zero, so
every sample of a frame landed on one x, the degeneracy guard tripped, and the
whole stream was timed by packet arrival instead. On a 1 MS/s source that
arrival clock is not monotonic within a frame: the export's time column held
90559 order breaks.
Space the batch by the cadence, exactly as the LAST_SAMPLE branch beside it
already dates its own batch backwards. The export's time column is now
strictly increasing (0 duplicates, 0 backwards steps) and reads the declared
1 MS/s.
mock_udpstreamer gains --time-mode {full,first}; it could only emit
FULL_ARRAY, so nothing exercised this branch. In 'first' it repeats the anchor
across the batch's wire slots -- the shape that produced the defect -- while
still generating the waveforms from the real per-sample stamps.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CLAUDE.md: the libadwaita dependency paragraph now names both AdwDialog
widgets (AdwShortcutsDialog and AdwAboutDialog), explains the shared
on_dialog_closed() handler keyed by pointer-to-pointer, and covers
both show_about() and show_shortcuts() in the AdwDialog sentence.
- CLAUDE.md: the icon-authoring invariant's explanation of how Icon= in
the desktop entry resolves now names the installed copies under
$XDG_DATA_HOME/icons/hicolor/scalable/apps and .../symbolic/apps,
not the GResource (a desktop shell cannot read a compiled-in resource).
- CLAUDE.md: [help] probe example byte count corrected from 1381 to 1379
(measured: wc -c src/help/01-overview.txt = 1379).
- README.md + udpscope.gresource.xml: "file, line and column" corrected
to "file and line number (plus a caret under the offending character)"
to match xmllint's actual output; also removes -- from the XML comment
where it was illegal.
- README.md: desktop-file-validate described as a hard failure "when the
tool is on PATH", matching cmake/install_desktop_entry.cmake lines 44-54.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Records the app icons: assertion and why it stops short of claiming the icon
renders -- no runtime GTK call can tell a malformed SVG from a good one, which
is why the manifest validates through xmllint at build time instead. Scopes the
'filled paths only' authoring rule to symbolic icons, since the colour icon
strokes its sine and is never recoloured. Revises the windows invariant to one
GtkWindow and two AdwDialogs, and documents the desktop-entry target with the
build-path caveat it comes with.
Two findings from the regression run are recorded as well. The icons' generated
GResource crossed C99's 4095-byte string-literal limit when the app icons joined
the action ones, so the build's warning baseline is five, not four. And the
app icons: line's has_icon() half is satisfied by an installed theme copy, so on
a machine that has run --target desktop-entry it reads ok even with the resource
path broken; toolbar icons: is the sensitive line for that.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two code-review findings:
1. Add a guard in cmake/install_desktop_entry.cmake that fails visibly when both
XDG_DATA_HOME and HOME are unset or empty, preventing silent installation to
the filesystem root in minimal CI sandboxes.
2. Replace the plan's tail-based verification with grep, because cmake's
FATAL_ERROR message and ninja's "build stopped" lines push the actual
rejection message out of the last four lines. The grep command reliably finds
the expected "desktop-file-validate rejected" string.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The entry's Exec is the built binary's absolute path, so it always launches what
was last built -- and moving the build directory breaks it, which is the price
of having no install step. An install() rule would make a second copy that can
diverge; a documented cp would need the binary on PATH, which it is not.
The target writes the entry and both icons under XDG_DATA_HOME, refreshes the
desktop and icon caches when the tools are there, and runs
desktop-file-validate as a hard failure -- the shell ignores a malformed entry
in silence, so the failure belongs at the moment the entry is asked for. It
prints the three paths it wrote, which is the uninstall instructions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
AdwAboutDialog puts the application icon in its own header, which is what the
app now has one for, and adw_about_window_new() is deprecated in favour of it.
It is an AdwDialog and not a GtkWindow, so it clears on ::closed and closes with
adw_dialog_close() -- the same trap the shortcut overview documents, and both
dialogs now share one handler keyed by the pointer to clear.
Two gains while mapping the fields: system_information becomes debug_info, which
renders as a Troubleshooting page with a copy button, and the string behind it
becomes a std::string rather than a char[1024] that truncated a long settings
path in silence.
The probe now reads the icon name back out of the open dialog, so it is asserted
to carry the icon rather than merely to open.
The resource-path registration moves from build_toolbar() up into run_ui(),
because the window's icon is now a second consumer of it and this is the one
place ahead of both. Whichever consumer ran first would otherwise resolve its
name to nothing, with no error -- GTK draws a missing-image placeholder.
The new 'app icons:' dump line is the guard, computed on the first tick beside
'toolbar icons:'. It reports named-and-present at the size the file declares,
and deliberately does not claim the file renders: no runtime GTK call can see a
malformed SVG, which is why the manifest validates at build time instead.
Both are drawn to the GNOME HIG: a 128px instrument face that leaves its
margins, carries one 4px front profile for depth and no gradients or shadows,
and a 16px symbolic reduction of the same metaphor in fills only. The colour
icon strokes its sine because it is never recoloured; the symbolic one cannot.
Every file in the manifest now preprocesses through xmllint, because a
malformed SVG is the one icon failure no runtime check can see: librsvg refuses
the file while the name still resolves, so the icon draws nothing and
has_icon() says it is fine.
Five tasks, each ending in a commit: the two SVGs with build-time xmllint
validation, the window icon plus the registration move and the app icons:
assertion, the AdwAboutDialog migration, the opt-in desktop-entry target, and
the documentation with the full regression run.
Written assertion-first, since the project has no unit suite: each task's first
step adds the probe or dump line and watches it read MISSING. Both negative
tests are real -- one breaks an SVG comment and requires the build to fail, the
other breaks Type= and requires the target to FATAL_ERROR.
The spec asserted that a paintable's intrinsic size reads 0x0 for an SVG
librsvg refuses. It does not: gtk_icon_theme_lookup_icon() never returns NULL
and always reports the requested size, and a valid icon, a malformed one and an
absent name all snapshot to the same texture node. GMarkup accepts the double
hyphen, and gdk-pixbuf goes through glycin here and refuses our own icons.
So the malformed file is caught by preprocess="xml-stripblanks", which runs
xmllint during glib-compile-resources and fails the build with file, line and
column. The runtime line keeps its format but asserts only what it can: the
name resolves, the resource is present, and the root element declares the size.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Records the decisions taken before design — the application ID stays
org.udpscope.app, the icon is the instrument face, and the desktop entry is an
opt-in CMake target rather than an install() rule — along with the HIG
constraints the artwork is built to and the two silent failures the new
verification line has to tell apart.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The whole-branch review found README describing a toolbar "?" button that
does not exist, Ctrl+? for an overview bound to Ctrl+/, bare `e` for an
export bound to Ctrl+E, and a help window that opens "on the page nearest
to what the user was doing" when it unconditionally selects Overview. All
four would send a reader looking for something that is not there. The key
table also claimed to be full while omitting F1, Ctrl+/ and Ctrl+Q, and
did not say that `f` is the plot's own key rather than an app accelerator.
format_rate() is %.3g, so it prints "10 kS/s"; the two doc comments in
status_text.hpp and the example in help page 01 still said "10.0 kS/s" —
the same error already corrected in 14-status.txt and CLAUDE.md, left in
the header that declares the function and in a second user-facing page.
close_help_windows()'s comment described only gtk_window_close(), and
show_shortcuts()'s called the overview "hand-rolled"; the Adwaita rework
made one half-true and the other false.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds the four new features to README.md (About dialog, shortcut overview
with Ctrl/Alt key table and deviation note, 14-page help window, two-tier
status bar), adds the four new probes and six new invariants to CLAUDE.md,
and fixes the `10.0 kS/s` rate-format error in src/help/14-status.txt
(format_rate() uses %.3g, which prints `10 kS/s`).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The hand-rolled grid existed to avoid GTK's deprecated GtkShortcutsWindow;
libadwaita 1.8's AdwShortcutsDialog is the supported replacement, so use the
platform widget and get its search, sectioning and shortcut labels for free.
The table strings become GTK accelerator syntax, since the shortcut label parses
them. The six mouse rows cannot: no accelerator syntax names a wheel or a drag,
so they carry an empty accelerator and prose in the new subtitle field.
adw_init() is called in run_ui() and in choose_source() (replacing its
gtk_init()), which means the whole app is drawn with libadwaita's stylesheet.
Verified settled: viewport.w 870..870, axes.w changes 2, x-desync 0.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The pages are Pango markup in a GResource, bundled for the same reason the icons
are: udpscope runs straight out of the build tree with no install step, so a
runtime search path's failure mode would be a silently empty help window.
--c-name=udpscope_help keeps the two generated bundles from colliding.
Every page is validated with pango_parse_markup() at load and falls back to a
one-line stand-in rather than rendering raw markup or nothing. show_help() reuses
help_window.cpp's existing close-request handler, and closes via
gtk_window_close() so that handler runs and the pointer is nulled.
UDPSCOPE_PROBE_HELP_MS loads and validates every page, opens the help and About
windows, closes both and reports; parsed must equal pages.
The probe's doc-comment advised running it inside a pinned state "so the _held
readings mean the other axis did not move" — but the reads happen in the same
callback as the key events, so no frame can intervene and nothing can re-frame.
Worse, the advice hid the real reason the read is synchronous, which is the
opposite of what the pan and zoom probes do and so invites a future reader to
"fix" it: hold_pinned_window() clamps the x span into the pinned window, so a
reading taken a frame later would report that clamp's answer rather than the
restriction's for the ctrl case. Name the actual bug class a deferred read would
confuse it with, and point at the probe that does assert figure adoption.
std::fabs for the two span comparisons, matching the file's existing idiom —
<cstdlib> is also in scope here.
Eleven tasks over the approved spec: build info and About, the plotter's
ctrl/alt axis-restricted zoom, 14 help pages, the accelerator table and
overview window, the app key controller, and the two-tier status bar.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Four features the user asked for together: an About dialog (version, build
timestamp, settings path), a shortcut overview covering the keyboard and the
mouse, a fourteen-page help window, and a status bar split into a short glance
line plus a details popover so the plot's own row stops being crowded.
Records one deviation from the approved design: keyboard zoom-x/zoom-y use
ctrl/alt rather than shift, because `+` *is* shift+`=` on the common layouts and
a bare zoom-in therefore already arrives with Shift set.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The lifetime fix in ffec797 removes the UDPSCOPE_PROBE_PICK_MS timeout after
the nested loop returns, so a row picked before the delay elapsed cannot leave
an armed source pointing at a gone stack frame. But the probe returns
G_SOURCE_REMOVE on every path, so on the ordinary run -- the probe fires, picks
row 0 and the loop quits -- the id was already stale and the removal printed
"Source ID N was not found when attempting to remove it" on every probe run.
The id moves onto the Chooser and pick_probe clears it on entry, so the
cancellation applies only to a probe that never fired.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Seven correctness and documentation fixes found in the final review:
- `known_sources()`: check the strtol end pointer and add a round-trip
guard so a filename that layout_path() could not have produced is
rejected; without it `foo_8000x.toml` listed as `foo:8000` whose `x`
would unlink `foo_8000.toml`, a different configured session.
- `chooser_finish()`: destroy the form window before the chooser window;
a row click or Escape while the form was up left a window whose handlers
pointed at a dead stack frame (latent use-after-free).
- `choose_source()`: keep the pick_probe timeout id and g_source_remove
it after the loop; a row picked before the delay elapsed left an armed
source on the main context pointing at the gone frame.
- `chooser_populate()`: replace a false comment ("not a GtkListBoxRow")
with the truth — GTK4 auto-wraps, but the wrapped row carries no
source-index data so the bounds check refuses it.
- `pick_probe()`: collapse the duplicate `known.empty()` / `row==nullptr`
guards; the second was unreachable and two identical lines for one cause
makes probe output ambiguous.
- `save_layout()`: state the TOML rule directly in the multicast/interface
comment rather than cross-referencing the adjacent enabled comment.
- `save_layout_now()`: refuse to write when signals() is empty; a run
against a dead port receives no CONFIG, so every slot name is empty and
the save would overwrite a configured session with signals=["",...],
which the chooser filter refuses, silently removing that source from
the only list it can be picked from.
- Fix include order in layout_store.cpp (<cstdio> before <cstdlib>).
- Update CLAUDE.md and README.md to document the empty-signals guard and
fix the probe-output quote to match the emitted text exactly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Server, port, multicast group and interface, and nothing else: --buffer-mb and
friends are tuning rather than which source, and a form that asks for them is
one a user has to read first. The form's widgets live on the chooser, which
outlives them, so no close path frees a live context.
One row per stored session, titled by its server -- the host the file is named
after and the one CONFIG comes from -- with the group as subtitle. A forget
repopulates rather than removing one row, because the rows carry their index
into the list and dropping one renumbers the rest.
The chooser runs before ScopeModel exists, so declining costs no buffer and no
socket. UDPSCOPE_PROBE_SOURCES=1 prints what the discovery found and exits,
before gtk_init(), so the list is checkable with no display.
collect_layout() now reports the multicast group and interface the app was
launched with, so a stored session says how to reach its source and not just
which host and port it was.
known_sources() reads the session files the layout store already owns, keeping
only those that name a signal: a development machine accumulates one file per
probe run on a throwaway port, and all 140 of them here hold empty slots.
A session is named <host>_<port>.toml, which cannot say whether that source
was multicast or which interface it joined on, so a chooser row could not
reconnect to one. Both go in the top block: a bare key after a [[table]]
header belongs to that table, which is how the enabled key was lost once.