19 Commits
Author SHA1 Message Date
Martino Ferrari cc8335c514 ui: each pane carries its own switch, the same one the header bar has
The trigger pane and the cursors pane now have a `GtkSwitch` in their heading, and it is *one control in
two places* with the header bar's button: both run the same action (`run_app_action()`), and
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change, so clicking either
end moves both. A pane whose feature is off is disabled, which is exactly the state you need the switch
in, so it belongs *in* the pane rather than only in the toolbar.

Three things it needed:

- **The sensitive target moved to the pane's body.** A control inside an insensitive parent cannot be
  clicked, so a switch that greyed out with its own pane could never turn it back on. `on_trigger_toggled`
  and `on_cursors_toggled` now disable `trigger_body` / `cursors_body`, and so does the startup default;
  the headings, with their switches, stay live.
- **The trigger's heading moved out of `trigger-panel.ui`** and is built in `build_trigger_panel()`,
  which returns the heading row and the panel together. It has to be a sibling of the body for the switch
  to stay usable, and keeping it in the file would have meant the file owning a widget the code must
  arrange. The file's own panel is unchanged otherwise, so the `[trigger panel:]` counts still read.
- **A guard on the sync.** Setting a `GtkSwitch` emits `notify::active` for a programmatic write just as
  it does for a click, so without `App::panel_switch_sync` the program's own write would come back as a
  click and flip the feature again.

Readings, both directions in one line:

    trigger panel sensitive before the probe toggled it: 0; … ; pane switches live while their panes are off=1
    panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1  (pane switch / header bar button)

`pane switches live …=1` is the design's point and is read before anything turns either pane on; `0` would
be a switch drawn, greyed and useless. The trigger pair is moved from the *toolbar* end and the cursors
pair from the *pane* end, so the line carries both halves of the sync, and `drove_toolbar=1` is the pane's
own switch having turned the cursors on with the button following. `trigger panel sensitive …=0` is the
body, not the panel.

Verified: the two lines above; `[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon. Docs: the new reading in `probe-readings.md` with what a `0` means,
the invariants bullet for the one-control-in-two-places rule and its two traps, the sidebar bullet's
sensitive target, and the README note on why the switch is in the pane.
2026-10-14 14:13:49 +02:00
Martino Ferrari d9287ea826 ui: the trigger's wave is a full period again
The bold pass had turned the trigger into half a period — a signal rising above its level — and that
lost the thing the glyph is for: the crossings. A crest and a trough read as a wave; a hump reads as a
hill, and a trigger that shows no crossing names the "above the level" half of an event. The full-period
sine with the dashed level through it is restored, exactly as it was, and the Signals pill carries the
same wave again so the pill and the toolbar button stay one picture.

The Measure glyph keeps the bold redesign, which is the part that was better: two cursors (a cap over two
4px dashes) beside a ruler of two bands with three 2x4 marks and no end caps. The Signals pill also keeps
its two bold channel rows.

Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`;
the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero
`Gtk-CRITICAL`. The icons' blob is 12352 bytes, still over the C99 limit, so the clean-build warning count
stays four.
2026-10-14 14:00:38 +02:00
Martino Ferrari 8c9c6ff7fd ui: bolder tab glyphs, and the trigger shares the Signals pill's picture
The previous pair were detailed miniatures — 1px ticks, 2px dashes, a 13-column wave — and at 32x16 that
reads as *texture* rather than as a shape, which is what "not clear" looks like. All three glyphs are
redrawn bold: one thick band, two thick dashes, few features.

- **Measure** is two cursors beside the ruler they read against. Each cursor is a cap over two bold
  dashes (4px), where three 2px dashes read as a dotted column; the ruler is two bands with three 2x4
  marks between them and **no end caps**, because a closed frame reads as a rectangle and costs a fifth
  of the ink on two lines that say nothing. This is the layout whose first attempt looked like two icons
  in a box — the difference is entirely the weight and the open ends.
- **Signals** is a signal rising above its level beside two bold channel rows. Half a period instead of
  a full one: at this size a full period is a zigzag and a hump is unmistakably a wave, and the level's
  marks survive on both sides of it instead of being swallowed by the crest.
- **The trigger's own icon** is now the same picture as the Signals pill, so the pill and the button that
  turns the trigger on are recognisably about the same thing.

Each was rendered and read back as an ink map at 1:1 and at 2x before it was installed, and three
candidates were compared for the Measure glyph (markers *on* a scale, cursors *over* a ruler, and this):
the first two turned the marks and the shafts into blobs at the crossings. Ink is 18%, 30% and 29%,
inside the range the icons above them set, and everything stays inset two pixels.

Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`;
the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero
`Gtk-CRITICAL`. The icons' blob is 11774 bytes, still over the C99 limit, so the clean-build warning
count stays four.
2026-10-14 13:58:36 +02:00
Martino Ferrari 574f47f769 ui: the Measure pill is two cursors on a scale, not a boxed ruler beside them
The previous Measure glyph was two icons sharing a box: a heavy closed ruler frame on the right and
two light caps on the left, with nothing relating them. It is now one instrument — two cursors
*standing on* the scale they read against, each marker centred on one of the scale's marks, so the
pair reads the way the pane works: bracket a window, read it against the axis.

Three things the render said, each fixed:

- **The closed frame was most of the ink.** Two lines and two end caps cost about a fifth of the
  glyph's ink and say nothing a ruler's marks do not, so the scale is now one band with alternating
  marks — long, short, long — hanging above it.
- **The caps were dashes, not grips.** 5x2 at 32px read as a horizontal stroke; they are 5x3, like the
  caps on the toolbar's cursors icon.
- **The ticks were finer than everything else.** At 1px they were thinner than the 2px weight every
  icon here uses, so they are 2px, and every mark alternates in height the way a ruler's do.

The Markers' shafts are dashed and meet the band rather than hovering over it, and the whole glyph is
inset two pixels like the others, so nothing is flush with an edge that a button's padding or a crop
takes a pixel off. Ink: 27%, inside the range the icons above it set.

Verified: `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`; `[side-tab-probe] … advanced=4`;
the Preferences dialog opened in the same run with no "not present" (the plain ruler it uses is untouched);
`make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' blob is 12624 bytes, still over the C99 limit, so
the clean-build warning count stays four.
2026-10-14 13:48:55 +02:00
Martino Ferrari 75aa62cc3c ui: the trigger glyph is a sine crossing its level, and every glyph is inset
The trigger's toolbar icon was a rising edge, which names only one of the four shape triggers the panel
offers. It is now a sine with a dashed level through it — the trigger's actual job, find the signal where
it crosses this — and the Signals tab's pill carries the *same* picture beside the channel list, so the
pill and the button that turns the trigger on read as the same idea. Both are one period of one-pixel
stairs, the way the plot rasterises its own traces.

Everything is inset two pixels now. The previous drafts ran their level and their bars flush to the cell
edge, which is what makes art look shaved wherever a button's padding or a crop takes a pixel: nothing in
either tab glyph, or in the trigger's, touches an edge.

Each shape was drawn and then read back as an ink map rather than trusted — the first sine came out as a
thin antialiased diagonal (a polygon through whole-pixel vertices has diagonal *edges*), which is why the
band is now per-column stairs; the level-through-the-centre arrangement was chosen over level-above-the-
centre after rendering both, since at these sizes a level nearer the crest merges with it into a blob. The
four glyphs measure 18%, 22%, 25% and 25% ink, inside the range the icons above them set.

Verified: `toolbar icons: trigger=udpscope-trigger-symbolic(ok)` and
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`;
`[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero `Gtk-CRITICAL` and no missing icon. The
icons' GResource blob is 12408 bytes — the SVG sources are embedded, comments included — still over the
C99 limit, so the clean-build warning count stays four.
2026-10-14 13:45:57 +02:00
Martino Ferrari dbe6377415 ui: the tab glyphs are 32x16 pairs, drawn twice as wide
Each tab's icon is now two motifs side by side instead of one in a 16px square: the Measure pill is the
two cursors beside the ruler, the Signals pill the trigger beside three channel rows. A 16px cell carries
one motif — squeezing a pair into it is what turned both into a smear — and the pill has the width for a
pair in a 300px column with two pills in it.

Two measurements decided the size, both of which I had wrong from the outside:

- **GDK paints an icon into a square box whatever the document's aspect.** `gtk_icon_theme_lookup_icon()`
  at 32 reports a `32x32` paintable for a 32x16 document, and librsvg fits it: rendered with aspect kept,
  the artwork comes out `32x16` inside a 32 request. So the pair is letterboxed, not stretched, and
  `pixel_size` 32 draws it at exactly the size it is written — 1:1, so the 2px bars stay crisp. A
  fractional scale (24 or 48) is what would blur them, which is why nothing here is drawn at one.
- **The bar does not get taller for it.** `switcher: … icon=32x32 size=308x36`: 36px is what the switcher
  measured with 16px icons too, because the pill's fixed height takes a 32px box without growing.

Both glyphs were drawn and then read back as ink maps rather than trusted: the first drafts came out at
39% and 44% ink (a blot beside the house 25–36%), and the tab bar is the last place a shape can be
checked, since no screenshot works under this X setup. The finished two measure 23% and 33%.

Verified: `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36`;
`page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`[side-tab-probe] … advanced=4`; the Preferences dialog opened in the same run with no "not present";
`make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' GResource blob is 11661 bytes, still over the
C99 limit, so the clean-build warning count stays four.
2026-10-14 13:40:09 +02:00
Martino Ferrari e615465366 ui: each tab's glyph is a pair, not one icon
The Measure tab is now `udpscope-tab-measure-symbolic` — the two cursors standing across a ruler — and
the Signals tab is `udpscope-tab-signals-symbolic` — the trigger's rising edge over the channel list it
acts on. Each is a 16px composite rather than one of the icons the toolbar carries: a glyph the toolbar
already uses for one thing means two things the moment a tab shows it too, which is why the ruler alone
and the trigger alone were both wrong here.

The plain ruler stays exactly where it was. `udpscope-measure-symbolic` is the Preferences window's Plot
page icon and is untouched; the tab's pair is a new file beside it, so nothing that already meant "the
measurement ruler" changed meaning. Seven action icons bundled, nine with the two app icons.

Both composites are at the limit of what 16px holds — four features each — so each was drawn and then
*measured*: rasterized and read back as an ink map, since the shapes have to be checked as pixels and the
house range is 25-36% ink (trigger 25, cursors 27, ruler 36). The Signals list is two rows and not three,
so the edge and the list get half the cell each; its bars are all one length, because unequal ones read as
a bar chart and a chart is not a list. The Measure ruler's ends are open on purpose: a closed frame puts
four more pixels of ink at the left and right edges, which is what turns a ruler into a rectangle at this
size. First drafts came out at 39% and 44% ink and were rebalanced to 32% and 36%.

Verified: `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`;
`switcher: policy=narrow pills=2 labels=0 names=2`; `[side-tab-probe] … advanced=4` (switching by page
name still works); the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14
files; zero `Gtk-CRITICAL`. The icons' GResource blob is now 11309 bytes, still over C99's 4095-byte
string-literal limit, so the clean-build warning count is still four.
2026-10-14 13:33:24 +02:00
Martino Ferrari 0b146f1d53 ui: the tabs are icons, and the pills keep their names
Each sidebar page gets its own glyph and no visible text. The Measure tab reuses the ruler
(`udpscope-measure-symbolic`, the icon its deleted toolbar toggle used and the Plot preferences page
still carries); the Signals tab gets a new `udpscope-signals-symbolic` — three rows of a colour chip
and a name, which is what that tab mostly is — rather than a second copy of the trigger glyph, since
one shape meaning two things in one window is worse than a new one. Its bars are all one length: a
set of unequal ones reads as a bar chart, and a chart is not a list.

`AdwViewSwitcherPolicy.narrow` alone does **not** give an icon-only bar. Measured on libadwaita 1.10
by walking the switcher's tree: with `narrow` and a title, each pill holds a `GtkImage` *and* a
`GtkLabel` — 48px of "Measure" inside a 54px pill. So the pages' `title` is **empty** (their `name` is
what identifies them to `select_side_tab()` and to the session file, and both are unchanged) and the
words are re-attached in code: `name_side_tabs()` runs on the first tick — the pills are
`AdwViewSwitcher`'s own and exist only once it is mapped — and gives each pill a **tooltip and an
accessible name** from the page's name, then hides the empty label that would otherwise leave the
icon off-centre. An icon-only pill is still a named control for a screen reader, and the page icon
that does not resolve leaves a *blank* pill, which is why it is read like the toolbar's icons are.

Two readings, because the claim is two claims:

    switcher: policy=narrow pills=2 labels=0 names=2
    page icons: measure=udpscope-measure-symbolic(ok) signals=udpscope-signals-symbolic(ok)

`labels` counts *mapped labels with text* — a plain mapped count reads 2 either way, because the
narrow pill keeps an empty label in its layout — and `pills=2` is the guard that makes it mean
something: with nothing mapped the rest of the line is void rather than a pass.

Docs: the icon census (six action icons, eight bundled, and what each of the two new users is), the
switcher bullet in `invariants-gtk-and-chrome.md` with the measurement above, the two new dump lines
in `probe-readings.md`, the re-measured GResource figures in `verification.md` (icons 8613 -> 9842
bytes, help 514660 after this session's help rewrite; the count stays four), and the README recording
that the design spec had the switcher showing titles and neither page an icon.

Verified: `make test-ui` 14 files; `[side-tab-probe] … advanced=4` (tab switching by name still
works with empty titles); `signals panel: … divider=1`; `[math-probe] 3 math channels`; zero
`Gtk-CRITICAL` and no missing icon.
2026-10-14 13:29:11 +02:00
Martino Ferrari f9cea4885b fix: an add or a delete outlives the list it rebuilds under itself
The `+` lives *inside* the signal list now, and both handlers it reaches rebuild that list:
`add_math_channel()` and `apply_math_channels()` → `rebuild_signal_panels()` destroys every row,
the divider's button and the row's menu popover with it. GLib holds a reference for the duration
of one emission, but a button's own code keeps running *after* the handler returns -- the same
last-reference hazard `on_signal_switch()` already repairs, and the same repair is used here:
hold one reference on the emitting widget and drop it from an idle.

Found by driving the three new controls the way a user reaches them, which the plan left to a
hand check:

    [side-list] divider + found=1
    [side-list] add: math 0 -> 1 editor_open=1
    [side-list] math row tick found=1 name='math1'
    [side-list] tick off: App::math enabled=0 (want 0)
    [side-list] math row Delete found=1
    [side-list] delete: math 0 (want 0)

So the `+` creates a channel *and* opens the editor on it, a math row carries its own tick, that
tick reaches `App::math` -- the copy the session file is written from -- and Delete removes the
channel, with the popover dismissed first so nothing points at the row that is going away. The
probe that measured this was a throwaway and is not in the tree.
2026-10-14 12:59:21 +02:00
Martino Ferrari 6492bb910f test: a probe reads the hidden tab's accumulator, and the docs follow the tabs
`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the design's §5 rule -- the statistics
accumulator keeps running on the hidden page -- which nothing tested before. It switches to
Measure, forces one pass so there is a baseline, switches away to Signals and reads again two
seconds later: `advanced=4` is the assertion, and `advanced=0` is the defect where the
accumulation is gated on the visible tab, which would leave a gap in min/max/avg for exactly as
long as the trigger was on screen.

Two things the probe has to do for itself, and getting either wrong makes `advanced=0` a lie
about the tab:

- it **enables its own signal**, because a signal starts disabled (enabling is what starts
  acquisition) and a probe that waited for one to be on would be reading another probe's side
  effects -- and `UDPSCOPE_ENABLE_ALL_MS` is not part of this run;
- it **assigns that channel to a plot**, because a channel with no trace leaves the live window
  *still*, and a still window is precisely the state `update_measures()` is supposed to skip.
  Measured the hard way: without the assignment the probe read `advanced=0 min_n=0` with the view
  pinned and only four passes in twelve seconds, none of them the tab's doing.

Docs: `probe-readings.md` gains the probe's entry and the signals panel's new shape
(`rows == signals + 1 + math`, `divider=0` a list that lost its `+`), loses the whole `math
panel:` entry, and follows `pane_sensitive=`, the gone `revealed=` field, the six toolbar
controls and the tab-reading `m`/`g` key pairs. `verification.md` gains the probe's bullet and
the re-measured warning figures. The invariants record the sidebar as a switcher above a stack
with a scroller per page, the hidden-page trap that replaces the unrevealed-revealer one for it,
that a feature's on/off is sensitivity rather than visibility, that a math channel is a row of
the signal list, and that a cursors-off readout blanks to `--` with the panel left on screen.
`shortcuts.ui` and the help page's window and sidebar bullets stop describing five revealers.
The README records the deviation from `SPECS.md` that names the panes as separate collapsible
sections.

Verified: `[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=16`;
`signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1`; `panels: … (page_current=1)
(sensitive=0) switcher=1`; `toolbar icons:` six controls with every name `(ok)`, with the
Preferences dialog opened in the same run; zero `Gtk-CRITICAL` and no missing icon.
2026-10-14 12:56:43 +02:00
Martino Ferrari cb866774c6 chore: drop the math icon, which lost its last user with the toggles
`udpscope-math-symbolic` was the toolbar's math-pane toggle, and that toggle is gone. Nothing
else names it: the census after the change is **five** action icons
(`udpscope-{trigger,cursors,measure,events,resize-rows}-symbolic.svg`) plus the two application
icons, seven bundled.

`udpscope-measure-symbolic` **stays**, and the plan's premise ("both lost their last user") is
wrong for it: `src/ui/preferences.ui` carries it as the Plot preferences page's icon -- a live
user, unrelated to the toggle that introduced it. Deleting it would have shipped a page whose
icon silently fails to resolve (GTK substitutes a placeholder and says nothing), so it stays and
the doc's census names it as the Preferences icon rather than as a toolbar one.

The warning baseline is unchanged: **four**, because the icons' generated GResource blob is
**8613 bytes** against C99's 4095-byte string-literal limit with the five action icons and the
two app icons -- removing one file did not put it back under, and neither figure moved on a
clean build. Measured by touching the two `*.gresource.xml` files and rebuilding, which
regenerates both blobs without one.

Verified: `make test-ui` 14 files; a run with the Preferences dialog opened resolves every icon
(`toolbar icons:` all `(ok)`, zero `Gtk-CRITICAL`, no "not present").
2026-10-14 12:56:36 +02:00
Martino Ferrari a493737ca1 ui: one channel list, and the math pane is gone
`rebuild_signal_list()` is handed `model.signals()`, and `append_math_signals_locked()` already
appends one synthetic entry per math channel -- "they go through the ordinary signal path on
purpose: a derived channel is a channel" -- so **every math channel was already a row of the
signal list**, with its tick, chip, name, unit and menu. The math pane listed the same channels a
second time, so this is mostly a deletion: `build_math_panel()`, `rebuild_math_pane()`,
`src/ui/math-panel.ui` and its resource entry, `App::math_list`, and the `math panel:` dump line.

What the one list gains:

- a non-selectable `MATH` divider before the first math row, carrying the `+` that creates a
  channel -- appended even when there are no math channels at all, because a `+` conditional on
  one existing could never create the first one;
- a math row's own menu: *Edit graph…*, *Default colour*, *Delete*. No Information and no
  Calibrate (calibration is applied at ingest, and a derived channel has no stream to describe),
  and no rename, because the node editor owns a channel's name and unit -- a second place to
  change them would be a second answer to the same question. An item dismisses the row's popover
  before opening or destroying anything from it;
- `App::math` mirrored from a math row's tick: `enable_locked()` mirrors it into the model, but
  `App::math` is the copy the session file is written from. The mirror sits *before* the
  `apply_math_channels()` in the `!on` path, which would otherwise push a stale `enabled = true`
  straight back into the model;
- a tick visible in a file view too: a derived channel is computed, and a file view has no
  acquisition to start, which is the other tick's question.

`rebuild_signal_list()` also publishes the list's own shape -- `rows`, `signals`, `math` -- and
the dump prints `divider=1` when `rows == signals + 1 + math`: the reading for a list that lost
its `+`.

Verified: `make test-ui` 14 files (was 15); `signals panel: labels=2 list=1 rows=9 signals=4
math=4 divider=1`; `[math-probe] 3 math channels`, every other line unchanged; no `math panel:`
line, and zero `Gtk-CRITICAL`.
2026-10-14 12:56:31 +02:00
Martino FerrariandClaude Sonnet 4.6 132041f79e feat: the session file remembers which sidebar tab was showing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 12:09:21 +02:00
Martino Ferrari d2ec89d866 docs: task 7 owns the shortcut window and the overview page
Task 1's review found two user-facing files that no task owned: src/ui/shortcuts.ui
still describes four accelerators as showing and hiding panes, and
src/help/01-overview.txt still lists the five-revealer column. Both were made false
by the tabbed sidebar and neither is caught by a probe, so give them an owner.
2026-10-14 12:05:33 +02:00
Martino FerrariandClaude Opus 4.6 619b5e356c fix: five review findings on the tabbed-sidebar commit
Finding 2 — four builder ids had no null check. `side_stack`,
`side_switcher`, `measure_page` and `signals_page` were resolved at
lines 4709-4712, well past the skeleton null-guard. A typo in the `.ui`
would have been a silent segfault.  The four resolutions are moved to
the guard block and added to its condition, in the same form the guard
already uses.

Finding 3 — the 'editable focused: m refused' leg of keys_probe was
deleted instead of ported.  It is restored, adapted for the new
behaviour: 'm' now selects the Measure tab instead of toggling a pane,
so the refusal is observed as the side_tab name being unchanged across
the synthesised key, not as a toggle that did not flip.  The dump line
keeps its existing name and `refused=` field so probe-readings.md:601
still describes it.

Finding 4 — `exit_probe_step3` assigned `sidebar_shown` to both
`dbg_side_visible` and `dbg_side_column_after_hide`, and the panels:
dump line read the wrong one (dbg_side_visible) for its `(visible=)`
field.  Two readings that cannot disagree diagnose nothing.  Resolved
by making `panels:` read `dbg_side_column_after_hide` and removing the
redundant `dbg_side_visible` field.

Finding 5 — the three-line rationale comment above `reload_file_view`
in `apply_math_channels` was removed by the commit under review.
Nothing it said became untrue.  Restored verbatim from e10a92f.

Finding 6 — three comments the change made false:
- `on_activate`: 'the sidebar's scroller' replaced with 'the sidebar's
  tab switcher and two-page stack', matching the actual ui file.
- `update_side_visibility`: 'Hiding the scroller instead left an empty
  column where the panels should be' removed; the design it described
  no longer exists.
- `header-bar.ui`: 'two of them are toggles' updated to 'one of them
  is a toggle'; after signal_toggle's deletion the overflow menu holds
  one toggle (layout).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 11:45:00 +02:00
Martino FerrariandClaude Opus 4.6 f224f56c4c fix: hold the check-button reference across the signal-panel rebuild
`gtk_check_button_set_active` emits both `notify::active` and `toggled`
in sequence.  GLib takes a per-emission reference, so it protects the
instance only through the end of `notify::active` — but our
`on_signal_switch` handler calls `rebuild_signal_panels`, which removes
every row from `signal_list`.  The row that contained the emitting check
button is destroyed, dropping its reference.  After `notify::active`
finishes, GLib unrefs the instance: with no external holder left that
is refcount 0, the instance is finalised.  The subsequent `toggled`
emission then fires into freed memory and produces two
`GLib-GObject-CRITICAL` diagnostics.

The fix: take a `g_object_ref` at the top of the handler and release it
from a `g_idle_add_full` destroy function.  The idle fires after
`set_active` has returned and all its emissions are complete, so the
instance lives until the emission chain is fully done.  The rebuild
itself stays synchronous — it has run before the next frame reads the
list — and the behaviour the handler delivers is unchanged.

This is a latent bug predating Task 1.  Previously the window's focus
reference happened to land on the row's `GtkListBoxRow` and kept the
row alive as a side effect; the new `AdwViewSwitcher` takes initial
focus instead, so nothing holds that reference any more.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 11:44:40 +02:00
Martino FerrariandClaude Sonnet 4.6 44d2fd35dc ui: the sidebar is two tabs, and a toggle disables its pane
Replace the sidebar's five-panel scrolling column with an AdwViewStack
carrying two pages — Measure (cursors + measurements) and Signals
(trigger + math + channel list) — with an AdwViewSwitcher above it. The
trigger and cursors toggles change from "reveal/hide my pane" to
"enable/disable my feature, leaving the pane insensitive", following the
disable-not-hide principle. The keys probe loses a duplicate registration
that was toggling the sidebar back on before the exit probe captured its
state; the exit-probe captures now read adw_overlay_split_view_get_show_
sidebar() instead of gtk_widget_get_visible(), which AdwOverlaySplitView
never changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-14 11:11:21 +02:00
Martino FerrariandClaude Opus 4.6 e10a92fc52 docs: correct the sidebar spec, and plan its implementation
The spec claimed the math pane and the signal list were two lists of the same
channels; they are one. `append_math_signals_locked()` already puts every math
channel into `signals()` and nothing filters `sig.math`, so the merge is mostly
a deletion -- the list gains a `MATH` divider and a second menu, and the pane
goes. §2, §3 and §6 say so now.

The plan is seven tasks, each ending in a build, `make test-ui` and one short
headless probe read.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 10:48:21 +02:00
Martino FerrariandClaude Opus 4.6 a519e13b67 docs: design a tabbed side panel with one channel list
Five revealers in one column leave every section short of room once the
trigger, cursors, measurements and math panes are open. The design splits
the column into two tabs, folds the math list into the signal list -- the
two were the same list drawn twice -- and makes a feature's toggle disable
its pane rather than hide it, because a tab cannot carry an on/off.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-14 10:23:19 +02:00
33 changed files with 2796 additions and 531 deletions

No files matched your search

+19
View File
@@ -2388,6 +2388,25 @@ now — they are listed so they are not mistaken for anything else:
progress. What the spec was protecting still holds — a *held* event is bound to its
snapshot alone, and the axis is the event's own window from the edge onwards rather
than a sliding live view.
- **The sidebar is two tabs, and a math channel is a row of the signal list.** `SPECS.md`
names the panes as separate collapsible sections — trigger, cursors, measurements, math,
signal list — in one scrolling column; with four of them open in a 320 px sidebar every
section was short of room, and finding one meant scrolling past the others. The built
sidebar is a **Measure** tab (the cursors readout and the measurements) and a **Signals**
tab (the trigger and the channels), **each a pill with its own glyph and no text** — the
cursors and the ruler for Measure, a sine crossing its trigger level and the channel list
for Signals (the same picture the toolbar's trigger button carries), drawn as a
32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and
accessible name. Each pane's heading also carries its **own switch**, the same control as the
header bar's button — clicking either moves both — because a pane that is *disabled* while its
feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The
design spec had the switcher showing *titles* and neither page an
`icon-name`; `narrow` alone does not do it (libadwaita still draws a label beside the icon,
measured), so the pages carry an empty title and the pills are named in code. With the math
list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in
the model, so the two lists were the same list drawn twice. A tab is one-of-*N*, so no
toggle hides a pane any more: trigger-off and cursors-off **disable** their pane and leave
it in place, and the measurements' and math panes' toolbar toggles are gone with the panes.
- **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot`
cannot give up a trace and the slots are therefore created up front.
- **One toggle drives both cursor orientations.** The vertical markers and the
+63 -48
View File
@@ -49,58 +49,40 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
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.
(Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list
is a *tab* of the sidebar, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text.
- **Seven 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,events}-symbolic.svg`
are the five 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. **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.
* **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:` in [probe-readings.md](probe-readings.md)): a resource path that was never added does not raise an
error, it draws `image-missing`.
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,tab-measure,tab-signals}-symbolic.svg`
are the seven action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it --
an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is
the Preferences window's icon, not the tab's.
The two **tabs** got glyphs of their own, and each is a *pair* rather than one of the icons above.
`udpscope-tab-measure-symbolic` is two cursors beside the ruler they read against — each a cap over two
bold dashes, beside a ruler of two bands with three marks between them and **no end caps** (a closed
frame reads as a rectangle, and costs a fifth of the ink on two lines that say nothing) — and
`udpscope-tab-signals-symbolic` is a sine crossing its dashed level, beside two bold channel rows. They
are not reuses, because a glyph the toolbar already carries means two things the moment a tab shows it
too; instead the *trigger's* own icon (`udpscope-trigger-symbolic`) was redrawn to the Signals pill's
picture, so the pill and the button that turns the trigger on are recognisably about the same thing.
The wave is a **full period and not a hump**: a crest and a trough are what make it read as a wave
rather than as a hill, and the level's dashes survive between the crossings. (A hump was tried and
reverted — it named the "above the level" half of a trigger and lost the crossings, which is the half
that matters.)
Both are **32x16** composites drawn **bold** — one thick band, two thick dashes, few features — because
at this size a thin wave or a 1px tick reads as *texture* rather than as a shape, which is what "not
clear" looks like. Wider than tall on purpose: a 16px square cell carries one motif, not a pair. And
**GDK paints an icon into a square box whatever the document's aspect** (measured:
`gtk_icon_theme_lookup_icon()` at 32 reports `32x32`, and librsvg fits it to `32x16`), so the pair is
letterboxed rather than stretched — and the pill's image is given `pixel_size` 32 in `name_side_tabs()`,
which draws the artwork at the size it is written, so its bars stay crisp. Every glyph here is
**inset two pixels**, because art flush with the cell edge is what a button's padding or a crop takes a
pixel off, and a fractional scale is what would blur it, which is why nothing is drawn at 24 or 48. The
two tab glyphs measure 29% and 30% ink, inside the range the icons above them set.
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
@@ -179,6 +161,39 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not
take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane
*is* open. The allocated width is how this was found -- the revealer's own measure said 0.
**The sidebar is a tab stack now, and has no `GtkRevealer` left in it**: an `AdwViewSwitcher`
above an `AdwViewStack`, each page a `GtkScrolledWindow` of its own (the two pages are different
heights, so one shared scroller would leave a switch scrolled where the new page never asked to
be). The reason is the one this trap teaches: five sections competing for one 320 px column left
every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the
trigger's and the cursors' toggles set their pane's **body** insensitive, so the pane stays where it
is and the settings you are about to switch back on do not vanish while you look for them. The *body*
and not the pane, because each pane's heading carries its own switch (see the next bullet). The
trap itself does not go away with the widget: a stack's hidden page measures 0x0 too and is still
given the leftover width, so **ask about state, not about size** carries over unchanged. Every
allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not*
current -- a change in what the reading means, not a defect, and no functional code may depend on
those numbers.
- **`AdwViewSwitcherPolicy.narrow` still draws a label; an *empty title* is what makes a pill an icon.**
Measured on libadwaita 1.10: with `narrow` and a title, the pill holds a `GtkImage` **and** a
`GtkLabel` — 48px of "Measure" inside a 54px pill — so the policy alone does not give an icon-only tab
bar. The sidebar's pages therefore have **no title** (their `name` is what identifies them to
`select_side_tab()` and the session file) and the pills are *named in code* instead: the first tick
calls `name_side_tabs()`, which walks the switcher's children — the pills are there in page order, and
`AdwViewSwitcherButton` is not a public type, so there is no type check to make — and sets each pill's
**tooltip and accessible name** from the page's name, then hides the empty label that would otherwise
leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and
`switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once.
- **A pane's heading switch and the header bar's button are one control in two places.** Each pane's
heading (`build_trigger_panel()` builds the trigger's, `cursors-panel.ui` has the cursors' in its
`head`) carries a `GtkSwitch` that runs the *same action* the toolbar button runs, and
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change — so clicking
either end moves both, and `panel switches: trigger=1/1 cursors=1/1` is the reading. Two traps:
a `GtkSwitch` emits `notify::active` for a *programmatic* write as well, so the sync holds a guard
(`App::panel_switch_sync`) or the program's own write comes back as a click; and the switcher must
stay *out* of what the pane disables, because a control inside an insensitive parent cannot be
clicked — a switch that greyed out with its own pane could never turn it back on. That is why the
sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane.
- **A list box outlives the children of the pane it sits in, and the teardown order is not yours to
choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the
list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four
+11
View File
@@ -301,6 +301,17 @@ with at most the transient a re-start costs. The exit dump's `math '<name>': fin
the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero
`hole_slots` is this defect returned.
- **A math channel is a row of the *signal* list, under a `MATH` divider, and there is no math pane.**
`append_math_signals_locked()` already puts every channel in `signals()`, so the pane was the same
list drawn twice; the divider is what the pane uniquely contributed. It is non-selectable and
non-activatable -- a heading that highlighted under the pointer would read as a row you can plot --
and it carries the `+` that creates a channel, appended even when there are none, because a `+` that
waited for a channel to exist could never create the first one. A math row's menu is *Edit graph…*,
*Default colour*, *Delete*: no Information and no Calibrate, because calibration is applied at
ingest and a derived channel has no stream to describe, and no rename, because the node editor owns
a channel's name and unit and a second place to change them would be a second answer to the same
question. A row's tick is the channel's own switch and must reach `App::math` -- the copy the
session file is written from -- or the list and the file disagree about what "enabled" was.
- **A graph *edit* does not move the plan; adding or removing a channel does.** The plan is a partition over
the channels, so `set_math_channels()` takes a surgical path whenever the *set* is unchanged -- same channels,
names and enables: the store is never touched, every other channel keeps its samples **and** its runtimes, and
@@ -54,6 +54,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
that range falls back to `%.3g`.
- **With the instruments off the readout blanks, and the panel stays on screen.** Cursors-off makes the
pane *insensitive* rather than hiding it -- a panel you can still see is a panel you can read, and the
settings you are about to switch back on must not vanish while you look for them -- and every cell in
it is written `"--"`, the spelling an unavailable cell already uses. A readout quotes a stored sample;
with nothing bracketing one there is no sample to quote, and keeping the last values would be quoting
a sample the cursors no longer name. Two spellings of one state in one grid would be the defect.
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
+89 -24
View File
@@ -161,10 +161,12 @@ Two lines of the self-check output are assertions in disguise:
*adoption* half -- a gesture that does move a cursor, and the shared value reaching every row
-- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a
synthetic press here cannot reach the adapter's own event pump.)
- `[cursors-probe] <action> mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]`
- `[cursors-probe] <action> mode=.. on=D pane_sensitive=D t0=.. t1=.. dt=.. T=[..] lines=N [..]`
(stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` →
`mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is
what a drag does) and reports again. **The times must survive every mode change**: they
what a drag does) and reports again. `pane_sensitive` is the cursors *panel body's* sensitivity, not a
revealer's child: with the cursors off the panel stays exactly where it is and its cells blank to `--`,
because a readout whose instruments are off has no sample to quote. **The times must survive every mode change**: they
are the `RowSet`'s one pair, and a mode that parks the markers must not read them back
(a parked cursor reports a real number, so a read-back would adopt it as a gesture — the
bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for
@@ -195,7 +197,7 @@ Two lines of the self-check output are assertions in disguise:
numbers and the plot's state agreeing is the difference between "the app placed them" and
"the app placed them and they are on screen".
- `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..]
[duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W
[duty=..] [marks=..]` and `measures pane: n=N marks_shown=M pane_rows=R pane_w=W
pane_h=H switch_on=X reset_sensitive=Y`
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
plottable signal and lets the tick compute them, so what it prints is what a user reads.
@@ -345,9 +347,9 @@ Two lines of the self-check output are assertions in disguise:
and an expanding one is measured into the title's centre box, so it stretched from the
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
grows with the window width is that bug back.
- `toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... sidebar=...
menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button
carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Four
- `toolbar icons: trigger=NAME(ok) events=... pause=... cursors=... sidebar=... menu=...` (in the
exit dump, beside `panel controls:`) — the icon name every toolbar button
carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Three
of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource`
compiled into the binary — see the toolbar-icon invariant in
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)), and a `(MISSING)` on one of
@@ -355,6 +357,27 @@ 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.
- `switcher: policy=narrow pills=2 labels=0 names=2 icon=32x32 size=308x36` (in the exit dump, beside
`panels:`) — the two
sidebar tabs as the switcher actually draws them. **`pills=2 labels=0`** is the claim: two pills are on
screen and **no text is drawn** (`labels` counts *mapped labels with text*, because `narrow` keeps an
empty label in the pill's layout and a plain mapped count would read 2 either way). `names=2` is the
other half — both pills carry a tooltip and an accessible name set by `name_side_tabs()`, so an
icon-only pill is not an anonymous button — and `pills=0` means nothing is mapped, which makes the
rest of the line meaningless rather than a pass. `policy=narrow` is read rather than assumed. `icon=`
is the box the pills' `GtkImage` is *rendered* in, which proves the 32px override stuck: the tab
glyphs are 32x16 documents, and GDK paints an icon into a square box whatever the document's aspect,
so the artwork is drawn 32x16 at 1:1 inside that 32x32 box — letterboxed, not stretched, which is what
keeps its 2px bars crisp. It reads `0x0` on the first tick, where nothing has been allocated yet, and
is therefore read by the exit probe with the rest of the sidebar's geometry. `size=` is the bar's own
size, and it is the answer to "what did the wider glyphs cost": `36` is what it was with 16px icons,
because the pill's fixed height takes a 32px box without growing — the glyph gets twice the width for
no vertical room at all.
- `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)` (in the exit
dump, beside `toolbar icons:`) — each tab's own glyph and `gtk_icon_theme_has_icon()`'s verdict, read
on the first tick for the same reason the toolbar's are. **Both must read `(ok)`**: the page titles are
empty on purpose, so an icon that does not resolve leaves a *blank* pill, and a blank pill looks
exactly like a page with nothing in it.
- `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
@@ -598,7 +621,7 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
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).
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. 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).
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
- `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N
open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` (stderr,
@@ -1116,6 +1139,29 @@ library measures and draws the rect in tests of its own.
defect this reading exists for, and it was invisible from the frame alone: the file view pinning its
axis from the ring each frame is what swallowed the gesture.
## `panel switches:` and the trigger panel's default sensitivity
```
[udpscope] trigger panel sensitive before the probe toggled it: 0; signal list items=1 selected=0; column visible after sidebar toggle off=1; pane switches live while their panes are off=1
[udpscope] panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1 (pane switch / header bar button; `drove_toolbar` is the pane's own switch having moved the button)
```
Each pane's heading carries its own switch, and it is the *same* control as the header bar's button:
both run the same action and both are re-read from the toolbar's state afterwards, so they cannot
disagree. The two lines are the claim, in both directions:
- **`pane switches live while their panes are off=1`** — read before anything turns either on, because
it is the whole point of putting the switches in the headings: a control inside an insensitive parent
cannot be clicked, so a switch that went insensitive *with* its own pane could never turn it back on.
**`0` is that defect**: the switch is drawn, greyed, and useless.
- **`trigger=` and `cursors=` are `pane switch / header bar button`**, and the pairs must agree. The
trigger's is moved from the *toolbar* end (these steps toggle that button to arm the trigger) and the
cursors' from the *pane* end, so one line carries both directions of the sync. **`drove_toolbar=1`**
is the second direction's assertion: the pane's own switch turned the cursors on and the header bar's
button followed.
- `trigger panel sensitive …=0` is the *panel body*, not the panel: the heading row, with its switch,
stays live. `1` here while trigger mode is off is the pane not being disabled at all.
## `[sidebar-probe]`
```
@@ -1144,6 +1190,30 @@ and `regained` reads 400 ms later.
- `collapsed=1` means the window is narrower than `kSidebarRoom` (900), which is the expected reading on a
scaled display, where the logical width is half what the screen looks like.
## `[side-tab-probe]`
```
[side-tab-probe] page=signals accums=4 baseline recorded
[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=18
```
`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the rule that **the statistics accumulator keeps running
on the hidden page**. It switches *to* the Measure tab, forces one pass so there is a baseline, then switches
away to Signals and reads again two seconds later -- with the measurements' page not current, so its widgets
are unrealized and measure 0x0.
- **`advanced=4` is the assertion**: every accumulator gained passes while its page was off screen, and
`min_n` (the smallest `n` seen) must be non-zero too. **`advanced=0` is the defect**: something gated the
accumulation on the visible tab, so the readout would gain a gap in its min/max/avg for exactly as long as
the trigger was on screen. A `Gtk-CRITICAL` here is the other half of the same rule -- a label or an
allocation read on the unrealized page.
- The probe enables a signal **and assigns it to a plot** itself, both deliberately. A signal starts disabled
(enabling is what starts acquisition), and a channel with no trace leaves the live window *still* -- and a
still window is the state `update_measures()` is *supposed* to skip, so a probe that left it that way would
read `advanced=0` for a reason that has nothing to do with the tab.
- `rows` is the measurement count and `accums` the accumulator count; the two must agree, because one
accumulator per measurement row is what makes `advanced` a statement about every row.
## `[prefs-probe]`
```
@@ -1252,25 +1322,20 @@ the two lines together read what the file gave and what the code put in it.
## `signals panel:`
```
[udpscope] signals panel: labels=2 list=1
[udpscope] signals panel: labels=2 list=1 rows=5 signals=4 math=0 divider=1
[udpscope] signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1
```
The signals panel's frame read from the window: the heading and the hint the `.ui` gives it, and the list
the rows go in. `labels=1` is a `.ui` that lost one of the two, `list=0` one that lost the list -- both of
which otherwise show as a panel quietly missing a piece. `panels: … signals=WxH row_h=26` is the other half:
the list's own geometry and the height a row really takes, which is what the compact-row CSS rule is for.
## `math panel:`
```
[udpscope] math panel: head=2 buttons=1 list=1 title='Math channels'
```
The math panel's frame read from the window: the head row the `.ui` gives it (the title and the add
button), and the list the channel rows go in. `buttons=0` is a `.ui` that lost the `+` -- a panel that
cannot add a channel, with nothing else complaining -- and `title=` is the file's own text read back, so a
`.ui` that lost the label shows as `title='-'`. `[math-probe]` is the other half of the reading: it drives
the add and the editor through the same code a user does.
The signals panel's frame read from the window, and in the same line the *shape* of the one channel list:
the heading and the hint the `.ui` gives it, the list the rows go in, how many rows that list holds
(`rows`), how many of them are streamed signals (`signals`), how many math channels the app has (`math`),
and `divider`, which is 1 when `rows == signals + 1 + math` and 0 otherwise. `labels=1` is a `.ui` that lost
one of the two labels, `list=0` one that lost the list -- both of which otherwise show as a panel quietly
missing a piece -- and **`divider=0` is the merged list that lost its `+`**, the one control that can create
a math channel. The first line above is a run with no math channels at all: the divider is there anyway,
because a `+` that only appeared once a channel existed could never create the first one. `panels: …
signals=WxH row_h=26` is the other half: the list's own geometry and the height a row really takes, which
is what the compact-row CSS rule is for.
## `events pane:`
+12 -3
View File
@@ -15,9 +15,12 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
Two baselines every run is read against. A clean build emits **four** warnings and no more: two
`-Wmissing-field-initializers` on `MathNode::source` 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). The fifth warning that used to appear here was an unused
generated GResource `.c` files — the help pages' (**514660** bytes) and the icons', whose blob is
**12352** against C99's 4095-byte string-literal limit with the seven action icons and the two app icons
that remain (a note about a generated file, which GCC itself supports) -- re-measured when
the tab work changed the set (that icon left, the two tab glyphs arrived, and this file's
help page was rewritten), which did not move the count; re-measure by touching each `*.gresource.xml`
under `src/` and rebuilding. The fifth warning that used to appear here was an unused
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
`output_after_add` already printed on the same line — and that deletion is what dropped the baseline
from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at
@@ -205,6 +208,12 @@ precedent, not an exception.
pauses, reports again with `dn=` per measurement, then resets and reports a third time. Three
phases: `phase=0`, `phase=1` (after pause) and `phase=2` (after Reset). Needs
`UDPSCOPE_ENABLE_ALL_MS` ahead of it and `UDPSCOPE_PROBE_MEASURE_MS` to add measurements first.
- `UDPSCOPE_PROBE_SIDE_TAB_MS=3000` with `UDPSCOPE_EXIT_AFTER_MS=11000` — the measurements on the
Measure tab, a forced pass, then the tab switched away and read again two seconds later. **`advanced=4`
is the assertion** (every accumulator gained passes while its page was off screen); `advanced=0` is the
defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for
exactly as long as the trigger was on screen. It enables *and plots* its own signal, so it needs no other
probe ahead of it. The reading's own entry is in `probe-readings.md`.
- `UDPSCOPE_PROBE_SEARCH_MS` — runs a buffer search for the armed condition, cross-checks the live
trigger's own `t0` against the nearest hit (`agree=1` is the load-bearing reading), and jumps to
hit 0. Reports the resulting `mode=`, `axis=` and `marks placed=`.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,199 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
# A tabbed side panel, and one channel list: design
The side panel stacks five sections in one scrolling column — trigger, cursors, measurements,
math, signals — each behind a `GtkRevealer` opened by its own toolbar toggle. With trigger,
measurements, cursors and math all open the column is unusable: every section is short of room,
and finding one means scrolling past three others.
Two changes, aimed at that one problem:
1. **The column becomes two tabs.** *Measure* holds the cursors readout and the measurements;
*Signals* holds the trigger and the channel list. One tab is on screen at a time, so no
section competes with four others for height.
2. **The math list is folded into the signal list.** A math channel already *is* a channel in
the model, and the two lists were the same list drawn twice. One list, with a `MATH`
divider in it.
A third change falls out of the first. A tab is one-of-N, so it can no longer carry an on/off:
**no toggle hides a pane any more.** Trigger-off and cursors-off put their pane in a disabled
state and leave it where it is.
## The decisions taken, and what each one rules out
| Decision | Alternative rejected |
| --- | --- |
| `AdwViewStack` + `AdwViewSwitcher` | `GtkStack` + `GtkStackSwitcher`: plain buttons beside an `AdwOverlaySplitView`, which is what the sidebar already is. `GtkNotebook`: draws its own tab strip, which costs width in a 320 px sidebar and reads as off-platform. libadwaita ≥ 1.8 is already a hard requirement (`CMakeLists.txt:22`), so the platform widget costs nothing new |
| **A scroller per page**, inside the stack | One shared scroller around the stack: the two pages are different heights, so a tab switch would leave the scroll position somewhere the new page did not ask for |
| The switcher sits **above** the scrolled area | Inside it: the one control that chooses what you are looking at must not be the first thing to scroll away |
| **Two** tabs, exactly as grouped | A tab per section (five tabs): that is the stack again, laid sideways, and a 320 px sidebar has no room for five switcher pills. Measurements read what the cursors bracket, and the trigger picks a channel from the list beside it — the grouping follows the way the panes are used |
| Nothing inside a tab collapses | Keeping a revealer per section inside the tab, or a `GtkPaned` between them: the first reintroduces exactly the state-management the tabs remove, two sections at a time instead of five; the second adds a divider position to persist per tab for a column that already scrolls |
| Trigger-off and cursors-off **disable** their pane | Hiding it, which is today's behaviour: a pane you can see is a pane you can read, and the settings you are about to turn back on should not vanish while you look for them |
| The `measurements` and `math` toolbar toggles are **deleted** | Keeping them as tab selectors: two buttons that select the same tab, whose pressed state duplicates the switcher's |
| The switcher shows **titles only**, and the two icons the deleted toggles used are **removed** | Giving each page an `icon-name`: the measurements icon would carry over, but *Signals* has no glyph — reusing the trigger icon for a page that is mostly a channel list misnames it, and drawing a new one is new artwork under the icon authoring rules for a label two words long |
| One row widget for both kinds of channel, differing **only in its menu** | Inline edit and delete buttons on math rows (today's math pane): math rows then carry visibly more controls than signal rows, and the list stops reading as one column |
| A `+` on the `MATH` divider **only** | A `+` on the signals header too: a signal comes from CONFIG and cannot be added, so the button would have nothing to do |
| The statistics accumulator keeps running on the **hidden** page | Gating `update_measures()` on the visible tab: the accumulator is history, so min/max/avg would gain a gap for exactly as long as you looked at the trigger |
| The probe reads the **page name and sensitivity** | Dropping the two revealer readings: the defect they catch — a panel that is built but not on screen — still exists, it is just spelled differently |
## 1. The container
The sidebar child of `side_view` becomes a vertical `GtkBox`:
```
AdwOverlaySplitView.side_view
sidebar: GtkBox (vertical)
AdwViewSwitcher <- never scrolls
AdwViewStack
page "measure" GtkScrolledWindow -> GtkBox: cursors, separator, measurements
page "signals" GtkScrolledWindow -> GtkBox: trigger, separator, signal list
```
`side_scroll` moves inside the pages; `App::side_box` becomes the stack, and the five revealer
fields in `ui_app.hpp` are removed with the code that sets them (`ui.cpp:4728-4767`).
The switcher carries the two page titles and no icons. `udpscope-measure-symbolic` and
`udpscope-math-symbolic` lose their last user with the toggles that used them, so both leave
`src/icons/scalable/actions/` and `udpscope.gresource.xml`: the bundled set goes from six
action icons to four, which `invariants-gtk-and-chrome.md` counts and must be corrected with
the change.
The pages are built by the functions that build them today — `build_trigger_panel()`,
`build_cursors_panel()`, `build_measures_panel()`, `build_signal_panel()` — unchanged except
that they are appended to a page's box instead of to a revealer.
## 2. State instead of visibility
| Control | Today | After |
| --- | --- | --- |
| `trigger_toggle` | trigger mode on/off **and** reveals the trigger pane (`ui_trigger.cpp:198`) | trigger mode on/off; the pane's body goes sensitive/insensitive |
| `cursors_toggle` | cursors on/off **and** reveals the readout (`ui_cursors.cpp:124-126`) | cursors on/off; the readout goes insensitive and its values blank to `—` |
| `cursors_reset` | hidden while cursors are off (`ui_cursors.cpp:122`) | insensitive while cursors are off |
| `measures_toggle` | reveals the measurements (`ui.cpp:4541-4550`) | **deleted** |
| `math_toggle` | reveals the math pane (`ui.cpp:4551-4558`) | **deleted** |
| `sidebar_toggle`, `events_toggle`, `pause_button` | — | unchanged |
The two deleted toggles leave `src/ui/header-bar.ui` with their handlers, so the bar is
declared with five controls and no id resolves to a button nothing listens to.
`update_side_visibility()` (`ui.cpp:2830-2850`) keeps its job — the whole sidebar is still the
split view's to show, still gated on a CONFIG having named a signal — minus the line that showed
`signal_toggle`, which no longer exists.
Everything else those handlers do is untouched: `tc.armed`, the epoch rule and the
`set_time_window()` call in `on_trigger_toggled()`, `sync_events_availability()`, the
Arm/Disarm label, and `rows->set_cursors_visible()` with its `reset_cursors()`.
Blanking the cursor readout to `—` rather than leaving the last values is the readout invariant
restated: a readout quotes a stored sample, and with the instruments off there is no sample it
quotes.
## 3. One channel list
**The duplication is literal, and the merge is mostly a deletion.**
`rebuild_signal_list()` is handed `model.signals()`, and `append_math_signals_locked()`
(`model.cpp:252-269`) has already appended one synthetic entry per math channel — *"they go
through the ordinary signal path on purpose: a derived channel is a channel"*, says its own
comment. Nothing in the list filters `sig.math`, so **every math channel is already a row in
the signal list**, with its tick, chip, name, unit and menu. The math pane listed the same
channels a second time.
What the list gains:
- a non-selectable `MATH` divider row before the first math row, carrying the `+` that opens
the node editor on a new channel;
- two items in a math row's menu.
| | signal row | math row |
| --- | --- | --- |
| tick | acquire (hidden in a file view) | enabled |
| colour chip | clickable while plotted | unchanged |
| unit | from CONFIG | the channel's own |
| menu | Information, Default colour, Calibrate… | **Edit graph…**, Default colour, **Delete** |
Information and Calibrate… are a *streamed* signal's — calibration is applied at ingest — so a
math row carries neither. No rename item either: the node editor owns a math channel's name and
unit (`src/ui/math-editor.ui:37-43`), so *Edit graph…* is where both are changed, and a second
place to change them would be a second answer to the same question.
Two things already work and must not be rebuilt: the tick is already the right switch —
`enable_locked()` (`model.cpp:626-639`) mirrors it into the `MathChannel`'s own `enabled` flag —
and the row's `GtkDragSource` already lets a math channel be dropped onto a plot row, which is
how `math1` reached row 0 in the session this work started from.
One defect to fix while merging: `App::math` is the copy the session file is written from, so a
tick on a math row must mirror `enabled` into it before `schedule_layout_save()`. Today the pane's
checkbox did that (`ui.cpp:2276`) and the list's tick did not, so the two controls disagreed
about what gets saved.
Deleted: `build_math_panel()` (`ui_math.cpp:1044`), `rebuild_math_pane()` (`ui.cpp:2251-2319`),
`src/ui/math-panel.ui` and its entry in `src/ui/udpscope-ui.gresource.xml`, `App::math_revealer`,
`App::math_list`, `App::math_toggle`, `App::measures_toggle`, `App::signal_revealer`, and
`App::signal_toggle` with `on_signals_toggled()` (`ui_signals.cpp:303-307`) and its overflow-menu
row (`ui.cpp:4452-4455`) — the list is a page now, so "show or hide the signal list" has nothing
to hide. The node editor dialog itself is untouched; only the button that opens it moves.
## 4. Persistence
One key, top level in the session file beside `window_s` and `measure_stats`
(`layout_store.cpp`):
```toml
side_tab = "signals" # or "measure"
```
Written on save; applied once where the rest of the layout is applied, so an unknown or missing
value leaves the stack on its first page. Nothing else about the panel is persisted today and
nothing else needs to be: no revealer states remain, and the 320 px width is still the `.ui`'s.
## 5. What runs on a hidden page
`update_cursors_pane()` and `update_measures(force=false, accumulate=true)` are called every
frame from the tick (`ui.cpp:3275-3276`) and keep being called whichever page is current.
Writing a label on an unrealized widget is legal; **reading its allocation is not**. Every
allocation read in the sidebar is a debug capture — `dbg_measures_pane_w/h`
(`ui.cpp:3237-3239`), `dbg_signal_w/h` and `dbg_trigger_w/h` (`ui_probe.cpp:77-80`) — and each
reads 0 on the page that is not current. That is a change in what the reading means, which §6
handles; it is not a defect, and no functional code may start depending on those numbers.
This is the revealer trap in a new widget. `invariants-gtk-and-chrome.md:174-181` records that
an unrevealed `GtkRevealer` measures 0×0 and is still given the leftover width; a stack's
hidden page measures 0×0 too, and the rule it teaches — ask about *state*, not about size —
carries over unchanged.
## 6. Verification
Readings that change, and what each must say instead:
| Reading | Was | Becomes |
| --- | --- | --- |
| `dbg_signal_revealed` (`ui_probe.cpp:90`) | the signal revealer's child is revealed | the `signals` page is current **and** its list is non-empty |
| `dbg_trigger_revealed` (`ui_probe.cpp:92`, `:2190`) | the trigger revealer's child is revealed | the `signals` page is current **and** the trigger body is sensitive |
| `dbg_measures_pane_w/h` | the pane's size while revealed | the pane's size while its page is current; 0 otherwise, and read as such |
| the panel-toggle reading (`ui_probe.cpp:104`) | both menu toggles are visible | the switcher is visible, which is the one control those two became |
One new probe, `UDPSCOPE_PROBE_SIDE_TAB_MS`: select the page that is not current, then dump the
page name, the measurement row count and the accumulator's sample count. The count must have
advanced across the switch — that is the §5 rule under test, and the defect it names is a
statistics pane that silently restarts its history every time you look at the trigger.
New reading for the merged list, on the existing signal-panel probe: the row count must be
signals + 1 + math channels, and the math rows must carry the same widget shape as a signal
row. A count short by the divider is a list that lost its `+`.
Baselines, unchanged and re-read on every run: exactly four warnings on a clean build, no
`Gtk-CRITICAL`, `test-ui` 15/15.
Docs to update with the work: `probe-readings.md` (the three changed lines and the two new
ones), `verification.md` (a bullet for the tab switch), `invariants-gtk-and-chrome.md` (the
sidebar is a view stack, the hidden-page trap replaces the unrevealed-revealer one for it, the
bar carries five controls and the bundled set four action icons),
`invariants-math-channels.md` (the list lives in the signal panel), `invariants-readouts-and-cursors.md`
(the readout blanks when the instruments are off), and a README note recording the deviation
from `SPECS.md` where it names the panes.
## Out of scope
The events pane left of the plot, export, the node editor's internals, and the status bar.
+7 -6
View File
@@ -31,13 +31,14 @@ 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.
pause, the events, cursors and side-panel switches, 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 side panel</b> -- two tabs. <b>Measure</b> holds the cursors readout and the
measurements derived from it; <b>Signals</b> holds the trigger and the channel list, where a
math channel is a row of that one list under a <tt>MATH</tt> heading whose <tt>+</tt> creates
one. One row per channel: its colour, an enable switch, its name and its unit. Channels 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).
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A sigma: the one pane that computes rather than reads. Four filled subpaths, all
wound clockwise so the default nonzero rule unions them where they overlap; the
diagonals run from the bars' left ends to a vertex right of centre, which is the
way the glyph actually points. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M3 2 H13 V4 H3 Z
M3 12 H13 V14 H3 Z
M4.64 2.23 L10.64 7.23 L9.36 8.77 L3.36 3.77 Z
M10.64 8.77 L4.64 13.77 L3.36 12.23 L9.36 7.23 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 762 B

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The Measure tab: two cursors beside the ruler they read against. Each cursor is a cap over two
bold dashes — the mark the toolbar's cursors button carries, at a weight that survives this size.
The ruler is two bands with three marks between them and *no end caps*: a closed frame reads as a
rectangle, and it costs a fifth of the ink on two lines that say nothing. Both motifs are centred
on the same line and share one weight, and everything is inset two pixels. -->
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M2 2 H7 V5 H2 Z
M3 6 H6 V10 H3 Z
M3 11 H6 V14 H3 Z
M9 2 H14 V5 H9 Z
M10 6 H13 V10 H10 Z
M10 11 H13 V14 H10 Z
M17 4 H30 V6 H17 Z
M17 10 H30 V12 H17 Z
M18 6 H20 V10 H18 Z
M22 6 H24 V10 H22 Z
M26 6 H28 V10 H26 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- The Signals tab: the toolbar's trigger picture — a sine crossing its dashed level — beside the
channel list. Sharing that picture is the point: the pill and the button that turns the trigger on
should be recognisably about the same thing. A full period and not a hump: the crest and the trough
are what make it read as a wave rather than as a hill, and the level's dashes survive between the
crossings. Two channel rows on the right, drawn bold like the wave, and everything inset two
pixels, because art flush with the cell edge is what a button or a crop clips. -->
<svg height="16px" width="32px" viewBox="0 0 32 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M2 6 H3 V10 H2 Z
M3 4 H4 V8 H3 Z
M4 3 H5 V7 H4 Z
M5 2 H6 V6 H5 Z
M6 3 H7 V7 H6 Z
M7 4 H8 V8 H7 Z
M8 6 H9 V10 H8 Z
M9 8 H10 V12 H9 Z
M10 9 H11 V13 H10 Z
M11 10 H12 V14 H11 Z
M12 9 H13 V13 H12 Z
M13 8 H14 V12 H13 Z
M14 6 H15 V10 H14 Z
M2 7 H5 V9 H2 Z
M7 7 H10 V9 H7 Z
M12 7 H15 V9 H12 Z
M18 3 H23 V8 H18 Z
M24 4 H30 V7 H24 Z
M18 10 H23 V15 H18 Z
M24 11 H30 V14 H24 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,16 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- A rising edge crossing a dashed trigger level: what the trigger *is*. The dash
rhythm is 2 on, 1 off (tight enough to read as one line rather than as a row of
dots), with the one wide gap at x 5..9 that the edge rises through. Every
coordinate is a whole pixel of the 16px cell so nothing blurs at 1x. -->
<!-- The trigger: a sine and the dashed level it is looked for at, which is what a trigger *is* on a
waveform that is not a step. It used to be a rising edge, and one edge names only one of the four
shape triggers the panel offers — a wave crossing a level names the trigger's job instead. One
period, one-pixel stairs, the way the plot rasterises its own traces, with the level through the
centre so the crest and the trough stand clear and the crossings fall on the steep parts. Inset
two pixels, like the tabs' glyphs, so no art is flush with an edge a button might clip. -->
<svg height="16px" width="16px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="#2e3436" d="M0 11 H8 V13 H0 Z
M6 3 H8 V13 H6 Z
M6 3 H16 V5 H6 Z
M0 7 H2 V9 H0 Z
M3 7 H5 V9 H3 Z
M9 7 H11 V9 H9 Z
M12 7 H14 V9 H12 Z"/>
<path fill="#2e3436" d="M2 7 H3 V10 H2 Z
M3 5 H4 V8 H3 Z
M4 3 H5 V6 H4 Z
M5 3 H6 V6 H5 Z
M6 4 H7 V7 H6 Z
M7 6 H8 V9 H7 Z
M8 8 H9 V11 H8 Z
M9 10 H10 V13 H9 Z
M10 11 H11 V14 H10 Z
M11 11 H12 V14 H11 Z
M12 9 H13 V12 H12 Z
M13 7 H14 V10 H13 Z
M2 7 H5 V9 H2 Z
M7 7 H10 V9 H7 Z
M11 7 H14 V9 H11 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 857 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+2 -1
View File
@@ -20,7 +20,8 @@
<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/actions/udpscope-tab-measure-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-tab-signals-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-events-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/udpscope-resize-rows-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
+3
View File
@@ -496,6 +496,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
}
} else if (key == "measure_stats") {
st.measure_stats = value == "true";
} else if (key == "side_tab") {
st.side_tab = strip_quote(value);
} else if (key == "multicast") {
st.multicast = strip_quote(value);
} else if (key == "interface") {
@@ -645,6 +647,7 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "]\n";
}
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
o << "side_tab = \"" << state.side_tab << "\"\n";
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
+4
View File
@@ -66,6 +66,10 @@ struct LayoutState {
/// stream and mean nothing restored against another one.
bool measure_stats = false;
/// Which sidebar tab was showing: "measure" or "signals". The one thing the panel persists --
/// no revealer states remain, and the 320 px width is the `.ui`'s.
std::string side_tab = "signals";
/// The measurements pane, by signal *name* and with the type and options a row was built with.
std::vector<Measure> measures;
+4 -8
View File
@@ -12,14 +12,10 @@ constexpr Accel kAccels[] = {
{GDK_KEY_space, 0, AppAction::PauseToggle, "space", "Pause or resume the display",
"Acquisition"},
{GDK_KEY_a, 0, AppAction::TriggerArm, "a", "Arm or disarm the trigger", "Acquisition"},
{GDK_KEY_t, 0, AppAction::TriggerPanel, "t", "Show or hide the trigger panel",
"Acquisition"},
{GDK_KEY_c, 0, AppAction::CursorsPane, "c", "Show or hide the cursors pane",
"Panes and editors"},
{GDK_KEY_m, 0, AppAction::MeasuresPane, "m", "Show or hide the measurements pane",
"Panes and editors"},
{GDK_KEY_g, 0, AppAction::MathPane, "g", "Show or hide the math pane",
"Panes and editors"},
{GDK_KEY_t, 0, AppAction::TriggerPanel, "t", "Enable or disable trigger mode", "Acquisition"},
{GDK_KEY_c, 0, AppAction::CursorsPane, "c", "Enable or disable the cursors", "Panes and editors"},
{GDK_KEY_m, 0, AppAction::SideTabMeasure, "m", "Show the Measure tab", "Panes and editors"},
{GDK_KEY_g, 0, AppAction::SideTabSignals, "g", "Show the Signals tab", "Panes and editors"},
{GDK_KEY_l, 0, AppAction::LayoutEditor, "l", "Show or hide the layout editor",
"Panes and editors"},
{GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the side panel",
+2 -2
View File
@@ -10,8 +10,8 @@ enum class AppAction {
TriggerArm,
TriggerPanel,
CursorsPane,
MeasuresPane,
MathPane,
SideTabMeasure,
SideTabSignals,
LayoutEditor,
SidePanel,
Export,
+255 -200
View File
@@ -2248,76 +2248,6 @@ void open_math_dialog(App& app, int edit) {
}
void rebuild_math_pane(App& app) {
if (app.math_list == nullptr) return;
while (GtkWidget* child = gtk_widget_get_first_child(app.math_list)) {
gtk_box_remove(GTK_BOX(app.math_list), child);
}
if (app.math.empty()) {
GtkWidget* none = gtk_label_new("— none —");
gtk_widget_add_css_class(none, "dim-label");
gtk_widget_set_halign(none, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(app.math_list), none);
return;
}
for (std::size_t i = 0; i < app.math.size(); ++i) {
const MathChannel& m = app.math[i];
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* on = gtk_check_button_new();
gtk_check_button_set_active(GTK_CHECK_BUTTON(on), m.enabled);
gtk_widget_set_tooltip_text(on, "enable this math channel: off means it is not acquired, not\n"
"plotted and not measured, exactly like a signal's switch");
const std::size_t index = i;
g_signal_connect(on, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t k = GPOINTER_TO_SIZE(
g_object_get_data(G_OBJECT(b), "udpscope-math-index"));
if (k >= a->math.size()) return;
a->math[k].enabled = gtk_toggle_button_get_active(b);
apply_math_channels(*a);
}),
&app);
g_object_set_data(G_OBJECT(on), "udpscope-math-index", GSIZE_TO_POINTER(index));
gtk_box_append(GTK_BOX(row), on);
std::string text = m.name;
if (!m.unit.empty()) text += " [" + m.unit + "]";
GtkWidget* label = gtk_label_new(text.c_str());
gtk_label_set_ellipsize(GTK_LABEL(label), PANGO_ELLIPSIZE_MIDDLE);
gtk_widget_set_hexpand(label, true);
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(row), label);
GtkWidget* edit = gtk_button_new_from_icon_name("document-edit-symbolic");
gtk_widget_add_css_class(edit, "flat");
gtk_widget_set_tooltip_text(edit, "edit this math channel's graph");
g_object_set_data(G_OBJECT(edit), "udpscope-math-index", GSIZE_TO_POINTER(index));
g_signal_connect(edit, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t k = GPOINTER_TO_SIZE(
g_object_get_data(G_OBJECT(b), "udpscope-math-index"));
open_math_dialog(*a, static_cast<int>(k));
}),
&app);
gtk_box_append(GTK_BOX(row), edit);
GtkWidget* del = gtk_button_new_from_icon_name("window-close-symbolic");
gtk_widget_add_css_class(del, "flat");
gtk_widget_set_tooltip_text(del, "delete this math channel");
g_object_set_data(G_OBJECT(del), "udpscope-math-index", GSIZE_TO_POINTER(index));
g_signal_connect(del, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t k = GPOINTER_TO_SIZE(
g_object_get_data(G_OBJECT(b), "udpscope-math-index"));
if (k < a->math.size()) a->math.erase(a->math.begin() + k);
apply_math_channels(*a);
}),
&app);
gtk_box_append(GTK_BOX(row), del);
gtk_box_append(GTK_BOX(app.math_list), row);
}
}
GtkWidget* build_measures_panel(App& app) {
// The panel's frame is a UI file; the readings are not, because each row is one measurement and they are
// rebuilt whenever the set or the mode changes. `make test-ui` checks the file's types against the GIR;
@@ -2824,11 +2754,28 @@ void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer
open_row_menu(*app, row, x, y, widget);
}
void on_side_tab_changed(GObject* o, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(o));
app->side_tab = name != nullptr ? name : "signals";
// Not while the layout is being applied: the restore would schedule a save of what it just read.
if (!app->updating_panels) schedule_layout_save(*app);
}
void select_side_tab(App& app, const char* name) {
if (app.side_stack == nullptr || name == nullptr) return;
// An unknown name leaves the stack where it is -- a session file from a later version must not
// be able to blank the sidebar.
if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(app.side_stack), name) == nullptr) return;
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app.side_stack), name);
app.side_tab = name;
}
/// The side column is on when it has something to show *and* the user's sidebar
/// button is in; the two panel buttons appear as soon as a CONFIG describes any
/// signal at all.
void update_side_visibility(App& app) {
if (app.side_scroll == nullptr) return;
if (app.side_stack == nullptr) return;
const bool have_signals = !app.model->signals().empty();
// `have_signals` decides whether the sidebar *can* be on; `sidebar_wanted` is the user's last word,
// which is why it is kept apart from the button's own state -- that state follows the panel now, and
@@ -2837,15 +2784,13 @@ void update_side_visibility(App& app) {
gtk_widget_set_sensitive(app.sidebar_toggle, have_signals);
const bool want_column = have_signals && app.sidebar_wanted;
// The split view owns the sidebar's visibility: it animates it, and when the window is too narrow it
// shows it as an overlay rather than letting the column be squeezed to nothing. Hiding the scroller
// instead left an empty column where the panels should be.
// shows it as an overlay rather than letting the column be squeezed to nothing.
if (app.side_view != nullptr) {
app.sidebar_syncing = true;
adw_overlay_split_view_set_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(app.side_view), want_column);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.sidebar_toggle), want_column);
app.sidebar_syncing = false;
}
gtk_widget_set_visible(app.signal_toggle, have_signals);
gtk_widget_set_visible(app.trigger_toggle, have_signals);
}
@@ -2920,6 +2865,59 @@ std::string icon_report(GtkIconTheme* theme, const char* name, const char* resou
/// 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
/// instead of asking GTK about a destroyed widget.
/// The words the pills do not draw. The pages' `title` is empty on purpose (see the `.ui`), so the
/// human name of a tab lives here and is put on the pill — its tooltip and its accessible name — by
/// `name_side_tabs()`. That keeps an icon-only pill a *named* control for a screen reader.
static const char* side_tab_title(const char* name) {
if (name != nullptr && std::strcmp(name, "measure") == 0) return "Measure";
if (name != nullptr && std::strcmp(name, "signals") == 0) return "Signals";
return "";
}
/// Names the switcher's pills and takes their empty labels out of the layout: an empty `GtkLabel`
/// still asks for its own width, which leaves the icon off-centre in a pill meant to be one glyph.
/// Called from the first tick, because the pills are `AdwViewSwitcher`'s own and exist only once the
/// widget has been mapped.
void name_side_tabs(App& app) {
if (app.side_tabs_named || app.side_switcher == nullptr) return;
GListModel* pages = app.side_stack != nullptr
? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))
: nullptr;
// The switcher's children *are* its pills, in page order, and `AdwViewSwitcherButton` is not a
// public type (there is no `ADW_IS_VIEW_SWITCHER_BUTTON`), so the two lists are walked together by
// index rather than checked by type.
GtkWidget* pill = gtk_widget_get_first_child(app.side_switcher);
for (guint i = 0; pages != nullptr && pill != nullptr && i < g_list_model_get_n_items(pages);
pill = gtk_widget_get_next_sibling(pill), ++i) {
AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i));
const char* title = side_tab_title(adw_view_stack_page_get_name(page));
g_object_unref(page);
if (title[0] == '\0') continue;
gtk_widget_set_tooltip_text(pill, title);
gtk_accessible_update_property(GTK_ACCESSIBLE(pill), GTK_ACCESSIBLE_PROPERTY_LABEL, title, -1);
const std::function<void(GtkWidget*)> hide_labels = [&](GtkWidget* w) {
if (GTK_IS_LABEL(w)) gtk_widget_set_visible(w, FALSE);
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
hide_labels(c);
}
};
hide_labels(pill);
// The tabs' glyphs are 32x16 documents, and the pill has the width for them: drawn at 32 they are
// 1:1, so both motifs sit side by side at the size they were authored at and the 2px bars stay
// crisp. A 16px box would scale the pair down to a smear, and a fractional scale would blur it.
const std::function<void(GtkWidget*)> size_images = [&](GtkWidget* w) {
if (GTK_IS_IMAGE(w)) gtk_image_set_pixel_size(GTK_IMAGE(w), 32);
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
size_images(c);
}
};
size_images(pill);
app.side_tabs_named = true;
}
}
void note_panel_controls(App& app) {
if (!app.dbg_panel_controls.empty()) return;
const auto in_bar = [](GtkWidget* w) {
@@ -2932,15 +2930,15 @@ void note_panel_controls(App& app) {
const auto in_cursors_pane = [&app](GtkWidget* w) {
for (GtkWidget* p = w != nullptr ? gtk_widget_get_parent(w) : nullptr; p != nullptr;
p = gtk_widget_get_parent(p)) {
if (p == app.cursors_revealer) return 1;
if (p == app.cursors_panel) return 1;
}
return 0;
};
// `HeaderBar::pack_end()` parents into an internal box, so the bar is a level up, not the parent.
char text[256];
std::snprintf(text, sizeof(text),
"cursors=%d measurements=%d math=%d in the toolbar, reset=%d in the cursors pane",
in_bar(app.cursors_toggle), in_bar(app.measures_toggle), in_bar(app.math_toggle),
"cursors=%d in the toolbar, switcher=%d, reset=%d in the cursors pane",
in_bar(app.cursors_toggle), app.side_switcher != nullptr ? 1 : 0,
in_cursors_pane(app.cursors_reset));
// ... and the order the bar actually shows, which is what "the menu should be last" is about. A
// widget tree walked in child order *is* the layout order, so this is the reading order of the
@@ -2948,8 +2946,7 @@ void note_panel_controls(App& app) {
const std::pair<GtkWidget*, const char*> named[] = {
{app.trigger_toggle, "trigger"}, {app.events_toggle, "events"},
{app.pause_button, "pause"},
{app.cursors_toggle, "cursors"}, {app.measures_toggle, "measures"},
{app.math_toggle, "math"}, {app.sidebar_toggle, "sidebar"},
{app.cursors_toggle, "cursors"}, {app.sidebar_toggle, "sidebar"},
{app.menu_button, "menu"},
};
// The widget tree's child order, which *is* the visual order left to right: a box lays its children
@@ -2997,6 +2994,62 @@ void note_panel_controls(App& app) {
" " +
icon_report(theme, "org.udpscope.app-symbolic",
"/org/udpscope/icons/scalable/apps/org.udpscope.app-symbolic.svg");
// The two sidebar pages' own icons, named and resolved the same way, and what the switcher is
// drawing for them. The pills are `AdwViewSwitcher`'s own buttons, built when the stack is mapped,
// so this reads on the first tick: `pills` is the guard (0 pills would make a label count of 0
// meaningless) and `labels` must be 0, because the switcher's policy is `narrow` -- the pages are
// icons, not words, in a 300px column.
// Walked through `get_pages()`, not the widget tree: an `AdwViewStackPage` is a plain `GObject`
// (only its `child` is a widget), so `gtk_widget_get_first_child()` on the stack yields the pages'
// scrolled windows and this line read empty the first time it was written.
GListModel* pages = app.side_stack != nullptr
? G_LIST_MODEL(adw_view_stack_get_pages(ADW_VIEW_STACK(app.side_stack)))
: nullptr;
for (guint i = 0; pages != nullptr && i < g_list_model_get_n_items(pages); ++i) {
AdwViewStackPage* page = ADW_VIEW_STACK_PAGE(g_list_model_get_item(pages, i));
const char* page_name = adw_view_stack_page_get_name(page);
const char* icon = adw_view_stack_page_get_icon_name(page);
app.dbg_page_icons += std::string(app.dbg_page_icons.empty() ? "" : " ") +
(page_name != nullptr ? page_name : "?") + "=" +
(icon != nullptr ? icon : "-") +
(icon != nullptr && gtk_icon_theme_has_icon(theme, icon) ? "(ok)"
: "(MISSING)");
g_object_unref(page);
}
// `mapped`, not `visible`: visibility is per widget, so a label whose *parent* is hidden still
// reads visible, and in the narrow policy that is exactly the case. `mapped` requires every
// ancestor, which is what "on screen" means -- and it is why the pill count is read the same way:
// if nothing is mapped the pill count reads 0 and the label count means nothing, rather than
// passing vacuously.
int pills = 0;
int drawn_labels = 0;
const std::function<void(GtkWidget*)> count_drawn = [&](GtkWidget* w) {
if (GTK_IS_TOGGLE_BUTTON(w) && gtk_widget_get_mapped(w)) ++pills;
// A *mapped* label with text: libadwaita's narrow pill keeps an empty label in the layout, so
// "mapped" alone would count the empty ones and read 2 whether the pills were icons or words.
if (GTK_IS_LABEL(w) && gtk_widget_get_mapped(w) && gtk_label_get_text(GTK_LABEL(w))[0] != '\0') {
++drawn_labels;
}
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
count_drawn(c);
}
};
if (app.side_switcher != nullptr) count_drawn(app.side_switcher);
app.dbg_switcher_pills = pills;
app.dbg_switcher_labels = drawn_labels;
int named_pills = 0;
for (GtkWidget* w = app.side_switcher != nullptr ? gtk_widget_get_first_child(app.side_switcher)
: nullptr;
w != nullptr; w = gtk_widget_get_next_sibling(w)) {
const char* tip = gtk_widget_get_tooltip_text(w);
if (tip != nullptr && tip[0] != '\0') ++named_pills;
}
app.dbg_switcher_names = named_pills;
if (app.side_switcher != nullptr) {
g_object_get(G_OBJECT(app.side_switcher), "policy", &app.dbg_switcher_policy, nullptr);
}
}
/// The event-zoom sweep's two halves: a gesture zoom, then the same walk through the window
@@ -3184,6 +3237,11 @@ void update_capture_row(App& app) {
gboolean tick(gpointer user) {
App* app = static_cast<App*>(user);
if (app->window == nullptr) return G_SOURCE_REMOVE;
// Before the reading, and not after it: the pills are `AdwViewSwitcher`'s own widgets and exist
// only once the switcher is mapped, so this is the first frame that can name them -- and their
// page titles are deliberately empty, so a frame that read before this ran would read pills with
// no text and no name either.
name_side_tabs(*app);
note_panel_controls(*app);
ScopeModel& model = *app->model;
const gint64 tick_start_us = g_get_monotonic_time();
@@ -4297,6 +4355,39 @@ void on_window_popover_press(GtkGestureClick*, int, double x, double y, gpointer
/// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a
/// click would; the non-toggles call their click handlers with a null button, which they
/// already ignore. Split out of the key handler so the probe can fire a row directly.
/// The pane's switches and the header bar's buttons are one control with two places to click, so a
/// change in either re-reads the other. `panel_switch_sync` is the guard: setting a `GtkSwitch` emits
/// its own `notify::active`, and without it the program's write would come back as a click.
void sync_panel_switches(App& app) {
if (app.panel_switch_sync) return;
app.panel_switch_sync = true;
const auto mirror = [](GtkWidget* panel_switch, GtkWidget* toolbar_toggle) {
if (panel_switch == nullptr || toolbar_toggle == nullptr) return;
gtk_switch_set_active(GTK_SWITCH(panel_switch),
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toolbar_toggle)) == TRUE);
};
mirror(app.trigger_switch, app.trigger_toggle);
mirror(app.cursors_switch, app.cursors_toggle);
app.panel_switch_sync = false;
}
/// A pane's own switch drives the *same action* as the header bar's button, so either place reaches
/// the feature down one path, and the sync above is what keeps the two showing the same thing.
void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->panel_switch_sync) return;
const bool on = gtk_switch_get_active(GTK_SWITCH(obj)) != FALSE;
const bool is_trigger = app->trigger_switch != nullptr && obj == G_OBJECT(app->trigger_switch);
GtkWidget* toggle = is_trigger ? app->trigger_toggle : app->cursors_toggle;
if (toggle == nullptr) return;
// The action toggles the toolbar button, so it is run only when the two actually disagree -- which
// is what makes this a *set* rather than a second toggle of something already in that state.
if ((gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(toggle)) != FALSE) != on) {
run_app_action(*app, is_trigger ? AppAction::TriggerPanel : AppAction::CursorsPane);
}
sync_panel_switches(*app);
}
void run_app_action(App& app, AppAction action) {
const auto toggle = [](GtkWidget* button) {
if (button == nullptr) return;
@@ -4316,12 +4407,8 @@ void run_app_action(App& app, AppAction action) {
case AppAction::CursorsPane:
toggle(app.cursors_toggle);
break;
case AppAction::MeasuresPane:
toggle(app.measures_toggle);
break;
case AppAction::MathPane:
toggle(app.math_toggle);
break;
case AppAction::SideTabMeasure: select_side_tab(app, "measure"); break;
case AppAction::SideTabSignals: select_side_tab(app, "signals"); break;
case AppAction::LayoutEditor:
toggle(app.layout_toggle);
break;
@@ -4449,11 +4536,6 @@ GtkWidget* build_toolbar(App& app) {
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");
@@ -4511,12 +4593,10 @@ GtkWidget* build_toolbar(App& app) {
app.trigger_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_toggle"));
app.events_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "events_toggle"));
app.pause_button = GTK_WIDGET(gtk_builder_get_object(ui, "pause_button"));
app.measures_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "measures_toggle"));
app.math_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "math_toggle"));
app.cursors_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "cursors_toggle"));
if (app.menu_button == nullptr || app.sidebar_toggle == nullptr || app.trigger_toggle == nullptr ||
app.events_toggle == nullptr || app.pause_button == nullptr || app.measures_toggle == nullptr ||
app.math_toggle == nullptr || app.cursors_toggle == nullptr) {
app.events_toggle == nullptr || app.pause_button == nullptr ||
app.cursors_toggle == nullptr) {
// A missing id is a mistake in the `.ui`: the bar comes up with the controls it got rather than taking
// the window with it, and `[udpscope] header:` reads whatever geometry is left.
return bar;
@@ -4538,24 +4618,6 @@ GtkWidget* build_toolbar(App& app) {
}),
&app);
g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app);
g_signal_connect(app.measures_toggle, "toggled",
G_CALLBACK(+[](GtkToggleButton* b, gpointer user) {
App* a = static_cast<App*>(user);
a->measures_revealed = gtk_toggle_button_get_active(b);
if (a->measures_revealer != nullptr) {
gtk_revealer_set_reveal_child(GTK_REVEALER(a->measures_revealer),
a->measures_revealed);
}
}),
&app);
g_signal_connect(app.math_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* b, gpointer user) {
App* a = static_cast<App*>(user);
if (a->math_revealer != nullptr) {
gtk_revealer_set_reveal_child(GTK_REVEALER(a->math_revealer),
gtk_toggle_button_get_active(b));
}
}),
&app);
g_signal_connect(app.cursors_toggle, "toggled", G_CALLBACK(on_cursors_toggled), &app);
return bar;
}
@@ -4565,8 +4627,9 @@ void on_activate(GtkApplication* application, gpointer user) {
install_css();
// The window's skeleton is a `.ui` file: the window, its two panes, the plot column with the status
// line under it, and the sidebar's scroller. Everything that depends on the stream -- the events
// pane, the two plot areas, the sidebar's panels -- is built below and put into the slots by id.
// line under it, and the sidebar's tab switcher and two-page stack. Everything that depends on the
// stream -- the events pane, the two plot areas, the sidebar's panels -- is built below and put
// into the slots by id.
g_clear_object(&app->window_ui);
app->window_ui = gtk_builder_new_from_resource("/org/udpscope/ui/main-window.ui");
if (app->window_ui == nullptr) return;
@@ -4579,14 +4642,18 @@ void on_activate(GtkApplication* application, gpointer user) {
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
app->side_scroll = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_scroll"));
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack"));
GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher"));
GtkWidget* measure_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "measure_page"));
GtkWidget* signals_page = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "signals_page"));
// A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every use below would
// be a null pointer.
if (app->window == nullptr || side_view == nullptr || events_divider == nullptr ||
app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr ||
app->status_label == nullptr || app->status_info_button == nullptr ||
app->side_scroll == nullptr || side == nullptr) {
side == nullptr || side_stack == nullptr || side_switcher == nullptr ||
measure_page == nullptr || signals_page == nullptr) {
return;
}
// The window's actions live on the application, so it has to know which one it belongs to.
@@ -4714,59 +4781,42 @@ void on_activate(GtkApplication* application, gpointer user) {
gtk_popover_set_has_arrow(GTK_POPOVER(app->status_popover), TRUE);
}
// The right-hand side: two independently collapsible panels, each revealed by
// its toolbar toggle. No outer scrolled window -- the signal list scrolls
// inside its own panel -- so neither panel can be squeezed to nothing by the
// other's size request.
// `side` is the sidebar column from the `.ui`, resolved above with the rest of the skeleton; the
// app keeps it because the panel geometry, the divide and the revealers all ask about it.
// The right-hand side is two tabs, not five stacked panels: one tab is on screen at a time, so
// no section competes with four others for height. Each page scrolls on its own -- the two are
// different heights, and one shared scroller would leave a switch scrolled where the new page
// never asked to be.
// `side` and `side_stack`/`side_switcher`/`measure_page`/`signals_page` are resolved above with
// the rest of the skeleton and checked by the null guard; assign the members here.
app->side_box = side;
app->side_stack = side_stack;
app->side_switcher = side_switcher;
// The two panels share a vertical box, so they collapse *vertically*: a
// horizontal slide would animate the width and leave the panel's full height
// reserved -- an invisible gap pushing the other panel down the column.
app->trigger_revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(app->trigger_revealer),
GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN);
// Measure: what the cursors bracket, and the figures derived from it.
app->cursors_panel = build_cursors_panel(*app);
gtk_box_append(GTK_BOX(measure_page), app->cursors_panel);
gtk_box_append(GTK_BOX(measure_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
gtk_box_append(GTK_BOX(measure_page), build_measures_panel(*app));
// Signals: the trigger picks a channel from the list beside it.
app->trigger_panel = build_trigger_panel(*app);
gtk_revealer_set_child(GTK_REVEALER(app->trigger_revealer), app->trigger_panel);
gtk_box_append(GTK_BOX(side), app->trigger_revealer);
gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
// The cursors readout sits with the other panels and is revealed by its toggle: the
// instruments are on the plot, but what they read has to be somewhere a value can be
// selected and copied from.
app->cursors_revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(app->cursors_revealer),
GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN);
gtk_revealer_set_child(GTK_REVEALER(app->cursors_revealer), build_cursors_panel(*app));
gtk_box_append(GTK_BOX(side), app->cursors_revealer);
gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app->measures_revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(app->measures_revealer),
GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN);
gtk_revealer_set_child(GTK_REVEALER(app->measures_revealer), build_measures_panel(*app));
gtk_box_append(GTK_BOX(side), app->measures_revealer);
gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app->math_revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(app->math_revealer),
GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN);
gtk_revealer_set_child(GTK_REVEALER(app->math_revealer), build_math_panel(*app));
gtk_box_append(GTK_BOX(side), app->math_revealer);
gtk_box_append(GTK_BOX(side), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
rebuild_math_pane(*app);
gtk_box_append(GTK_BOX(signals_page), app->trigger_panel);
gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
app->signal_panel = build_signal_panel(*app);
app->signal_revealer = gtk_revealer_new();
gtk_revealer_set_transition_type(GTK_REVEALER(app->signal_revealer),
GTK_REVEALER_TRANSITION_TYPE_SLIDE_DOWN);
gtk_revealer_set_child(GTK_REVEALER(app->signal_revealer), app->signal_panel);
gtk_revealer_set_reveal_child(GTK_REVEALER(app->signal_revealer), TRUE);
gtk_box_append(GTK_BOX(side), app->signal_revealer);
// Its size request and the scroller around it are the `.ui`'s: the column scrolls as a whole, so a
// long signal list or a revealed trigger panel can never push either panel out of the window.
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
// Both instruments start off, so both *bodies* start insensitive -- stated here by the one place
// that knows the default, rather than left to whichever widget happened to be built first. The
// bodies and not the panels: each panel's heading carries the switch that turns it on, and a
// control inside an insensitive parent cannot be clicked.
gtk_widget_set_sensitive(app->trigger_body, FALSE);
gtk_widget_set_sensitive(app->cursors_body, FALSE);
gtk_widget_set_sensitive(app->cursors_reset, FALSE);
if (app->side_stack != nullptr) {
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(app->side_stack), "signals");
g_signal_connect(app->side_stack, "notify::visible-child-name",
G_CALLBACK(on_side_tab_changed), app);
}
// The left side is the events pane *and* the plot, in one row: the pane is a column of its own
// (the item's "collapsable pane on the left"), revealed by trigger mode.
@@ -5401,6 +5451,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_STATS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), stats_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SIDE_TAB_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), side_tab_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_SEARCH_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), search_probe, &app);
}
@@ -5494,9 +5547,6 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_WINDOWPOP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), windowpop_probe_step, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_COLOR_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), color_probe, &app);
}
@@ -5604,7 +5654,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
std::printf("[udpscope] axes.w changes: %d (last at frame %d, side panel up=%d)\n",
app.dbg_axes_w_changes, app.dbg_axes_w_change_frame,
app.dbg_axes_w_change_side);
std::printf("[udpscope] reset button visible: %d\n", app.dbg_reset_visible);
std::printf("[udpscope] reset button sensitive: %d\n", app.dbg_reset_sensitive);
std::printf("[udpscope] render blank frames: %d of %d painted\n",
app.dbg_render_blank_frames, app.dbg_frames_painted);
std::printf("[udpscope] traceless paints: %d (%d after the first bind, last at paint %d)\n",
@@ -5634,25 +5684,16 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (GTK_IS_LABEL(c)) ++labels;
if (GTK_IS_LIST_BOX(c)) ++lists;
}
std::printf("[udpscope] signals panel: labels=%d list=%d\n", labels, lists);
}
{
// The math panel's frame, read from the window: the head row with the title and the add button, and the
// list. `buttons=0` is a `.ui` that lost the `+` -- otherwise a panel that cannot add anything, with
// nothing else complaining -- and the title is read out to show the file's own text arrived.
GtkWidget* panel = app.math_list != nullptr ? gtk_widget_get_parent(app.math_list) : nullptr;
GtkWidget* head = panel != nullptr ? gtk_widget_get_first_child(panel) : nullptr;
GtkWidget* title = head != nullptr ? gtk_widget_get_first_child(head) : nullptr;
int head_children = 0;
int buttons = 0;
for (GtkWidget* c = head != nullptr ? gtk_widget_get_first_child(head) : nullptr; c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
++head_children;
if (GTK_IS_BUTTON(c)) ++buttons;
}
std::printf("[udpscope] math panel: head=%d buttons=%d list=%d title='%s'\n", head_children,
buttons, app.math_list != nullptr ? 1 : 0,
GTK_IS_LABEL(title) ? gtk_label_get_text(GTK_LABEL(title)) : "-");
// The list's shape, beside the frame: `rows` is what the list holds, `signals` the streamed
// ones, and the `MATH` divider plus the math channels account for the rest -- so
// `rows == signals + 1 + math` is the invariant, and `divider=0` is a list that lost its `+`.
std::printf("[udpscope] signals panel: labels=%d list=%d rows=%d signals=%zu math=%zu "
"divider=%d\n",
labels, lists, app.dbg_signal_rows, app.dbg_signal_plain, app.math.size(),
app.dbg_signal_rows ==
static_cast<int>(app.dbg_signal_plain + 1 + app.math.size())
? 1
: 0);
}
{
// The events pane's frame, read from the window: the four widgets the `.ui` gives it, and the list
@@ -5671,14 +5712,26 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
inside(app.events_list, app.events_revealer));
}
std::printf("[udpscope] panels: side=%dx%d (visible=%d) signals=%dx%d row_h=%d "
"(revealed=%d) trigger=%dx%d (revealed=%d) toggles=%d cursors=%d window=%.3fs "
"window_cap=%.1fs\n",
app.dbg_side_w, app.dbg_side_h, app.dbg_side_visible, app.dbg_signal_w,
app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_revealed,
"(page_current=%d) trigger=%dx%d (sensitive=%d) switcher=%d "
"cursors=%d window=%.3fs window_cap=%.1fs\n",
app.dbg_side_w, app.dbg_side_h, app.dbg_side_column_after_hide, app.dbg_signal_w,
app.dbg_signal_h, app.dbg_signal_row_h, app.dbg_signal_page_current,
app.dbg_trigger_w, app.dbg_trigger_h,
app.dbg_trigger_revealed, app.dbg_toggles_visible,
app.dbg_trigger_sensitive, app.dbg_switcher_visible,
rows.cursors_visible() ? 1 : 0, rows.time_window(),
app.window_limit_s);
std::printf("[udpscope] panel switches: trigger=%d/%d cursors=%d/%d drove_toolbar=%d "
"(pane switch / header bar button; `drove_toolbar` is the pane's own switch having "
"moved the button)\n",
app.dbg_panel_switch_trigger, app.dbg_toolbar_switch_trigger,
app.dbg_panel_switch_cursors, app.dbg_toolbar_switch_cursors,
app.dbg_panel_drove_toolbar);
std::printf("[udpscope] switcher: policy=%s pills=%d labels=%d names=%d icon=%dx%d "
"size=%dx%d\n",
app.dbg_switcher_policy == 0 ? "narrow" : (app.dbg_switcher_policy == 1 ? "wide" : "?"),
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names,
app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w,
app.dbg_switcher_h);
std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str());
std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f "
"valid=%d\n",
@@ -5800,6 +5853,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] page icons: %s\n", app.dbg_page_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
@@ -5841,11 +5895,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
std::printf("[udpscope] badge frames: lit=%d dim=%d idle=%d connecting=%d\n",
app.dbg_badge_frames[0], app.dbg_badge_frames[1], app.dbg_badge_frames[2],
app.dbg_badge_frames[3]);
std::printf("[udpscope] trigger panel revealed before the probe toggled it: %d; "
"signal list items=%d selected=%d; column visible after sidebar "
"toggle off=%d\n",
app.dbg_trigger_revealed_default, app.dbg_trigger_items,
app.dbg_trigger_selected, app.dbg_side_scroll_after_hide);
std::printf("[udpscope] trigger panel sensitive before the probe toggled it: %d; "
"signal list items=%d selected=%d; column visible after sidebar toggle off=%d; "
"pane switches live while their panes are off=%d\n",
app.dbg_trigger_sensitive_default, app.dbg_trigger_items,
app.dbg_trigger_selected, app.dbg_side_column_after_hide,
app.dbg_panel_switch_live);
{
// The panel's *frame* read back: the row boxes the `.ui` holds, and that every control the code built
// ended up inside the panel rather than in a row that is not there. A control is created in code and
@@ -6078,9 +6133,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
}
{
std::lock_guard<std::mutex> lock(model.mutex());
std::printf("[udpscope] measures pane: n=%zu revealed=%d marks_shown=%d pane_rows=%zu "
std::printf("[udpscope] measures pane: n=%zu marks_shown=%d pane_rows=%zu "
"pane_w=%d pane_h=%d switch_on=%d reset_sensitive=%d\n",
app.measures.size(), app.measures_revealed ? 1 : 0,
app.measures.size(),
rows.measure_marks_placed(), app.measure_labels.size(),
app.dbg_measures_pane_w, app.dbg_measures_pane_h,
// The two widgets the panel's *frame* is read by: the code drives `measure_stats_on`,
+9
View File
@@ -38,6 +38,15 @@
<property name="css-classes">flat</property>
</object>
</child>
<child>
<!-- The pane's own switch, the same one the header bar carries: a feature whose pane is
disabled while it is off needs its control *in* the pane, or turning it back on means
finding the toolbar button that greyed it out. `body` is what goes insensitive, not the
panel, so this stays live. -->
<object class="GtkSwitch" id="cursors_switch">
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<child>
+3 -15
View File
@@ -3,15 +3,15 @@
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The header bar: the time-window control's slot, the trigger, pause and events group with the separator that
marks where the *view's* controls end, and the five at the far end.
marks where the *view's* controls end, and the three at the far end.
The bar carries only the controls you reach for *while watching a signal*; everything you set up once lives
in the overflow menu, which is code — its rows act on the `RowSet` directly and two of them are toggles
in the overflow menu, which is code — its rows act on the `RowSet` directly and one of them is a toggle
whose state the rest of the interface reads, so a `GMenu` model would only add a second copy of that state.
The order is the file's, and it is the one thing here that is not self-evident: `pack_end()` lays its
children out right to left, so a `<child type="end">` is added leftwards-from-the-right in document order
and the screen reads the reverse of it — cursors, measurements, math, sidebar, menu.
and the screen reads the reverse of it — cursors, sidebar, menu.
The first start child is a slot and not the control: the time-window control is code (its menu's rows come
from the buffer's limits), and `gtk_header_bar_pack_start()` appends *after* whatever a file has already
@@ -64,18 +64,6 @@
<property name="active">1</property>
</object>
</child>
<child type="end">
<object class="GtkToggleButton" id="math_toggle">
<property name="icon-name">udpscope-math-symbolic</property>
<property name="tooltip-text" translatable="yes">channels derived from the others by a graph of operations: gain, absolute value, filters, sums and the rest. An enabled one is acquired, plotted and measured exactly like a signal</property>
</object>
</child>
<child type="end">
<object class="GtkToggleButton" id="measures_toggle">
<property name="icon-name">udpscope-measure-symbolic</property>
<property name="tooltip-text" translatable="yes">compute a value over the visible window of a signal, and optionally mark it on the plot</property>
</object>
</child>
<child type="end">
<object class="GtkToggleButton" id="cursors_toggle">
<property name="icon-name">udpscope-cursors-symbolic</property>
+76 -13
View File
@@ -3,7 +3,7 @@
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The main window's skeleton: the window, the split view that puts the sidebar beside the plot, the
events pane's divider, the plot column with the status line under it, and the sidebar's scroller.
events pane's divider, the plot column with the status line under it, and the sidebar's tab stack.
The sidebar is an `AdwOverlaySplitView`, which is what a utility sidebar is in GNOME (as against
`AdwNavigationSplitView`, which is for navigation lists). Three things follow from that, and they are
@@ -20,7 +20,7 @@
What is *not* here is as deliberate as what is. Everything this file nests is fixed — the shape of
the window does not depend on the stream, the trigger or the layout — while the subtrees that do are
built by code and put in place: the events pane, into `events_divider`'s start side; the two plot
areas, into the split inside `plot_box`; the sidebar's panels, appended to `side_column`; and the
areas, into the split inside `plot_box`; the sidebar's panels, appended to `measure_page` and `signals_page`; and the
status popover, attached to `status_info`.
The header bar is not here either: it is the window's own titlebar, built by `build_toolbar()`
@@ -48,18 +48,81 @@
<property name="min-sidebar-width">320</property>
<property name="max-sidebar-width">320</property>
<property name="sidebar">
<!-- The column scrolls as a whole, so a long signal list or a revealed trigger panel can
never push either panel out of the window. -->
<object class="GtkScrolledWindow" id="side_scroll">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="min-content-width">300</property>
<property name="child">
<object class="GtkBox" id="side_column">
<property name="orientation">vertical</property>
<property name="width-request">300</property>
<!-- A switcher above a stack, and a scroller *per page*: the two pages are different
heights, so one shared scroller around the stack would leave a tab switch scrolled
to somewhere the new page never asked for. The switcher is the sibling above, not a
child of either scroller: the one control that chooses what you are looking at must
not be the first thing to scroll away.
The policy is `narrow` and the **titles are empty**: `narrow` still draws a label
beside an icon (measured: a 48px "Measure" inside a 54px pill), so an empty title is
what makes a pill an icon. The pages' *names* are what identify them to
`select_side_tab()` and the session file, and the words are re-attached to the pills in
code — tooltip and accessible name — by `name_side_tabs()` on the first tick, so an
icon-only pill is still a named thing rather than an anonymous button.
Each page's icon is a glyph of its own, and each one is a *pair*: the Measure tab is
`udpscope-tab-measure-symbolic` (the cursors and the ruler they read against), and the
Signals tab is `udpscope-tab-signals-symbolic` (the trigger's edge and the channel list
below it). They are drawn for the tabs rather than reusing the icons the toolbar and the
Preferences window already carry: a glyph the toolbar uses for one thing means two things
the moment a tab shows it too. -->
<object class="GtkBox" id="side_column">
<property name="orientation">vertical</property>
<property name="width-request">300</property>
<child>
<object class="AdwViewSwitcher" id="side_switcher">
<property name="stack">side_stack</property>
<property name="policy">narrow</property>
<property name="margin-top">4</property>
<property name="margin-bottom">4</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
</object>
</property>
</child>
<child>
<object class="AdwViewStack" id="side_stack">
<property name="vexpand">1</property>
<child>
<object class="AdwViewStackPage">
<property name="name">measure</property>
<property name="title"></property>
<!-- The cursors and the ruler: what the pane brackets, and what it
reads the bracketed window with. -->
<property name="icon-name">udpscope-tab-measure-symbolic</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="child">
<object class="GtkBox" id="measure_page">
<property name="orientation">vertical</property>
</object>
</property>
</object>
</property>
</object>
</child>
<child>
<object class="AdwViewStackPage">
<property name="name">signals</property>
<property name="title"></property>
<!-- The trigger and the channel list it acts on. -->
<property name="icon-name">udpscope-tab-signals-symbolic</property>
<property name="child">
<object class="GtkScrolledWindow">
<property name="hscrollbar-policy">never</property>
<property name="vscrollbar-policy">automatic</property>
<property name="child">
<object class="GtkBox" id="signals_page">
<property name="orientation">vertical</property>
</object>
</property>
</object>
</property>
</object>
</child>
</object>
</child>
</object>
</property>
<property name="content">
-49
View File
@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The math panel: a heading row with the button that adds a channel, and the list they go in.
The rows are not here. A math channel's row carries its name, its unit, its expression, its colour and its
own edit and remove buttons, all of which follow the channel — so the file holds the frame and the code
holds the rows, as in the measurements and signals panels beside it.
The heading takes the slack instead of the button, which is what keeps the `+` at the row's end however
long the title or the panel is.
-->
<interface>
<object class="GtkBox" id="math_panel">
<property name="orientation">vertical</property>
<property name="spacing">4</property>
<property name="margin-top">6</property>
<property name="margin-bottom">6</property>
<property name="margin-start">6</property>
<property name="margin-end">6</property>
<child>
<object class="GtkBox" id="math_head">
<property name="orientation">horizontal</property>
<property name="spacing">8</property>
<child>
<object class="GtkLabel" id="math_title">
<property name="label" translatable="yes">Math channels</property>
<property name="halign">start</property>
<property name="hexpand">1</property>
</object>
</child>
<child>
<object class="GtkButton" id="math_add">
<property name="icon-name">list-add-symbolic</property>
<property name="css-classes">flat</property>
<property name="tooltip-text" translatable="yes">add a math channel</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="math_list">
<property name="orientation">vertical</property>
<property name="spacing">2</property>
</object>
</child>
</object>
</interface>
+4 -4
View File
@@ -46,7 +46,7 @@
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">t</property>
<property name="title" translatable="yes">Show or hide the trigger panel</property>
<property name="title" translatable="yes">Enable or disable trigger mode</property>
</object>
</child>
</object>
@@ -66,21 +66,21 @@
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">c</property>
<property name="title" translatable="yes">Show or hide the cursors pane</property>
<property name="title" translatable="yes">Enable or disable the cursors</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">m</property>
<property name="title" translatable="yes">Show or hide the measurements pane</property>
<property name="title" translatable="yes">Show the Measure tab</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">g</property>
<property name="title" translatable="yes">Show or hide the math pane</property>
<property name="title" translatable="yes">Show the Signals tab</property>
</object>
</child>
<child>
+4 -8
View File
@@ -2,8 +2,10 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The trigger panel's frame: the heading, one labelled row per setting, the two buttons that run it, and
the search block.
The trigger panel's frame: one labelled row per setting, the two buttons that run it, and the search
block. Its *heading* is not here: the heading row carries the switch that turns trigger mode on and
off, and this panel's body is what goes insensitive when it is off, so the heading has to be its
sibling rather than a child of it (`build_trigger_panel()` returns the two together).
Every row here is a label and a place for a control, and the control itself is built in code. A
dropdown's model and a spin button's range are the *application's* (the condition list, the level
@@ -24,12 +26,6 @@
<property name="margin-bottom">8</property>
<property name="margin-start">8</property>
<property name="margin-end">8</property>
<child>
<object class="GtkLabel" id="trigger_heading">
<property name="label" translatable="yes">Trigger</property>
<property name="halign">start</property>
</object>
</child>
<child>
<object class="GtkBox" id="signal_row">
<property name="orientation">horizontal</property>
-1
View File
@@ -18,7 +18,6 @@
<file preprocess="xml-stripblanks">main-window.ui</file>
<file preprocess="xml-stripblanks">cursors-panel.ui</file>
<file preprocess="xml-stripblanks">measures-panel.ui</file>
<file preprocess="xml-stripblanks">math-panel.ui</file>
<file preprocess="xml-stripblanks">math-editor.ui</file>
<file preprocess="xml-stripblanks">events-pane.ui</file>
<file preprocess="xml-stripblanks">header-bar.ui</file>
+85 -28
View File
@@ -139,18 +139,21 @@ struct App {
/// what we synced is ours -- GTK only notifies on a change, so the user cannot produce it.
int window_sync_unit = -1;
GtkWidget* trigger_toggle = nullptr;
GtkWidget* trigger_revealer = nullptr;
/// The trigger panel's own box, kept so the defaults probe can read its spin buttons without
/// walking the window for them.
GtkWidget* trigger_panel = nullptr;
GtkWidget* signal_toggle = nullptr;
GtkWidget* signal_revealer = nullptr;
GtkWidget* signal_panel = nullptr;
GtkWidget* side_box = nullptr;
/// The sidebar's tab stack and its switcher. The stack is what chooses which panels are on
/// screen; a feature's own on/off never does (it sets its panel's sensitivity instead).
GtkWidget* side_stack = nullptr;
GtkWidget* side_switcher = nullptr;
/// The page name the stack is showing, and the one value the session file persists about the
/// sidebar. "signals" on a first run.
std::string side_tab = "signals";
GtkWidget* arm_button = nullptr;
GtkWidget* trigger_signal_drop = nullptr;
GtkWidget* sidebar_toggle = nullptr;
GtkWidget* side_scroll = nullptr;
GtkWidget* trigger_edge_drop = nullptr;
GtkWidget* trigger_type_drop = nullptr;
GtkWidget* trigger_cmp_drop = nullptr;
@@ -207,14 +210,56 @@ struct App {
/// works, and every other line of the dump still passes.
std::string dbg_toolbar_icons;
std::string dbg_app_icons;
/// The two sidebar pages' own icons and whether the theme resolved them, read on the first tick
/// like the two lines above.
std::string dbg_page_icons;
/// What the switcher is: how many pills it holds, and how many labels it is *drawing*. The label
/// count is the one that says the pages are icons rather than words: `narrow` hides them, and the
/// titles stay set because they are the pages' names for a screen reader and for the tooltip.
int dbg_switcher_pills = -1;
int dbg_switcher_labels = -1;
int dbg_switcher_names = -1;
/// The pills' icon as it is *rendered*: a 32x16 glyph in a 32px box is drawn 1:1, which is what
/// keeps its 2px bars crisp.
int dbg_switcher_icon_w = -1;
int dbg_switcher_icon_h = -1;
/// The switcher's own size, beside it: the pills carry 32px icon boxes now, so the tab bar's height
/// is what it costs.
int dbg_switcher_w = -1;
int dbg_switcher_h = -1;
/// The panes' own switches and the header bar's buttons, read together by the exit probe: the two
/// must agree, and `dbg_panel_drove_toolbar` is the pane's switch having moved the button.
int dbg_panel_switch_trigger = -1;
int dbg_toolbar_switch_trigger = -1;
int dbg_panel_switch_cursors = -1;
int dbg_toolbar_switch_cursors = -1;
int dbg_panel_drove_toolbar = -1;
/// Both panes' switches being *sensitive* while their panes are off -- read before anything turns
/// one on. It is the whole point of putting them in the heading: a control inside an insensitive
/// parent cannot be clicked, so a switch that went insensitive with its own pane could never turn
/// it back on.
int dbg_panel_switch_live = -1;
int dbg_switcher_policy = -1;
/// Whether `name_side_tabs()` has named the pills: it can only run once the switcher is mapped.
bool side_tabs_named = false;
/// 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;
GtkWidget* cursors_reset = nullptr;
/// The cursors pane: revealed with the toggle, and listing what the instruments
/// read -- T0/T1/dT always, per row an Y1/Y2/dY where rulers are up, and in `mix` mode
/// the value each cursor crosses each trace at.
GtkWidget* cursors_revealer = nullptr;
/// The cursors panel's root, kept because cursors-off disables it rather than hiding it.
GtkWidget* cursors_panel = nullptr;
/// The panes' *own* switches, in their header rows, and the panel bodies those switches disable.
/// The switch and the header bar's button are one control with two places to click: each runs the
/// same action, and `sync_panel_switches()` re-reads both from the toolbar's state after any
/// change, so they cannot disagree.
GtkWidget* trigger_switch = nullptr;
GtkWidget* cursors_switch = nullptr;
GtkWidget* trigger_body = nullptr;
/// True while `sync_panel_switches()` is writing the switches: setting a `GtkSwitch` emits its own
/// `notify::active`, and without this the program's own write would come back as a click.
bool panel_switch_sync = false;
/// The cursors pane: listing what the instruments read -- T0/T1/dT always, per row an Y1/Y2/dY
/// where rulers are up, and in `mix` mode the value each cursor crosses each trace at.
GtkWidget* cursors_mode_drop = nullptr;
GtkWidget* cursors_body = nullptr;
/// One line per row (rulers) and one per bound trace (`mix`), in the order the body
@@ -237,17 +282,12 @@ struct App {
/// price the frame tick should pay).
std::vector<Measure> measures;
std::vector<MeasureResult> measure_results;
GtkWidget* measures_revealer = nullptr;
GtkWidget* measures_list = nullptr;
GtkWidget* measures_toggle = nullptr;
// --- math channels --------------------------------------------------------
/// The app's copy of the math channels, and the source of truth for the pane. Every change goes
/// through `apply_math_channels()`, which writes this and hands it to the model -- the model's own
/// copy is what the producer evaluates.
/// The app's copy of the math channels: what the signal list's ticks mirror into and what the
/// session file is written from. Every change goes through `apply_math_channels()`, which hands it
/// to the model -- the model's own copy is what the producer evaluates.
std::vector<MathChannel> math;
GtkWidget* math_toggle = nullptr;
GtkWidget* math_revealer = nullptr;
GtkWidget* math_list = nullptr;
/// The math editor's builder. The editor's window belongs to the builder's object list, so the builder is
/// held for as long as the window is up and cleared on the *next* opening rather than with the window --
/// freeing it under a live window frees the window with it. That is the source form's pattern.
@@ -358,7 +398,6 @@ struct App {
/// that memory reused and the read is a **SIGSEGV** in the middle of the dump.
int dbg_measures_pane_w = -1;
int dbg_measures_pane_h = -1;
bool measures_revealed = false;
/// The measurement dialog while one is open (null otherwise). A probe needs a handle on it:
/// it is a window of its own, so the main window's widget tree does not reach its buttons.
GtkWidget* measure_dialog = nullptr;
@@ -508,7 +547,6 @@ struct App {
/// True once the renderer's `Style` has been stated (see `tick()`): the renderer does not
/// exist until the area is realized, so the one-shot has to be a tick's job.
bool style_stated = false;
bool signals_panel_initialised = false;
/// Set once the user edits the trigger level, so the panel stops defaulting it
/// into the middle of the declared range.
bool trigger_level_touched = false;
@@ -690,7 +728,7 @@ struct App {
/// over a run that acquired anything -- that is what "it blinks" means, and a badge
/// stuck lit looks identical to a working one in a screenshot.
int dbg_badge_frames[4] = {0, 0, 0, 0};
int dbg_reset_visible = -1;
int dbg_reset_sensitive = -1;
std::vector<std::uint64_t> last_stored;
std::uint64_t dbg_latches_at_disarm = 0;
std::uint64_t dbg_seq_at_disarm = 0;
@@ -738,12 +776,16 @@ struct App {
int dbg_signal_row_h = 0;
int dbg_trigger_w = 0;
int dbg_trigger_h = 0;
int dbg_signal_revealed = 0;
int dbg_trigger_revealed = 0;
int dbg_side_visible = 0;
int dbg_signal_page_current = 0;
int dbg_trigger_sensitive = 0;
int dbg_trigger_items = 0;
int dbg_trigger_selected = -1;
int dbg_side_scroll_after_hide = 1;
/// The signal list's shape, captured where the list is built: how many rows it holds, and how many
/// of them are streamed signals. The `MATH` divider and the math channels account for the rest, and
/// `rows == signals + 1 + math` is the invariant the dump checks -- a list that lost its `+`.
int dbg_signal_rows = 0;
std::size_t dbg_signal_plain = 0;
int dbg_side_column_after_hide = 1;
std::string dbg_mode_triggered;
std::string dbg_window_text;
double dbg_marker_x = 0.0;
@@ -751,8 +793,8 @@ struct App {
double dbg_trigger_time = 0.0;
int dbg_trigger_time_valid = -1;
std::string dbg_mode_live;
int dbg_toggles_visible = 0;
int dbg_trigger_revealed_default = 0;
int dbg_switcher_visible = 0;
int dbg_trigger_sensitive_default = 0;
/// The detail string the popover and the exit dump read. The dump used to read the label,
/// which no longer carries these fields.
@@ -1151,6 +1193,9 @@ constexpr int kEventThumbH = 46;
constexpr int kEventThumbMaxH = 104;
inline int g_stats_phase = 0;
inline std::vector<std::uint64_t> g_stats_n_before;
/// The same for the tab-switch probe: the accumulator counts recorded while the Measure page was
/// still current, so the report can say whether any of them advanced afterwards.
inline std::vector<std::uint64_t> g_side_tab_n_before;
inline double g_trigrenum_edge = 0.0;
inline std::uint64_t g_trigrenum_latches = 0;
inline ConditionProbe g_condition;
@@ -1233,6 +1278,13 @@ std::string signal_chip_markup(App& app, const std::vector<SignalInfo>& signals,
void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user);
void open_signal_color(App& app, std::size_t signal);
void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals);
/// The `MATH` divider row in the one channel list, and the `+` it carries.
GtkWidget* make_math_divider(App& app);
void on_math_add_clicked(GtkButton* button, gpointer user);
/// The index in `App::math` of the channel a synthetic signal is named after, or `-1`.
int math_index_for_signal(App& app, const std::string& name);
void on_math_edit_clicked(GtkButton* button, gpointer user);
void on_math_delete_clicked(GtkButton* button, gpointer user);
void rebuild_trigger_signals(App& app);
double window_limit(const App& app);
void sync_window_display(App& app, double seconds);
@@ -1331,12 +1383,9 @@ void math_dialog_sync(MathDialog* d);
void on_math_apply(GtkButton*, gpointer user);
GtkWidget* math_row(const char* label, GtkWidget* widget, GtkWidget** row_out);
void open_math_dialog(App& app, int edit);
GtkWidget* build_math_panel(App& app);
void rebuild_math_pane(App& app);
GtkWidget* build_measures_panel(App& app);
GtkWidget* build_cursors_panel(App& app);
void on_cursors_toggled(GtkToggleButton* button, gpointer user);
void on_signals_toggled(GtkToggleButton* button, gpointer user);
void on_fit_clicked(GtkButton*, gpointer user);
void on_fit_x_clicked(GtkButton*, gpointer user);
void on_cursors_reset(GtkButton*, gpointer user);
@@ -1359,6 +1408,12 @@ void row_menu_fill(RowMenuState& st);
GtkWidget* open_row_menu(App& app, int row, double x, double y, GtkWidget* parent, bool autohide = true);
void on_right_click(GtkGestureClick* gesture, int, double x, double y, gpointer user);
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user);
void on_side_tab_changed(GObject*, GParamSpec*, gpointer user);
void select_side_tab(App& app, const char* name);
/// Reads each pane's own switch back off the header bar's button, and the other way round: the two are
/// one control in two places, and `panel_switch_sync` keeps the write from looking like a click.
void sync_panel_switches(App& app);
void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user);
void update_side_visibility(App& app);
void on_sidebar_toggled(GtkToggleButton* button, gpointer user);
void rebuild_signal_panels(App& app);
@@ -1418,6 +1473,8 @@ gboolean measure_probe(gpointer user);
gboolean measure_trace(gpointer user);
gboolean stats_probe_report(gpointer user);
gboolean stats_probe(gpointer user);
gboolean side_tab_probe_report(gpointer user);
gboolean side_tab_probe(gpointer user);
gboolean pan_probe(gpointer user);
gboolean arm_probe(gpointer user);
gboolean trigrenum_probe_step3(gpointer user);
+27 -8
View File
@@ -14,7 +14,18 @@ namespace ui_impl {
/// `%.6g s` is exact enough for a cursor readout and short enough for the panel.
void update_cursors_pane(App& app) {
if (app.cursors_body == nullptr || !app.rows->cursors_visible()) return;
if (app.cursors_body == nullptr) return;
if (!app.rows->cursors_visible()) {
// A readout quotes a stored sample; with the instruments off there is no sample it quotes, so
// the cells blank rather than keeping the last ones. `"--"` is the same spelling an
// unavailable cell already uses -- two spellings of one state in one grid would be the defect.
for (const App::CursorLine& line : app.cursors_lines) {
if (line.a != nullptr) gtk_label_set_text(GTK_LABEL(line.a), "--");
if (line.b != nullptr) gtk_label_set_text(GTK_LABEL(line.b), "--");
if (line.d != nullptr) gtk_label_set_text(GTK_LABEL(line.d), "--");
}
return;
}
if (app.cursors_pane_mode != static_cast<int>(app.rows->cursor_mode()) ||
app.cursors_pane_rows != app.rows->row_count() ||
app.cursors_pane_generation != app.model->generation() ||
@@ -85,6 +96,12 @@ GtkWidget* build_cursors_panel(App& app) {
app.cursors_mode_drop = GTK_WIDGET(gtk_builder_get_object(ui, "mode_drop"));
app.cursors_reset = GTK_WIDGET(gtk_builder_get_object(ui, "reset"));
app.cursors_body = GTK_WIDGET(gtk_builder_get_object(ui, "body"));
app.cursors_switch = GTK_WIDGET(gtk_builder_get_object(ui, "cursors_switch"));
if (app.cursors_switch != nullptr) {
gtk_widget_set_tooltip_text(app.cursors_switch,
"the cursors, the same switch the header bar carries");
g_signal_connect(app.cursors_switch, "notify::active", G_CALLBACK(on_panel_switch_toggled), &app);
}
if (box == nullptr || app.cursors_mode_drop == nullptr || app.cursors_reset == nullptr ||
app.cursors_body == nullptr) {
// A missing id is a mistake in the `.ui`. The panel comes up empty rather than taking the window
@@ -117,18 +134,20 @@ void on_cursors_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool on = gtk_toggle_button_get_active(button);
app->rows->set_cursors_visible(on);
// The reset button exists for the cursors: with them hidden there is nothing to
// reset, and a button that does nothing reads as broken.
gtk_widget_set_visible(app->cursors_reset, on);
// The pane is the cursors' readout: with them off there is nothing to read.
if (app->cursors_revealer != nullptr) {
gtk_revealer_set_reveal_child(GTK_REVEALER(app->cursors_revealer), on);
}
// Disabled, not hidden, and the same for the reset beside it: with the cursors off there is
// nothing to reset, but the button must stay where the user will look for it. The *body* goes
// insensitive and not the panel, because the panel's header carries the switch that turns the
// cursors back on -- and a control inside an insensitive parent cannot be clicked.
gtk_widget_set_sensitive(app->cursors_reset, on);
if (app->cursors_body != nullptr) gtk_widget_set_sensitive(app->cursors_body, on);
if (!on) update_cursors_pane(*app);
if (on) {
app->rows->reset_cursors();
rebuild_cursors_pane(*app);
update_cursors_pane(*app);
}
// The pane's own switch shows the same state, whichever of the two was clicked.
sync_panel_switches(*app);
}
void on_cursors_reset(GtkButton*, gpointer user) {
+2
View File
@@ -58,6 +58,7 @@ LayoutState collect_layout(App& app) {
st.math = app.math;
st.measures = app.measures;
st.measure_stats = app.measure_stats_on;
st.side_tab = app.side_tab;
// The connection is the session's too: the file's name carries only host and port.
st.multicast = app.source_multicast;
st.interface_addr = app.source_interface;
@@ -327,6 +328,7 @@ void apply_layout(App& app, const LayoutState& st) {
app.measures = st.measures;
app.measure_stats_on = st.measure_stats;
app.updating_panels = true;
select_side_tab(app, st.side_tab.c_str());
if (app.measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check),
app.measure_stats_on);
+3 -21
View File
@@ -22,7 +22,9 @@ void apply_math_channels(App& app) {
// refills that; a file's nothing does, so the view is re-read -- and with the math set now in place
// the reload computes the derived channels over the file as it goes.
reload_file_view(app);
rebuild_math_pane(app);
// The math channels are rows of the signal list too -- `append_math_signals_locked()` puts them
// in `signals()` -- so the list is stale until it is rebuilt.
rebuild_signal_panels(app);
schedule_layout_save(app);
}
@@ -1041,25 +1043,5 @@ GtkWidget* math_row(const char* label, GtkWidget* widget, GtkWidget** row_out) {
return row;
}
GtkWidget* build_math_panel(App& app) {
// The panel's frame -- the heading row with its add button, and the list -- is a UI file; the rows are
// not, because each one is a channel and the whole list is rebuilt whenever the set changes. `make test-ui`
// checks the file's types against the GIR; the ids below are what keeps the code and the file agreeing.
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/math-panel.ui");
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "math_panel"));
GtkWidget* add = GTK_WIDGET(gtk_builder_get_object(ui, "math_add"));
app.math_list = GTK_WIDGET(gtk_builder_get_object(ui, "math_list"));
if (box == nullptr || add == nullptr || app.math_list == nullptr) {
// A missing id is a mistake in the `.ui`: the panel comes up empty rather than taking the window with
// it, and `[udpscope] math panel:` reads the difference.
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
}
g_signal_connect(add, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
add_math_channel(*static_cast<App*>(user));
}),
&app);
return box;
}
} // namespace ui_impl
} // namespace udpscope
+178 -31
View File
@@ -74,24 +74,79 @@ gboolean exit_probe_step3(gpointer user) {
a->window = nullptr; // stop the tick before the widgets go
a->dbg_side_w = gtk_widget_get_width(a->side_box);
a->dbg_side_h = gtk_widget_get_height(a->side_box);
a->dbg_signal_w = gtk_widget_get_width(a->signal_revealer);
a->dbg_signal_h = gtk_widget_get_height(a->signal_revealer);
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_revealer);
a->dbg_trigger_h = gtk_widget_get_height(a->trigger_revealer);
a->dbg_signal_w = gtk_widget_get_width(a->signal_panel);
a->dbg_signal_h = gtk_widget_get_height(a->signal_panel);
a->dbg_trigger_w = gtk_widget_get_width(a->trigger_panel);
a->dbg_trigger_h = gtk_widget_get_height(a->trigger_panel);
// The switcher's icon as it is *rendered*: a 32x16 glyph in a 32px box draws 1:1, which is what
// keeps its 2px bars crisp rather than blurred by a fractional scale. Read here and not on the
// first tick, where the window has not been allocated yet and every widget still measures 0x0.
if (a->side_switcher != nullptr) {
a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher);
a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher);
}
// The panes' own switches, and *both* directions of the sync: the header bar's trigger button is
// flipped here and the pane's switch has to follow, then the cursors' switch is driven from the
// pane and the button is what has to follow. Both are read back after the fact, so the two pairs in
// the dump are the claim: they agree, having been moved from opposite ends.
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
a->dbg_panel_switch_trigger =
a->trigger_switch != nullptr && gtk_switch_get_active(GTK_SWITCH(a->trigger_switch)) ? 1 : 0;
a->dbg_toolbar_switch_trigger =
a->trigger_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->trigger_toggle))
? 1 : 0;
if (a->cursors_switch != nullptr) {
gtk_switch_set_active(GTK_SWITCH(a->cursors_switch), TRUE);
a->dbg_panel_switch_cursors = gtk_switch_get_active(GTK_SWITCH(a->cursors_switch)) ? 1 : 0;
a->dbg_toolbar_switch_cursors =
a->cursors_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->cursors_toggle))
? 1 : 0;
a->dbg_panel_drove_toolbar = a->dbg_toolbar_switch_cursors;
}
for (GtkWidget* pill = a->side_switcher != nullptr ? gtk_widget_get_first_child(a->side_switcher)
: nullptr;
pill != nullptr; pill = gtk_widget_get_next_sibling(pill)) {
const std::function<void(GtkWidget*)> measure_icon = [&](GtkWidget* node) {
if (GTK_IS_IMAGE(node) && gtk_widget_get_mapped(node)) {
a->dbg_switcher_icon_w = std::max(a->dbg_switcher_icon_w, gtk_widget_get_width(node));
a->dbg_switcher_icon_h = std::max(a->dbg_switcher_icon_h, gtk_widget_get_height(node));
}
for (GtkWidget* c = gtk_widget_get_first_child(node); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
measure_icon(c);
}
};
measure_icon(pill);
}
// The height of one signal row, which is the thing "the channel toggle is too tall"
// was about. The revealer's own height cannot stand in for it: it also covers the
// was about. The panel's own height cannot stand in for it: it also covers the
// heading, the hint and the box margins.
if (GtkWidget* first = gtk_widget_get_first_child(a->signal_list)) {
a->dbg_signal_row_h = gtk_widget_get_height(first);
}
if (a->area != nullptr) a->dbg_frames_painted = static_cast<int>(a->area->frames_painted());
a->dbg_reset_visible = a->cursors_reset != nullptr && gtk_widget_get_visible(a->cursors_reset) ? 1 : 0;
a->dbg_signal_revealed =
gtk_revealer_get_child_revealed(GTK_REVEALER(a->signal_revealer)) ? 1 : 0;
a->dbg_trigger_revealed =
gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0;
a->dbg_side_visible = gtk_widget_get_visible(a->side_box) ? 1 : 0;
a->dbg_side_scroll_after_hide = gtk_widget_get_visible(a->side_scroll) ? 1 : 0;
a->dbg_reset_sensitive = a->cursors_reset != nullptr && gtk_widget_get_sensitive(a->cursors_reset) ? 1 : 0;
// A page, not a revealer: a stack's hidden page measures 0x0 the way an unrevealed revealer
// did, so the reading asks about *state* -- is this page the current one -- and never about size.
const char* page = a->side_stack != nullptr
? adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(a->side_stack))
: nullptr;
const int on_signals = page != nullptr && std::strcmp(page, "signals") == 0 ? 1 : 0;
a->dbg_signal_page_current =
on_signals && gtk_widget_get_first_child(a->signal_list) != nullptr ? 1 : 0;
a->dbg_trigger_sensitive = on_signals && gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0;
// `gtk_widget_get_visible` on the sidebar's GtkBox is always 1: AdwOverlaySplitView hides its
// sidebar child via `gtk_widget_set_child_visible()`, not `gtk_widget_set_visible()`. The useful
// reading is whether the split view is currently showing the sidebar panel.
const int sidebar_shown = a->side_view != nullptr &&
adw_overlay_split_view_get_show_sidebar(ADW_OVERLAY_SPLIT_VIEW(a->side_view)) ? 1 : 0;
a->dbg_side_column_after_hide = sidebar_shown;
a->dbg_switcher_visible =
a->side_switcher != nullptr && gtk_widget_get_visible(a->side_switcher) ? 1 : 0;
if (a->trigger_signal_drop != nullptr) {
GListModel* model = gtk_drop_down_get_model(GTK_DROP_DOWN(a->trigger_signal_drop));
a->dbg_trigger_items = model != nullptr ? static_cast<int>(g_list_model_get_n_items(model)) : 0;
@@ -100,9 +155,6 @@ gboolean exit_probe_step3(gpointer user) {
}
// `dbg_window_text` is kept by `sync_window_display()`, the only place that knows the span the
// window control is showing: there is no label to read it back from any more.
a->dbg_toggles_visible =
(gtk_widget_get_visible(a->signal_toggle) && gtk_widget_get_visible(a->trigger_toggle)) ? 1
: 0;
if (window != nullptr) gtk_window_destroy(GTK_WINDOW(window));
return G_SOURCE_REMOVE;
}
@@ -1116,6 +1168,89 @@ gboolean stats_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// Phase two of the tab-switch probe: the hidden page's accumulator must have advanced. A statistics
/// readout is *history*, so min/max/avg would gain a gap for exactly as long as the trigger was on
/// screen -- which is the one defect the design's "what runs on a hidden page" rule is there to
/// prevent, and the reason no frame-rate code may be gated on which tab is current.
gboolean side_tab_probe_report(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
std::uint64_t advanced = 0;
std::uint64_t min_n = 0;
bool first = true;
for (std::size_t i = 0; i < a->measure_stats.size(); ++i) {
const std::uint64_t n = a->measure_stats[i].n;
if (i < g_side_tab_n_before.size() && n > g_side_tab_n_before[i]) ++advanced;
if (first || n < min_n) {
min_n = n;
first = false;
}
}
std::fprintf(stderr,
"[side-tab-probe] page=%s rows=%zu accums=%zu advanced=%" G_GUINT64_FORMAT
" min_n=%" G_GUINT64_FORMAT "\n",
a->side_tab.c_str(), a->measures.size(), a->measure_stats.size(), advanced, min_n);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// Phase one: four measurements on the first channel, statistics on, then switch *away* from the
/// Measure tab and come back in two seconds to read what ran while it was not on screen.
gboolean side_tab_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
// One signal on, and on a plot, the way `enable_all_probe` does it and a restored session arrives.
// Both halves matter here: a signal starts disabled (enabling is what starts acquisition), and a
// channel with no trace leaves the live window *still* -- and a still window is exactly the state
// the tick is supposed to skip, so a probe that left it that way would be testing nothing. This
// probe is about the case where the window does move while the page is somewhere else.
std::size_t pick = a->model->signals().size();
{
const std::vector<SignalInfo>& signals = a->model->signals();
for (std::size_t i = 0; i < signals.size(); ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not acquired
pick = i;
break;
}
}
if (pick >= a->model->signals().size()) return G_SOURCE_REMOVE;
a->model->set_signals_enabled({pick}, true);
std::string name;
{
const std::vector<ChannelInfo>& channels = a->model->channels();
if (channels.empty()) return G_SOURCE_REMOVE;
name = channels.front().name;
a->rows->assign(0, 0, channels.front().name, channels.front().name,
signal_color_index(a->model->signals(), channels.front().signal));
}
a->relayout_pending = true;
if (a->measures.empty()) {
for (const MeasureType t : {MeasureType::Average, MeasureType::Top, MeasureType::PeakToPeak,
MeasureType::Amplitude}) {
MeasureOptions options;
options.show_on_plot = measure_can_show_on_plot(t);
a->measures.push_back({name, t, options});
}
rebuild_measures_pane(*a);
}
if (a->measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(a->measures_stats_check), TRUE);
}
select_side_tab(*a, "measure");
update_measures(*a, /*force=*/true, /*accumulate=*/true);
g_side_tab_n_before.clear();
for (const MeasureStatsAccum& s : a->measure_stats) g_side_tab_n_before.push_back(s.n);
// Away from the page the measurements are on: its widgets are now unrealized and measure 0x0, and
// the tick keeps calling `update_measures()` regardless.
select_side_tab(*a, "signals");
std::fprintf(stderr, "[side-tab-probe] page=%s accums=%zu baseline recorded\n",
a->side_tab.c_str(), g_side_tab_n_before.size());
std::fflush(stderr);
// Long enough for several throttled recomputes (one per 100 ms while the window moves).
g_timeout_add(2000, side_tab_probe_report, a);
return G_SOURCE_REMOVE;
}
gboolean pan_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr || a->rows->row_count() <= 0) return G_SOURCE_REMOVE;
@@ -2186,8 +2321,11 @@ gboolean export_probe(gpointer user) {
gboolean exit_probe_step1(gpointer user) {
App* a = static_cast<App*>(user);
// Record the panel states before the toggles are exercised.
a->dbg_trigger_revealed_default =
gtk_revealer_get_child_revealed(GTK_REVEALER(a->trigger_revealer)) ? 1 : 0;
a->dbg_trigger_sensitive_default = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0;
a->dbg_panel_switch_live =
a->trigger_switch != nullptr && gtk_widget_get_sensitive(a->trigger_switch) &&
a->cursors_switch != nullptr && gtk_widget_get_sensitive(a->cursors_switch)
? 1 : 0;
if (a->trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), TRUE);
}
@@ -2227,13 +2365,10 @@ void cursors_probe_report(App& a, const char* what) {
double my = 0.0;
const bool have_mark = a.rows->crossing_at(0, 0, false, mx, my);
std::fprintf(stderr,
"[cursors-probe] %-14s mode=%-10s on=%d pane=%d t0=%.6f t1=%.6f dt=%.6f "
"[cursors-probe] %-14s mode=%-10s on=%d pane_sensitive=%d t0=%.6f t1=%.6f dt=%.6f "
"T=[%s] lines=%d marks=%d [%s]\n",
what, kModes[mode], a.rows->cursors_visible() ? 1 : 0,
a.cursors_revealer != nullptr &&
gtk_revealer_get_reveal_child(GTK_REVEALER(a.cursors_revealer))
? 1
: 0,
a.cursors_body != nullptr && gtk_widget_get_sensitive(a.cursors_body) ? 1 : 0,
a.rows->cursor_a_x(), a.rows->cursor_b_x(),
a.rows->cursor_b_x() - a.rows->cursor_a_x(), t_text.c_str(), n,
a.rows->crossings_visible(), lines.c_str());
@@ -2498,7 +2633,7 @@ gboolean measures_probe_step(gpointer user) {
m.options.show_on_plot = measure_can_show_on_plot(m.type);
a->measures.push_back(std::move(m));
}
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->measures_toggle), true);
select_side_tab(*a, "measure");
rebuild_measures_pane(*a);
std::fprintf(stderr, "[measures-probe] added %zu measurements on '%s'\n", a->measures.size(),
signal.c_str());
@@ -4027,24 +4162,36 @@ gboolean keys_probe(gpointer user) {
before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel);
before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane);
before_after("m: measures", a->measures_toggle, AppAction::MeasuresPane);
before_after("g: math", a->math_toggle, AppAction::MathPane);
{
const std::string before_tab = a->side_tab;
run_app_action(*a, AppAction::SideTabMeasure);
const std::string after_tab = a->side_tab;
char buf[96];
std::snprintf(buf, sizeof(buf), " m: tab %s->%s |", before_tab.c_str(), after_tab.c_str());
line += buf;
}
{
const std::string before_tab = a->side_tab;
run_app_action(*a, AppAction::SideTabSignals);
const std::string after_tab = a->side_tab;
char buf[96];
std::snprintf(buf, sizeof(buf), " g: tab %s->%s |", before_tab.c_str(), after_tab.c_str());
line += buf;
}
before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor);
before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel);
// An accel must be refused while text is being typed. Focus the window entry, send 'm'
// through the real handler, and require the measurements toggle not to move.
// through the real handler, and require the side tab not to change.
int refused = -1;
if (a->window_entry != nullptr) {
gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
gtk_widget_grab_focus(a->window_entry);
const int before = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle))
? 1 : 0;
const std::string before_tab = a->side_tab;
const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0,
static_cast<GdkModifierType>(0), a);
const int after = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle))
? 1 : 0;
refused = (handled == FALSE && before == after) ? 1 : 0;
const std::string after_tab = a->side_tab;
refused = (handled == FALSE && before_tab == after_tab) ? 1 : 0;
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button));
}
char tail[64];
+182 -35
View File
@@ -24,7 +24,23 @@ void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) {
const std::vector<SignalInfo>& signals = app->model->signals();
if (index < signals.size()) name = signals[index].name;
}
// Hold a reference on the emitting widget across the rebuild. `rebuild_signal_panels` destroys
// the list that owns `obj` (this check button's row), dropping the row's reference and leaving
// only GLib's own per-emission ref. `gtk_check_button_set_active` emits both `notify::active`
// and `toggled` in sequence: GLib's ref covers only one emission at a time, so after
// `notify::active` finishes GLib unrefs the instance and, without our extra hold, that is the
// last reference -- and the subsequent `toggled` emission is then into freed memory, which is
// the two `GLib-GObject-CRITICAL` diagnostics. Taking our own ref here and releasing it from
// an idle keeps the instance alive until after `set_active` has emitted everything and returned.
g_object_ref(obj);
app->model->set_signal_enabled(index, on);
// `App::math` is the copy the session file is written from, so a math row's tick has to reach it.
// Here and not after the `if (!on)` block below: `apply_math_channels()` down there pushes
// `App::math` into the model, and a stale `enabled = true` would switch the channel straight back on.
{
const int k = math_index_for_signal(*app, name);
if (k >= 0) app->math[static_cast<std::size_t>(k)].enabled = on;
}
if (!on) {
// A trace of a signal nobody acquires draws nothing, and holds the slot a live one could take.
int gone = remove_signal_from_plots(*app, name);
@@ -48,6 +64,11 @@ void on_signal_switch(GObject* obj, GParamSpec*, gpointer user) {
}
// The enable state is part of the session (a signal can be on only as a math channel's source).
schedule_layout_save(*app);
// Release the extra reference from an idle so that `set_active`'s full emission chain --
// `notify::active` and `toggled` -- has completed before the instance can be finalized.
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
+[](gpointer) -> gboolean { return G_SOURCE_REMOVE; },
obj, g_object_unref);
}
/// The palette entry a signal owns: its position among the *plottable* signals, the
@@ -132,11 +153,110 @@ void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user) {
schedule_layout_save(app);
}
/// `App::math` is indexed by position and `signals()` by name, and the mapping between them is
/// the name: `append_math_signals_locked()` names each synthetic signal after its channel and
/// `enable_locked()` matches it back the same way.
int math_index_for_signal(App& app, const std::string& name) {
for (std::size_t i = 0; i < app.math.size(); ++i) {
if (app.math[i].name == name) return static_cast<int>(i);
}
return -1;
}
/// The `MATH` boundary in the one channel list, carrying the `+` that creates a channel. Not
/// selectable and not activatable: it is a heading, and a heading that highlights under the
/// pointer reads as a row you can plot.
GtkWidget* make_math_divider(App& app) {
GtkWidget* row = gtk_list_box_row_new();
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(row), FALSE);
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), FALSE);
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
GtkWidget* label = gtk_label_new("MATH");
gtk_widget_add_css_class(label, "dim-label");
gtk_widget_add_css_class(label, "caption-heading");
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_set_hexpand(label, TRUE);
gtk_box_append(GTK_BOX(box), label);
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
gtk_widget_add_css_class(add, "flat");
gtk_widget_set_tooltip_text(add, "add a math channel");
g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app);
gtk_box_append(GTK_BOX(box), add);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
return row;
}
/// A handler that rebuilds the signal list destroys the widget it was emitted from -- the row it
/// lives in, and that row's menu popover with it. GLib holds a reference for the duration of *one*
/// emission, and a button's own code keeps running after the handler returns, so the last reference
/// has to outlive the emission: hold one here and drop it from an idle. This is the same repair
/// `on_signal_switch()` makes, for the same reason.
static void outlive_the_rebuild(GtkWidget* widget) {
g_object_ref(widget);
g_idle_add_full(G_PRIORITY_DEFAULT_IDLE,
+[](gpointer) -> gboolean { return G_SOURCE_REMOVE; }, widget, g_object_unref);
}
/// Create a channel, then open the editor on it: a new channel is a single `Source` node with a
/// default name, which is of no use until it is given a graph.
void on_math_add_clicked(GtkButton* button, gpointer user) {
App* a = static_cast<App*>(user);
outlive_the_rebuild(GTK_WIDGET(button));
add_math_channel(*a);
if (a->math.empty()) return;
// `add_math_channel()` appended, so the new channel is the last one; `apply_math_channels()`
// inside it has already rebuilt this list, which is why the index is taken after the call.
open_math_dialog(*a, static_cast<int>(a->math.size()) - 1);
}
/// The row's menu items resolve their identity the one way the list already does: the widget
/// carries its *signal* index, and the name is read off the model under the lock.
static std::string signal_name_at(App& app, const std::size_t index) {
std::lock_guard<std::mutex> lock(app.model->mutex());
const std::vector<SignalInfo>& signals = app.model->signals();
return index < signals.size() ? signals[index].name : std::string();
}
/// Close the row's popover before anything is opened or destroyed from it: a dialog that takes
/// focus over an open popover leaves it hanging over the new window, and `Delete` removes the row
/// the popover belongs to, which cannot be left pointing at it.
static void dismiss_row_menu(GtkButton* button) {
GtkWidget* popover = gtk_widget_get_ancestor(GTK_WIDGET(button), GTK_TYPE_POPOVER);
if (popover != nullptr) gtk_widget_set_visible(popover, FALSE);
}
void on_math_edit_clicked(GtkButton* button, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(button), "udpscope-signal")));
const int k = math_index_for_signal(*a, signal_name_at(*a, index));
if (k < 0) return;
dismiss_row_menu(button);
open_math_dialog(*a, k);
}
void on_math_delete_clicked(GtkButton* button, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t index = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(button), "udpscope-signal")));
const int k = math_index_for_signal(*a, signal_name_at(*a, index));
if (k < 0) return;
dismiss_row_menu(button);
outlive_the_rebuild(GTK_WIDGET(button));
a->math.erase(a->math.begin() + k);
// Rebuilds the model's signal list, the plots and this list, and schedules the save.
apply_math_channels(*a);
}
/// One line per signal: colour chip, checkbox, name, unit, and a menu for the things
/// you touch once (information, calibration). A `GtkSwitch` and two always-visible
/// buttons made every row about twice this tall, which is what turned a four-signal
/// stream into a panel you have to scroll.
void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
if (app.signal_list == nullptr) return;
app.updating_panels = true;
GtkWidget* list = app.signal_list;
GtkWidget* child = gtk_widget_get_first_child(list);
@@ -150,6 +270,12 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
// that showed both would name one "sine (A)" and the other "sine (B)" as if they were different
// things, and give them different colours. What the row toggles, colours and drags is the signal.
std::vector<std::size_t> rows;
// The `MATH` divider goes in before the first math row, and once only: `append_math_signals_locked()`
// has already put every math channel in `signals()`, so the boundary is the one thing the deleted
// math pane contributed that the list does not already have.
bool math_divider_done = false;
// Streamed signals, counted as they are drawn: the dump reads `rows == signals + 1 + math`.
std::size_t plain = 0;
for (std::size_t i = 0; i < signals.size(); ++i) {
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
const std::string base = layer_base_name(signals[i].name);
@@ -163,6 +289,13 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
for (std::size_t i : rows) {
const SignalInfo& sig = signals[i];
// One list, with a boundary in it: a derived channel *is* a channel, and the divider carries the
// `+` that creates the first one.
if (sig.math && !math_divider_done) {
math_divider_done = true;
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
}
if (!sig.math) ++plain;
GtkWidget* row = gtk_list_box_row_new();
gtk_widget_add_css_class(row, "udpscope-compact");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
@@ -201,11 +334,13 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
GtkWidget* enable = gtk_check_button_new();
gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), app.model->signal_enabled(i));
gtk_widget_set_tooltip_text(enable, "acquire this signal");
// A derived channel is computed here, not streamed: its tick is its own switch, and it has one
// in every view -- a file view has no acquisition to start, which is the other tick's question.
gtk_widget_set_tooltip_text(enable, sig.math ? "compute this channel" : "acquire this signal");
// A file view has nothing to acquire: an event's or a file's samples are already stored, and
// "acquire this signal" is a stream's question. Hidden rather than disabled -- a greyed tick invites
// the user to look for the reason, and the reason is that this mode has no acquisition at all.
gtk_widget_set_visible(enable, app.waveform_name.empty());
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
gtk_box_append(GTK_BOX(box), enable);
@@ -230,11 +365,6 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_widget_set_margin_start(menu_box, 4);
gtk_widget_set_margin_end(menu_box, 4);
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);
// The way back from a chosen colour. The chooser itself cannot carry a "default" button, and a
// setting with no undo is a trap; it lives here rather than on the chip because a second
// gesture on a one-glyph target is not something a user would find.
@@ -260,20 +390,42 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
schedule_layout_save(*a);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), recolor);
if (sig.math) {
// A math row carries its own menu. Information and Calibrate... are a *streamed*
// signal's: calibration is applied at ingest, and a derived channel has no stream to
// describe. No rename either -- the node editor owns a math channel's name and its unit,
// and a second place to change them would be a second answer to the same question.
GtkWidget* edit = menu_button("Edit graph...", nullptr);
g_object_set_data(G_OBJECT(edit), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), edit);
gtk_box_append(GTK_BOX(menu_box), recolor);
GtkWidget* del = menu_button("Delete", nullptr);
gtk_widget_set_tooltip_text(del, "delete this math channel");
g_object_set_data(G_OBJECT(del), "udpscope-signal", GSIZE_TO_POINTER(i));
g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), del);
} else {
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 = 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",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
open_calibration(*a, GTK_WINDOW(a->window), idx);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), cal);
gtk_box_append(GTK_BOX(menu_box), recolor);
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",
G_CALLBACK(+[](GtkButton* b, gpointer user) {
App* a = static_cast<App*>(user);
const std::size_t idx = static_cast<std::size_t>(
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
open_calibration(*a, GTK_WINDOW(a->window), idx);
}),
&app);
gtk_box_append(GTK_BOX(menu_box), cal);
}
GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
@@ -297,15 +449,18 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
gtk_list_box_append(GTK_LIST_BOX(list), row);
}
// Appended when there are no math channels at all: the `+` is the only way to create the first
// one, so it cannot be conditional on one already existing.
if (!math_divider_done) gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
app.dbg_signal_plain = plain;
app.dbg_signal_rows = 0;
for (GtkWidget* c = gtk_widget_get_first_child(list); c != nullptr;
c = gtk_widget_get_next_sibling(c)) {
++app.dbg_signal_rows;
}
app.updating_panels = false;
}
void on_signals_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
gtk_revealer_set_reveal_child(GTK_REVEALER(app->signal_revealer),
gtk_toggle_button_get_active(button));
}
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y,
gpointer user) {
App* app = static_cast<App*>(user);
@@ -396,17 +551,9 @@ void rebuild_signal_panels(App& app) {
rebuild_trigger_signals(app);
refresh_window_limits(app);
// Nothing to configure before a CONFIG arrives: hide the whole column and its
// toolbar buttons rather than showing two empty panels. The first CONFIG
// reveals the signal panel (the trigger panel stays behind its own button).
// Nothing to configure before a CONFIG arrives: hide the whole column.
update_side_visibility(app);
if (have_signals && !app.signals_panel_initialised) {
app.signals_panel_initialised = true;
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), TRUE);
}
if (!have_signals) {
app.signals_panel_initialised = false;
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.signal_toggle), FALSE);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), FALSE);
}
app.signal_generation = app.model->generation();
+32 -2
View File
@@ -195,13 +195,19 @@ void on_trigger_toggled(GtkToggleButton* button, gpointer user) {
if (on && signal >= 0) {
app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
}
gtk_revealer_set_reveal_child(GTK_REVEALER(app->trigger_revealer), on);
// Disabled, not hidden: a panel you can still see is a panel you can read, and the settings you
// are about to switch back on must not vanish while you look for them. The *body* is what goes
// insensitive and not the panel, because the panel's heading row carries the switch that turns
// this back on -- and a control inside an insensitive parent cannot be clicked.
gtk_widget_set_sensitive(app->trigger_body, on);
// The event-history button is part of trigger mode and the pane it opens is *on demand*; leaving
// trigger mode takes the button away and collapses the pane, so going back to streaming is not a
// column of stale events beside the plot. Recording is the other half of that answer, so this is
// `sync_events_availability()`'s call and not the trigger's alone.
sync_events_availability(*app);
gtk_button_set_label(GTK_BUTTON(app->arm_button), on ? "Disarm" : "Arm");
// The pane's own switch shows the same state, whichever of the two was clicked.
sync_panel_switches(*app);
}
/// The way out of trigger mode, from inside the panel that froze the view: it is
@@ -234,6 +240,8 @@ GtkWidget* build_trigger_panel(App& app) {
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/trigger-panel.ui");
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_panel"));
if (box == nullptr) return gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
// The *body* is what trigger-off disables; this panel's index into it is `trigger_body`.
app.trigger_body = box;
app.trigger_signal_drop = gtk_drop_down_new(G_LIST_MODEL(gtk_string_list_new(nullptr)), nullptr);
g_signal_connect(app.trigger_signal_drop, "notify::selected",
@@ -420,7 +428,29 @@ GtkWidget* build_trigger_panel(App& app) {
&app);
}
sync_trigger_rows(app);
return box;
// The heading row, and the switch that is the *same* control as the header bar's button. Built here
// rather than in the file because it must not go insensitive with the body: turning trigger mode
// back on would otherwise mean finding the toolbar button that greyed the panel out.
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_margin_top(head, 8);
gtk_widget_set_margin_start(head, 8);
gtk_widget_set_margin_end(head, 8);
GtkWidget* title = gtk_label_new("Trigger");
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_widget_set_hexpand(title, TRUE);
gtk_box_append(GTK_BOX(head), title);
app.trigger_switch = gtk_switch_new();
gtk_widget_set_valign(app.trigger_switch, GTK_ALIGN_CENTER);
gtk_widget_set_tooltip_text(app.trigger_switch,
"trigger mode, the same switch the header bar carries");
g_signal_connect(app.trigger_switch, "notify::active", G_CALLBACK(on_panel_switch_toggled), &app);
gtk_box_append(GTK_BOX(head), app.trigger_switch);
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_box_append(GTK_BOX(outer), head);
gtk_box_append(GTK_BOX(outer), box);
return outer;
}
} // namespace ui_impl