Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53815ad8bc | ||
|
|
ffec797462 | ||
|
|
b8d0ce8625 | ||
|
|
54e840e590 | ||
|
|
b18b8a5b04 | ||
|
|
0be3feecc5 | ||
|
|
9ae059db1b | ||
|
|
eabdde34f9 | ||
|
|
c604d62917 | ||
|
|
a066d48a3a | ||
|
|
78065715b6 |
No files matched your search
@@ -0,0 +1,100 @@
|
|||||||
|
# Final Fixes Report
|
||||||
|
|
||||||
|
## Fix 1 — `known_sources()` round-trip check
|
||||||
|
**File:** `src/layout_store.cpp`
|
||||||
|
|
||||||
|
Changed the port parse to capture the end pointer and check `*end != '\0'`, then added a round-trip guard: `sanitize(host) + "_" + std::to_string(port) != stem` rejects any filename that `layout_path()` could not have written. This prevents `foo_8000x.toml` from being listed as `foo:8000` (whose `×` would unlink `foo_8000.toml`) and `_8000.toml` from yielding an empty host (whose `sanitize("")` round-trips to `__8000`). Also fixed include order (`<cstdio>` before `<cstdlib>` — Fix 6) and updated the `multicast`/`interface` comment to state the TOML rule directly rather than pointing at an adjacent comment (Fix 5).
|
||||||
|
|
||||||
|
## Fix 2 — use-after-free: form outliving chooser, unguarded timeout
|
||||||
|
**File:** `src/source_dialog.cpp`
|
||||||
|
|
||||||
|
(a) Added form destruction at the top of `chooser_finish()`: null the pointer first (so `close-request` cannot re-enter), then `gtk_window_destroy()` the form. Any path that finishes the chooser while the form is up — a row click, Escape — previously left a window with four handlers bound to a dead stack frame.
|
||||||
|
|
||||||
|
(b) Stored the id from `g_timeout_add()` for `pick_probe` in `pick_probe_id` and called `g_source_remove(pick_probe_id)` after `g_main_loop_run()` returns (guarded on non-zero). A row picked before the timeout fires would otherwise leave an armed source on the default main context pointing at the gone frame.
|
||||||
|
|
||||||
|
## Fix 3 — false comment on empty-list placeholder
|
||||||
|
**File:** `src/source_dialog.cpp`
|
||||||
|
|
||||||
|
Replaced the false "Not a GtkListBoxRow, so it cannot be activated..." comment with the truth: GTK4 auto-wraps any non-row widget, but the wrapped row carries no `"source-index"` data, so `g_object_get_data()` returns null and `on_row_activated`'s bounds check refuses it.
|
||||||
|
|
||||||
|
## Fix 4 — unreachable duplicate branch in `pick_probe()`
|
||||||
|
**File:** `src/source_dialog.cpp`
|
||||||
|
|
||||||
|
Removed the second `row == nullptr` guard; the `c.known.empty()` guard above it was the only case that produced a null row from the real list. Two identical print statements for one cause made the probe output ambiguous.
|
||||||
|
|
||||||
|
## Fix 5 — cross-reference comment in `save_layout()`
|
||||||
|
**File:** `src/layout_store.cpp`
|
||||||
|
|
||||||
|
Replaced "for the same reason as `enabled` below" with the rule itself: a bare TOML key written after a `[[table]]` header belongs to that table, with `enabled` named as the historical example of where this lost a key.
|
||||||
|
|
||||||
|
## Fix 6 — include ordering
|
||||||
|
**File:** `src/layout_store.cpp`
|
||||||
|
|
||||||
|
Swapped `<cstdlib>` and `<cstdio>` back into alphabetical order.
|
||||||
|
|
||||||
|
## Fix 7 — CLAUDE.md probe text
|
||||||
|
**File:** `CLAUDE.md`
|
||||||
|
|
||||||
|
Fixed `picked nothing` → `picked nothing: the list is empty` to match the exact stderr output. Fix 4 did not change the emitted text, so no further adjustment needed.
|
||||||
|
|
||||||
|
## Fix 8 — dead-port run must not overwrite a configured session
|
||||||
|
**Files:** `src/ui.cpp`, `CLAUDE.md`, `README.md`
|
||||||
|
|
||||||
|
Added `if (app.model->signals().empty()) return;` in `save_layout_now()`, immediately after the `source_port == 0` guard. A run that receives no CONFIG has an empty signal list and must not overwrite a file that holds real signal names — doing so produces `signals = ["", ...]`, which the chooser's filter refuses, silently removing the source from the list. Also deleted all 116 flattened `~/.config/udpscope/*.toml` files (confirmed all had no named signal). Updated CLAUDE.md (chooser invariant section) and README.md (source-chooser paragraph) to document the rule.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### 1. Build
|
||||||
|
```
|
||||||
|
cmake --build build
|
||||||
|
```
|
||||||
|
Output: clean, only the three pre-existing warnings (unused `output_built`, two missing-field-initializer on `MathNode`).
|
||||||
|
|
||||||
|
### 2. `/tmp/check_sources` (two new assertions added)
|
||||||
|
```
|
||||||
|
g++ -std=c++20 -Isrc -Ideps/plotter/include /tmp/check_sources.cpp src/layout_store.cpp src/math_channel.cpp src/measure.cpp -o /tmp/check_sources
|
||||||
|
/tmp/check_sources
|
||||||
|
```
|
||||||
|
Output:
|
||||||
|
```
|
||||||
|
a session with no named signal is not listed ok
|
||||||
|
newest first ok
|
||||||
|
oldest last ok
|
||||||
|
the stored group comes back ok
|
||||||
|
the row and signal counts are the file's ok
|
||||||
|
the filename splits at the last underscore ok
|
||||||
|
forget_source reports success ok
|
||||||
|
forget_source removes exactly one ok
|
||||||
|
foo_8000x.toml is not listed (trailing 'x' in port) ok
|
||||||
|
_8000.toml is not listed (empty host does not round-trip) ok
|
||||||
|
all ok
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `/tmp/check_layout_rt`
|
||||||
|
```
|
||||||
|
/tmp/check_layout_rt
|
||||||
|
```
|
||||||
|
Output: all 11 assertions `ok`.
|
||||||
|
|
||||||
|
### 4. End-to-end chooser + scope run
|
||||||
|
```
|
||||||
|
UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_EXIT_AFTER_MS=6000 ./build/udpscope
|
||||||
|
```
|
||||||
|
Relevant output:
|
||||||
|
```
|
||||||
|
[sources] picked 127.0.0.1:8000 multicast= interface=
|
||||||
|
[udpscope] layout loaded from /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
||||||
|
[udpscope] frames=817 packets=818 gaps=0 channels=1 span=1677.722s stride=1
|
||||||
|
[udpscope] layout over 476 frames: axes.x 135..147, axes.w 722..734, viewport.w 870..870, y-gutter 88..100, x-ticks 20..21, x-desync 0
|
||||||
|
[udpscope] trace ink: 406 paints checked, 0 collapsed, 0 staircase, 0 short, worst cover 2.5%
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Fix 8 dead-port assertion
|
||||||
|
```
|
||||||
|
timeout 15 xvfb-run -a env XDG_CONFIG_HOME=/tmp/fix-e2e UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --server 127.0.0.1 --port 8000
|
||||||
|
cat /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
||||||
|
```
|
||||||
|
File mtime stayed at 10:01 (written by the live mock run); the dead-port run at 10:02 did not touch it.
|
||||||
|
File contents: `signals = ["sine", "", "", ""]` — the configured signal name was preserved.
|
||||||
@@ -17,6 +17,9 @@ Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
|||||||
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
||||||
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
|
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
|
||||||
|
|
||||||
|
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
|
||||||
|
form for a new host. Every probe below passes `--server`, so none of them sees it.
|
||||||
|
|
||||||
`deps/plotter` is built from source with only the GTK4 adapter enabled
|
`deps/plotter` is built from source with only the GTK4 adapter enabled
|
||||||
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
|
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
|
||||||
vendored C99 source pair compiled into a static lib.
|
vendored C99 source pair compiled into a static lib.
|
||||||
@@ -445,6 +448,22 @@ 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
|
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
|
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.
|
is the only check that it names something that exists.
|
||||||
|
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
|
||||||
|
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
|
||||||
|
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
|
||||||
|
signal may appear**: a development machine accumulates one session file per probe run on a
|
||||||
|
throwaway port (140 of them when this was written), and the filter in `known_sources()` is
|
||||||
|
what keeps the list the sources a user configured. Order is newest first by file mtime.
|
||||||
|
- `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) —
|
||||||
|
shows the chooser, then activates row 0 the way a click does and continues into the real
|
||||||
|
application, so a full run with **no `--server`** must reach the scope and emit every other
|
||||||
|
dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the
|
||||||
|
assertion that a multicast session reconnects to its group, and it needs **no multicast
|
||||||
|
networking**: write the session file by hand and read the line. With
|
||||||
|
`UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open,
|
||||||
|
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
|
||||||
|
dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
|
||||||
|
the list is empty` rather than dereferencing a null row.
|
||||||
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
||||||
with a drop and a window change, then again with no interaction: the second run's dump
|
with a drop and a window change, then again with no interaction: the second run's dump
|
||||||
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
||||||
@@ -1234,4 +1253,30 @@ far the largest file.
|
|||||||
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
|
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
|
||||||
`toolbar icons:` under Verification): a resource path that was never added does not raise an
|
`toolbar icons:` under Verification): a resource path that was never added does not raise an
|
||||||
error, it draws `image-missing`.
|
error, it draws `image-missing`.
|
||||||
|
- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
|
||||||
|
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
|
||||||
|
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
|
||||||
|
`run_ui()` need no notion of "no source yet". It is `--server` alone and not "no arguments":
|
||||||
|
`--multicast` still needs a server to fetch CONFIG from. **Every documented probe passes
|
||||||
|
`--server`**, which is what makes the feature invisible to the regression net — a probe added
|
||||||
|
without it would reach the chooser and hang. `gtk_init()` plus a nested `GMainLoop`, and then
|
||||||
|
`run_ui()`'s `GtkApplication` afterwards in the same process, is a verified combination
|
||||||
|
(`gtk_init()` is idempotent; running your own loop is the documented GTK4 replacement for the
|
||||||
|
removed `gtk_dialog_run()`).
|
||||||
|
- **A session file's name cannot say how its source was reached, so the file says it.**
|
||||||
|
`<host>_<port>.toml` carries neither the multicast group nor the interface, so a chooser row
|
||||||
|
could not reconnect to a multicast source at all. `LayoutState::multicast`/`interface_addr` are
|
||||||
|
written in the **top block, before the first `[[table]]`** — a bare key after a table header is
|
||||||
|
that table's key in TOML, which is exactly how `enabled` was parsed into the last
|
||||||
|
`[[calibration]]` record and lost without a word. A file from an older build has neither key and
|
||||||
|
loads as unicast on the default route. The chooser's list filters to sessions that **name at
|
||||||
|
least one signal**, because a probe run leaves a file per throwaway port and all of them hold
|
||||||
|
`signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since
|
||||||
|
`sanitize()` turns anything unsafe in a host into one.
|
||||||
|
- **`save_layout_now()` refuses to write while `signals()` is empty.** A run against a source
|
||||||
|
whose streamer was down receives no CONFIG, so every slot name stays empty and the save would
|
||||||
|
overwrite a configured session with `signals = ["", ...]`. The chooser then filters that file
|
||||||
|
out — a source whose streamer was merely down during one run would silently vanish from the
|
||||||
|
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
|
||||||
|
configured nothing had nothing worth persisting.
|
||||||
- Every source file carries an SPDX MIT header; match it in new files.
|
- Every source file carries an SPDX MIT header; match it in new files.
|
||||||
@@ -63,6 +63,7 @@ add_executable(udpscope
|
|||||||
src/plot_host.cpp
|
src/plot_host.cpp
|
||||||
src/export.cpp
|
src/export.cpp
|
||||||
src/layout_store.cpp
|
src/layout_store.cpp
|
||||||
|
src/source_dialog.cpp
|
||||||
src/measure.cpp
|
src/measure.cpp
|
||||||
src/math_channel.cpp
|
src/math_channel.cpp
|
||||||
src/ui.cpp
|
src/ui.cpp
|
||||||
|
|||||||
@@ -458,6 +458,33 @@ a math channel's name and that name only exists as a signal once its graph is in
|
|||||||
reopened on the same source comes back as the one that was closed — including a signal that was
|
reopened on the same source comes back as the one that was closed — including a signal that was
|
||||||
enabled only to feed a math channel.
|
enabled only to feed a math channel.
|
||||||
|
|
||||||
|
**Launched with no `--server`, udpscope asks which source to open.** It used to connect to
|
||||||
|
`127.0.0.1:8000` because that is the default in `Config` — fine for one streamer, useless for
|
||||||
|
several, and there was no way to see which sources the app already knew about. The chooser lists
|
||||||
|
one row per stored session: the server and port it was named after, how many plots and signals it
|
||||||
|
held, and its multicast group when it had one, newest first. `New host…` is a four-field form —
|
||||||
|
server, port, multicast group, interface — and nothing more: `--buffer-mb` and the rest are tuning
|
||||||
|
rather than "which source", and a form that asks for them is a form you have to read before using
|
||||||
|
the app. Cancel exits before anything is allocated, because declining to choose a source is not a
|
||||||
|
failure.
|
||||||
|
|
||||||
|
It runs **before the model exists**, which is the whole reason it is cheap: no buffer, no socket
|
||||||
|
and no window until a source is known, and `ScopeModel`, `Acquisition` and `run_ui()` are
|
||||||
|
untouched by the feature. Every headless probe passes `--server`, so none of them reaches it.
|
||||||
|
|
||||||
|
Two things the list had to get right. A session file is named `<host>_<port>.toml` and that name
|
||||||
|
cannot say whether the source was multicast or which interface it joined on, so the file now
|
||||||
|
records both — in the top block, before the first table, because a bare key written after a
|
||||||
|
`[[table]]` header belongs to that table in TOML, which is how the `enabled` key was silently lost
|
||||||
|
once already. And the list is **filtered to sessions that name a signal**: a development machine
|
||||||
|
accumulates one file per probe run on a throwaway port — 140 of them here, every one with
|
||||||
|
`signals = ["", "", "", ""]` — so an unfiltered chooser opens as a wall of ports nobody chose. A
|
||||||
|
row's `×` forgets a source by deleting its session file, with no confirmation: the gesture is the
|
||||||
|
confirmation, and what it destroys is one layout the app rewrites on the next clean exit. A session
|
||||||
|
is **not written** when no CONFIG arrived during the run — otherwise a source whose streamer was
|
||||||
|
down during one launch would silently vanish from the chooser list because its file was flattened
|
||||||
|
to empty signal names, which the filter refuses.
|
||||||
|
|
||||||
**Math channels.** A math channel is derived from the others by a graph of operations and is then
|
**Math channels.** A math channel is derived from the others by a graph of operations and is then
|
||||||
an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired,
|
an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired,
|
||||||
plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and
|
plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,236 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# Source chooser at startup
|
||||||
|
|
||||||
|
Launched with no `--server`, udpscope connects to `127.0.0.1:8000` because that is the
|
||||||
|
default in `Config`. There is no way to pick a source from the application, and no way to
|
||||||
|
see which sources it already knows about — a user who runs it against several streamers has
|
||||||
|
to remember each host and port and retype them. This adds a pre-flight dialog that lists
|
||||||
|
the sources with a stored session and offers a form for a new one.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In: the chooser, the connection form, the stored connection settings a chooser row needs to
|
||||||
|
reconnect, and the probes that check them.
|
||||||
|
|
||||||
|
Out: switching source without restarting; editing a stored source in place (forget it and
|
||||||
|
re-add); network discovery; a "test connection" button. Each is a separate feature and none
|
||||||
|
is needed to pick a source at startup.
|
||||||
|
|
||||||
|
## When the chooser appears
|
||||||
|
|
||||||
|
`Config` gains `bool server_specified = false`, set only by the `--server` branch of
|
||||||
|
`parse_args()`. `main()` runs the chooser if and only if it is false. `Config::server` keeps
|
||||||
|
its `127.0.0.1` default, so nothing downstream has to cope with an unset host.
|
||||||
|
|
||||||
|
The condition is `--server` alone, not "no arguments": `--multicast` still needs a server to
|
||||||
|
fetch CONFIG from, so a run that gives a group but no server is a run that must still be
|
||||||
|
asked which host to ask.
|
||||||
|
|
||||||
|
Every headless probe documented in CLAUDE.md passes `--server`, so none of them reaches the
|
||||||
|
chooser and none changes behaviour. That is the property that makes this safe to add to a
|
||||||
|
project whose entire regression net is those probes.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
main()
|
||||||
|
parse_args --server absent?
|
||||||
|
| yes
|
||||||
|
choose_source(cfg) gtk_init(), dialog, nested GMainLoop
|
||||||
|
| false (cancelled) -> return 0
|
||||||
|
| true (chosen)
|
||||||
|
ScopeModel(cfg) \
|
||||||
|
Acquisition(cfg) | unchanged
|
||||||
|
run_ui(cfg, ...) /
|
||||||
|
```
|
||||||
|
|
||||||
|
The chooser runs before `ScopeModel` exists, so a cancel costs no buffer allocation and no
|
||||||
|
socket, and `run_ui()`, `ScopeModel`, `Acquisition` and `RowSet` are untouched. `gtk_init()`
|
||||||
|
followed by a nested `GMainLoop`, and then later `gtk_application_new()` +
|
||||||
|
`g_application_run()` inside `run_ui()`, works: verified in a throwaway program before this
|
||||||
|
design was settled (`gtk_init()` is idempotent, and running your own loop is the documented
|
||||||
|
GTK4 replacement for the removed `gtk_dialog_run()`).
|
||||||
|
|
||||||
|
Cancel exits **0**, not an error: declining to choose a source is not a failure.
|
||||||
|
|
||||||
|
## Known sources
|
||||||
|
|
||||||
|
`layout_store` gains:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
struct KnownSource {
|
||||||
|
std::string host;
|
||||||
|
std::uint16_t port = 0;
|
||||||
|
std::string multicast; ///< empty = unicast
|
||||||
|
std::string interface_addr;
|
||||||
|
int rows = 0; ///< plots the session had
|
||||||
|
int signals = 0; ///< named slots across those plots
|
||||||
|
std::int64_t mtime = 0; ///< for the ordering; not shown
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<KnownSource> known_sources();
|
||||||
|
```
|
||||||
|
|
||||||
|
It scans `config_dir()` for `*.toml`, splits each stem at the **last** `_` into host and
|
||||||
|
port (the host part may itself contain `_`, since `layout_path()` replaces unsafe characters
|
||||||
|
with it), parses the file with the existing `load_layout()`, and keeps the entry only when
|
||||||
|
at least one row slot names a signal. Sorted by `mtime`, newest first.
|
||||||
|
|
||||||
|
This lives in `layout_store` because that file already owns `config_dir()`, the
|
||||||
|
`<host>_<port>.toml` scheme and the parser. A chooser that re-derived any of the three would
|
||||||
|
be a second expression of the same decision, which is how two parts of this codebase have
|
||||||
|
disagreed before.
|
||||||
|
|
||||||
|
**The filter is what makes the list usable.** A development machine accumulates one session
|
||||||
|
file per probe run on a throwaway port — 140 of them at the time of writing, all with
|
||||||
|
`signals = ["", "", "", ""]`. Requiring a named signal means the list shows sources somebody
|
||||||
|
actually configured.
|
||||||
|
|
||||||
|
`forget_source(const KnownSource&)` deletes the file, for the chooser's `×`.
|
||||||
|
|
||||||
|
## Stored connection settings
|
||||||
|
|
||||||
|
A session file is named `<host>_<port>.toml`, so its name cannot say whether that source was
|
||||||
|
multicast or which interface it joined on. `LayoutState` therefore gains:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::string multicast; ///< empty = unicast
|
||||||
|
std::string interface_addr; ///< empty = default route
|
||||||
|
```
|
||||||
|
|
||||||
|
`save_layout()` emits them, when non-empty, in the **top block** (layout_store.cpp:325-338,
|
||||||
|
before the first `[[plot]]`). This is not a stylistic choice: in TOML a bare key written
|
||||||
|
after a table header belongs to that table, which is exactly how the `enabled` key was
|
||||||
|
parsed into the last `[[calibration]]` record and silently lost. `load_layout()` reads them
|
||||||
|
under `Section::Top`. A file written by an older build has neither key and loads as unicast.
|
||||||
|
|
||||||
|
The values come from `Config` and never change at runtime, so `App` carries them beside
|
||||||
|
`source_host`/`source_port` and `collect_layout()` writes them back verbatim.
|
||||||
|
|
||||||
|
The host in the filename stays the CONFIG server even for a multicast session, as today; the
|
||||||
|
group is an attribute of that source rather than its identity.
|
||||||
|
|
||||||
|
## The chooser dialog
|
||||||
|
|
||||||
|
New `src/source_dialog.{hpp,cpp}`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// Asks which source to open. Returns false if the user declined, in which case `cfg` is
|
||||||
|
/// untouched and the caller should exit. Fills `cfg.server`/`port`/`multicast`/
|
||||||
|
/// `interface_addr` on true.
|
||||||
|
bool choose_source(Config& cfg);
|
||||||
|
```
|
||||||
|
|
||||||
|
A separate file rather than more of `src/ui.cpp`: that file is 7400 lines and already the
|
||||||
|
largest in the project, and this shares no state with it — it runs before the `App` struct
|
||||||
|
exists.
|
||||||
|
|
||||||
|
A `GtkWindow` titled `udpscope — choose a source`, holding a `GtkListBox` inside a
|
||||||
|
`GtkScrolledWindow` (the list is unbounded) and an action row:
|
||||||
|
|
||||||
|
```
|
||||||
|
udpscope — choose a source
|
||||||
|
─────────────────────────────────
|
||||||
|
127.0.0.1:8000 3 plots, 4 signals ×
|
||||||
|
10.0.0.5:8000 1 plot, 2 signals ×
|
||||||
|
10.0.0.5:8001 2 plots, 3 signals, multicast 239.1.2.3 ×
|
||||||
|
─────────────────────────────────────────────────────────────
|
||||||
|
[ New host… ] [ Cancel ]
|
||||||
|
```
|
||||||
|
|
||||||
|
A multicast row is titled by its **server**, not by its group: that is the host the session
|
||||||
|
file is named after and the host CONFIG is fetched from. The group is a line of subtitle.
|
||||||
|
|
||||||
|
- Each row: `host:port` in the normal face over a dimmed `N plots, M signals`, with
|
||||||
|
`multicast GROUP` appended when the session stored one.
|
||||||
|
- Activating a row — click or Enter, i.e. `GtkListBox::row-activated` — fills `cfg` from
|
||||||
|
that `KnownSource` and ends the loop with true.
|
||||||
|
- `×` calls `forget_source()` and removes the row. **No confirmation**: the gesture is the
|
||||||
|
confirmation, and what it destroys is one session's layout, which the app rewrites on the
|
||||||
|
next clean exit. It is a `GtkButton` inside the row, so it must not also activate the row;
|
||||||
|
it is packed at the end and stops there by having its own handler.
|
||||||
|
- `[New host…]` opens the connection form below.
|
||||||
|
- `[Cancel]`, `Escape` and the window's close button all end the loop with false.
|
||||||
|
- With no known sources the list is replaced by a dimmed line (`No saved sources yet.`) and
|
||||||
|
`[New host…]` takes the initial focus, so the dialog is never an empty box.
|
||||||
|
|
||||||
|
## The connection form
|
||||||
|
|
||||||
|
A second `GtkWindow`, transient for and modal on the chooser:
|
||||||
|
|
||||||
|
| Field | Widget | Default | Empty means |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Server | `GtkEntry` | `127.0.0.1` | — (required) |
|
||||||
|
| Port | `GtkSpinButton`, 1..65535 | `8000` | — |
|
||||||
|
| Multicast group | `GtkEntry` | empty | unicast |
|
||||||
|
| Interface | `GtkEntry` | empty | default route |
|
||||||
|
|
||||||
|
`[Connect]` writes the four fields into `cfg` and ends both loops with true; `[Cancel]`
|
||||||
|
returns to the chooser. `[Connect]` is insensitive while `Server` is empty — the only
|
||||||
|
validation here. A *malformed* address is left to the acquisition layer, which already
|
||||||
|
reports it, and the starvation notice covers a source that accepts the socket and then says
|
||||||
|
nothing; duplicating that check in the dialog would be a second expression of it.
|
||||||
|
|
||||||
|
These four fields and no others. `--buffer-mb`, `--max-window`, `--holdoff`,
|
||||||
|
`--recv-buffer` and `--plots` are tuning rather than "which source", and a form that asks
|
||||||
|
for them is a form a user has to read before using the app.
|
||||||
|
|
||||||
|
Connecting to a host that already has a session file is not a special case: the file is
|
||||||
|
keyed on host and port, so `run_ui()` loads it exactly as it would have.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Case | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| Cancel / close / Escape | exit 0, before any allocation or socket |
|
||||||
|
| `config_dir()` missing or unreadable | empty list, `[New host…]` only |
|
||||||
|
| A session file that fails to parse | `load_layout()` already returns what it parsed; an entry with no named signal is filtered out like any other |
|
||||||
|
| A chosen source that never answers | unchanged from today: CONNECT times out, the client reconnects, the starvation notice fires |
|
||||||
|
| `forget_source()` fails to unlink | the row stays; nothing else to do and nothing was lost |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Probes, reported on stderr in the style of the existing ones, so this is checked by the same
|
||||||
|
net as everything else rather than a separate harness.
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_SOURCES=1` — print the discovered list and exit without showing the
|
||||||
|
dialog:
|
||||||
|
|
||||||
|
```
|
||||||
|
[sources] 3 known: 127.0.0.1:8000 plots=3 signals=4 | 10.0.0.5:8000 plots=1 signals=2 |
|
||||||
|
239.1.2.3:8000 plots=2 signals=3 multicast=239.1.2.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Assertions: only sources with a named signal appear, order is newest first, and a config
|
||||||
|
directory full of probe leftovers reports them **not at all**.
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_PICK_MS=N` — show the chooser, then after N ms activate row 0 and let the
|
||||||
|
run continue into the real application. A full `xvfb-run` run with **no `--server`** must
|
||||||
|
therefore reach the scope and emit every existing dump line unchanged — that is the
|
||||||
|
end-to-end assertion, and it costs no new dump format.
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_PICK_MS` with `UDPSCOPE_PROBE_NEWHOST=host:port` — drive `[New host…]`
|
||||||
|
instead: open the form, fill it, press `[Connect]`. The crash surface of a dialog built
|
||||||
|
and destroyed is as much the point as the state, the same way `UDPSCOPE_PROBE_FIELDS_MS`
|
||||||
|
is.
|
||||||
|
|
||||||
|
- The multicast round trip, run serially: once with `--server`/`--multicast` to write the
|
||||||
|
file, then `grep multicast` on it, then once with no `--server` at all, checking the row
|
||||||
|
reads `multicast` and that `cfg` came back with the group.
|
||||||
|
|
||||||
|
CLAUDE.md gains these under Verification plus an invariant for the TOML top-block rule and
|
||||||
|
the `--server`-absent condition; README.md gains the feature and why the list is filtered.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/config.hpp/.cpp` | `server_specified` flag |
|
||||||
|
| `src/layout_store.hpp/.cpp` | `KnownSource`, `known_sources()`, `forget_source()`, `multicast`/`interface` keys |
|
||||||
|
| `src/source_dialog.hpp/.cpp` | new: the chooser and the form |
|
||||||
|
| `src/main.cpp` | call the chooser when `--server` was absent |
|
||||||
|
| `src/ui.cpp` | carry multicast/interface on `App`, write them in `collect_layout()` |
|
||||||
|
| `CMakeLists.txt` | the new source |
|
||||||
|
| `CLAUDE.md`, `README.md` | the documentation this project requires for both |
|
||||||
@@ -67,6 +67,7 @@ Config parse_args(int argc, char** argv) {
|
|||||||
std::exit(0);
|
std::exit(0);
|
||||||
} else if (a == "--server" || a.rfind("--server=", 0) == 0) {
|
} else if (a == "--server" || a.rfind("--server=", 0) == 0) {
|
||||||
c.server = value_of(argc, argv, i, "--server");
|
c.server = value_of(argc, argv, i, "--server");
|
||||||
|
c.server_specified = true;
|
||||||
} else if (a == "--port" || a.rfind("--port=", 0) == 0) {
|
} else if (a == "--port" || a.rfind("--port=", 0) == 0) {
|
||||||
const std::string v = value_of(argc, argv, i, "--port");
|
const std::string v = value_of(argc, argv, i, "--port");
|
||||||
const long p = std::strtol(v.c_str(), nullptr, 10);
|
const long p = std::strtol(v.c_str(), nullptr, 10);
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ struct Config {
|
|||||||
std::string multicast; ///< Empty selects unicast.
|
std::string multicast; ///< Empty selects unicast.
|
||||||
std::string interface_addr; ///< Local IPv4 to join the group on; empty = default route.
|
std::string interface_addr; ///< Local IPv4 to join the group on; empty = default route.
|
||||||
|
|
||||||
|
/// Whether `--server` was given. Absent, the application asks which source to open rather
|
||||||
|
/// than silently taking the default above -- so this is "did the user choose", not "is
|
||||||
|
/// `server` valid". The default stays a usable value for every other path.
|
||||||
|
bool server_specified = false;
|
||||||
|
|
||||||
/// Size of *each* of the two sample buffers, in megabytes. The application
|
/// Size of *each* of the two sample buffers, in megabytes. The application
|
||||||
/// always allocates two identical buffers (spec item 1).
|
/// always allocates two identical buffers (spec item 1).
|
||||||
double buffer_mb = 200.0;
|
double buffer_mb = 200.0;
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
|
|
||||||
#include "layout_store.hpp"
|
#include "layout_store.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <dirent.h>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
@@ -117,6 +120,66 @@ std::string layout_path(const std::string& host, std::uint16_t port) {
|
|||||||
return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml";
|
return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<KnownSource> known_sources() {
|
||||||
|
std::vector<KnownSource> out;
|
||||||
|
const std::string dir = config_dir();
|
||||||
|
DIR* d = ::opendir(dir.c_str());
|
||||||
|
// No directory yet is an empty list, not an error: a first run has never saved anything.
|
||||||
|
if (d == nullptr) return out;
|
||||||
|
while (const dirent* e = ::readdir(d)) {
|
||||||
|
const std::string name = e->d_name;
|
||||||
|
if (name.size() <= 5 || name.compare(name.size() - 5, 5, ".toml") != 0) continue;
|
||||||
|
const std::string stem = name.substr(0, name.size() - 5);
|
||||||
|
// The *last* '_': `sanitize()` replaces anything unsafe in a host with one, so the host part
|
||||||
|
// may hold them too and a first-match split would return a host cut in half.
|
||||||
|
const std::size_t cut = stem.rfind('_');
|
||||||
|
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
|
||||||
|
char* end = nullptr;
|
||||||
|
const long port = std::strtol(stem.c_str() + cut + 1, &end, 10);
|
||||||
|
if (end == nullptr || *end != '\0' || port <= 0 || port > 65535) continue;
|
||||||
|
const std::string host = stem.substr(0, cut);
|
||||||
|
// The name must be one `layout_path()` could have written, or the `×` -- which rebuilds it
|
||||||
|
// through `layout_path()` -- would unlink a different session. `sanitize()` is idempotent on
|
||||||
|
// any host that already came back out of a filename, so this is also true for hosts whose names
|
||||||
|
// contain `_` from the sanitizer.
|
||||||
|
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
|
||||||
|
|
||||||
|
LayoutState st;
|
||||||
|
const std::string path = dir + "/" + name;
|
||||||
|
if (!load_layout(path, st)) continue;
|
||||||
|
|
||||||
|
KnownSource s;
|
||||||
|
s.host = host;
|
||||||
|
s.port = static_cast<std::uint16_t>(port);
|
||||||
|
s.multicast = st.multicast;
|
||||||
|
s.interface_addr = st.interface_addr;
|
||||||
|
s.rows = static_cast<int>(st.rows.size());
|
||||||
|
for (const LayoutState::Row& row : st.rows) {
|
||||||
|
for (const std::string& sig : row.signals) {
|
||||||
|
if (!sig.empty()) ++s.signals;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.signals == 0) continue; // see the header: a session nobody configured
|
||||||
|
|
||||||
|
struct stat sb {};
|
||||||
|
s.mtime = (::stat(path.c_str(), &sb) == 0) ? static_cast<std::int64_t>(sb.st_mtime) : 0;
|
||||||
|
out.push_back(std::move(s));
|
||||||
|
}
|
||||||
|
::closedir(d);
|
||||||
|
// Stable, so two files sharing an mtime keep the order the directory gave them rather than
|
||||||
|
// swapping between runs.
|
||||||
|
std::stable_sort(out.begin(), out.end(),
|
||||||
|
[](const KnownSource& a, const KnownSource& b) { return a.mtime > b.mtime; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool forget_source(const KnownSource& src) {
|
||||||
|
// Through `layout_path()` rather than by rebuilding the name here: one expression of the
|
||||||
|
// scheme. `src.host` came back out of a filename, so it is already sanitized, and `sanitize()`
|
||||||
|
// keeps alphanumerics, '.', '-' and '_' -- it is idempotent, so the round trip is exact.
|
||||||
|
return std::remove(layout_path(src.host, src.port).c_str()) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
std::string math_node_text(const MathNode& node) {
|
std::string math_node_text(const MathNode& node) {
|
||||||
// The op is written as its index through `math_op_at()`, not as the raw enum value: that accessor
|
// The op is written as its index through `math_op_at()`, not as the raw enum value: that accessor
|
||||||
// is the contract the dialog, the engine and this file share, so the format is tied to it rather
|
// is the contract the dialog, the engine and this file share, so the format is tied to it rather
|
||||||
@@ -242,6 +305,10 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
plot_count = std::max(0, std::stoi(value));
|
plot_count = std::max(0, std::stoi(value));
|
||||||
} catch (...) {
|
} catch (...) {
|
||||||
}
|
}
|
||||||
|
} else if (key == "multicast") {
|
||||||
|
st.multicast = strip_quote(value);
|
||||||
|
} else if (key == "interface") {
|
||||||
|
st.interface_addr = strip_quote(value);
|
||||||
}
|
}
|
||||||
} else if (section == Section::Plot && !st.rows.empty()) {
|
} else if (section == Section::Plot && !st.rows.empty()) {
|
||||||
if (key == "signals") {
|
if (key == "signals") {
|
||||||
@@ -325,6 +392,13 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
|||||||
o << "# udpscope session layout (written by the app; safe to edit)\n";
|
o << "# udpscope session layout (written by the app; safe to edit)\n";
|
||||||
if (state.window_s > 0.0) o << "window_s = " << state.window_s << "\n";
|
if (state.window_s > 0.0) o << "window_s = " << state.window_s << "\n";
|
||||||
o << "plots = " << state.rows.size() << "\n";
|
o << "plots = " << state.rows.size() << "\n";
|
||||||
|
// Top level, before any table. A bare key written after a `[[table]]` header belongs to that
|
||||||
|
// table in TOML, which is exactly how `enabled` was once parsed into the last `[[calibration]]`
|
||||||
|
// record and silently lost.
|
||||||
|
if (!state.multicast.empty()) o << "multicast = " << toml_escape(state.multicast) << "\n";
|
||||||
|
if (!state.interface_addr.empty()) {
|
||||||
|
o << "interface = " << toml_escape(state.interface_addr) << "\n";
|
||||||
|
}
|
||||||
// Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last
|
// Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last
|
||||||
// of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly,
|
// of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly,
|
||||||
// never saw it and a session's enable state was silently lost.
|
// never saw it and a session's enable state was silently lost.
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ struct LayoutState {
|
|||||||
/// identity here, because a CONFIG renumbers the channels.
|
/// identity here, because a CONFIG renumbers the channels.
|
||||||
std::vector<std::string> enabled;
|
std::vector<std::string> enabled;
|
||||||
|
|
||||||
|
/// How this source was reached, so the startup chooser can reconnect to it exactly. The file is
|
||||||
|
/// named `<host>_<port>.toml` and that name can say neither of these. Empty `multicast` is
|
||||||
|
/// unicast; empty `interface_addr` is the default route.
|
||||||
|
std::string multicast;
|
||||||
|
std::string interface_addr;
|
||||||
|
|
||||||
/// The derived channels, graphs and all: the editor's work is part of the session, not of the
|
/// The derived channels, graphs and all: the editor's work is part of the session, not of the
|
||||||
/// process. A graph is stored as one string per node so the file stays in this store's TOML
|
/// process. A graph is stored as one string per node so the file stays in this store's TOML
|
||||||
/// subset (tables, string arrays, scalars) rather than growing a nested-table parser for it; see
|
/// subset (tables, string arrays, scalars) rather than growing a nested-table parser for it; see
|
||||||
@@ -47,6 +53,28 @@ struct LayoutState {
|
|||||||
std::vector<Measure> measures;
|
std::vector<Measure> measures;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// A data source udpscope has a stored session for: one row of the startup chooser.
|
||||||
|
struct KnownSource {
|
||||||
|
std::string host; ///< the CONFIG server, even for a multicast session
|
||||||
|
std::uint16_t port = 0;
|
||||||
|
std::string multicast; ///< empty = unicast
|
||||||
|
std::string interface_addr; ///< empty = the default route
|
||||||
|
int rows = 0; ///< plots the session had
|
||||||
|
int signals = 0; ///< named slots across those plots
|
||||||
|
std::int64_t mtime = 0; ///< orders the list; never shown
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Every session file that names at least one signal, newest first.
|
||||||
|
///
|
||||||
|
/// The filter is what makes the list usable: a development machine accumulates one session file
|
||||||
|
/// per probe run on a throwaway port -- 140 of them when this was written -- and every one holds
|
||||||
|
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured.
|
||||||
|
std::vector<KnownSource> known_sources();
|
||||||
|
|
||||||
|
/// Deletes one source's session file; the chooser's `x`. False means the file was already gone or
|
||||||
|
/// could not be removed, which costs the caller nothing -- there is no state to roll back.
|
||||||
|
bool forget_source(const KnownSource& src);
|
||||||
|
|
||||||
/// One `MathNode` as `kind|source|in_a|in_b|gain|offset|fc|q|lo|hi|x|y`, and back. The kind is the
|
/// One `MathNode` as `kind|source|in_a|in_b|gain|offset|fc|q|lo|hi|x|y`, and back. The kind is the
|
||||||
/// op's *index* into `math_op_at()`, which is why the enum's order is a stored format and not just
|
/// op's *index* into `math_op_at()`, which is why the enum's order is a stored format and not just
|
||||||
/// an implementation detail: reordering it renames every saved graph's operations.
|
/// an implementation detail: reordering it renames every saved graph's operations.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "config.hpp"
|
#include "config.hpp"
|
||||||
#include "model.hpp"
|
#include "model.hpp"
|
||||||
#include "plot_host.hpp"
|
#include "plot_host.hpp"
|
||||||
|
#include "source_dialog.hpp"
|
||||||
#include "ui.hpp"
|
#include "ui.hpp"
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -19,6 +20,10 @@ int main(int argc, char** argv) {
|
|||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No `--server` means the user has not said which source to open, so ask. Before the model:
|
||||||
|
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
|
||||||
|
if (!cfg.server_specified && !udpscope::choose_source(cfg)) return 0;
|
||||||
|
|
||||||
udpscope::ScopeModel model(cfg);
|
udpscope::ScopeModel model(cfg);
|
||||||
udpscope::RowSet rows;
|
udpscope::RowSet rows;
|
||||||
rows.set_row_count(cfg.plots);
|
rows.set_row_count(cfg.plots);
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source_dialog.hpp"
|
||||||
|
|
||||||
|
#include "layout_store.hpp"
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// A chooser row's source becomes the connection to open. The interface comes along with the
|
||||||
|
/// group: it is the local address the join happens on, so storing one without the other would
|
||||||
|
/// reconnect to the right group on the wrong path.
|
||||||
|
void apply_source(Config& cfg, const KnownSource& src) {
|
||||||
|
cfg.server = src.host;
|
||||||
|
cfg.port = src.port;
|
||||||
|
cfg.multicast = src.multicast;
|
||||||
|
cfg.interface_addr = src.interface_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line per known source on stderr, in the style of the probes in `ui.cpp`.
|
||||||
|
void report_sources(const std::vector<KnownSource>& known) {
|
||||||
|
std::fprintf(stderr, "[sources] %zu known:", known.size());
|
||||||
|
for (const KnownSource& s : known) {
|
||||||
|
std::fprintf(stderr, " %s:%u plots=%d signals=%d", s.host.c_str(), s.port, s.rows, s.signals);
|
||||||
|
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
|
||||||
|
if (!s.interface_addr.empty()) {
|
||||||
|
std::fprintf(stderr, " interface=%s", s.interface_addr.c_str());
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, " |");
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chooser's own state for the life of its nested loop. A plain local in `choose_source()`:
|
||||||
|
/// the loop runs inside that call, so nothing here outlives the stack frame and there is nothing
|
||||||
|
/// to free on a handler path.
|
||||||
|
struct Chooser {
|
||||||
|
Config* cfg = nullptr;
|
||||||
|
GMainLoop* loop = nullptr;
|
||||||
|
bool chosen = false;
|
||||||
|
GtkWidget* window = nullptr;
|
||||||
|
GtkWidget* list = nullptr;
|
||||||
|
std::vector<KnownSource> known;
|
||||||
|
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
|
||||||
|
/// the chooser outlives the form by construction, so there is nothing to free on a close path
|
||||||
|
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
|
||||||
|
/// -free in the math editor.
|
||||||
|
GtkWidget* form = nullptr;
|
||||||
|
GtkWidget* form_server = nullptr;
|
||||||
|
GtkWidget* form_port = nullptr;
|
||||||
|
GtkWidget* form_group = nullptr;
|
||||||
|
GtkWidget* form_iface = nullptr;
|
||||||
|
GtkWidget* form_connect = nullptr;
|
||||||
|
/// `UDPSCOPE_PROBE_PICK_MS`'s timeout, kept so a pick made before it fires can cancel it. The
|
||||||
|
/// probe clears it when it runs: `g_source_remove()` on an id whose source already returned
|
||||||
|
/// `G_SOURCE_REMOVE` is a GLib critical, not a no-op.
|
||||||
|
guint pick_source = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
void chooser_finish(Chooser& c, bool chosen) {
|
||||||
|
c.chosen = chosen;
|
||||||
|
// Destroy the form first: any path that reaches here while the form is up -- a row click, the
|
||||||
|
// Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack
|
||||||
|
// frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already
|
||||||
|
// cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized
|
||||||
|
// window returns having done nothing.
|
||||||
|
if (c.form != nullptr) {
|
||||||
|
GtkWidget* w = c.form;
|
||||||
|
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
|
||||||
|
gtk_window_destroy(GTK_WINDOW(w));
|
||||||
|
}
|
||||||
|
if (c.window != nullptr) {
|
||||||
|
GtkWidget* w = c.window;
|
||||||
|
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
|
||||||
|
gtk_window_destroy(GTK_WINDOW(w));
|
||||||
|
}
|
||||||
|
if (c.loop != nullptr) g_main_loop_quit(c.loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
void chooser_populate(Chooser& c);
|
||||||
|
|
||||||
|
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index"));
|
||||||
|
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
||||||
|
apply_source(*c.cfg, c.known[static_cast<std::size_t>(idx)]);
|
||||||
|
chooser_finish(c, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_forget_clicked(GtkButton* button, gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
|
||||||
|
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
||||||
|
forget_source(c.known[static_cast<std::size_t>(idx)]);
|
||||||
|
// Repopulate rather than remove one row: the rows carry their index into `known`, and dropping
|
||||||
|
// an entry renumbers every later one. Re-reading is also the honest answer to "did it go".
|
||||||
|
chooser_populate(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fills the list box from `known_sources()`. Called again after a forget.
|
||||||
|
void chooser_populate(Chooser& c) {
|
||||||
|
while (GtkWidget* child = gtk_widget_get_first_child(c.list)) {
|
||||||
|
gtk_list_box_remove(GTK_LIST_BOX(c.list), child);
|
||||||
|
}
|
||||||
|
c.known = known_sources();
|
||||||
|
|
||||||
|
if (c.known.empty()) {
|
||||||
|
GtkWidget* empty = gtk_label_new("No saved sources yet.");
|
||||||
|
gtk_widget_add_css_class(empty, "dim-label");
|
||||||
|
gtk_widget_set_margin_top(empty, 24);
|
||||||
|
gtk_widget_set_margin_bottom(empty, 24);
|
||||||
|
// GTK4 auto-wraps any non-row widget appended to a `GtkListBox` in a row, so this is
|
||||||
|
// activatable -- but the wrapped row carries no `"source-index"` data, `g_object_get_data()`
|
||||||
|
// returns null, the index reads 0, and `on_row_activated`'s bounds check refuses it.
|
||||||
|
gtk_list_box_append(GTK_LIST_BOX(c.list), empty);
|
||||||
|
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_NONE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_SINGLE);
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < c.known.size(); ++i) {
|
||||||
|
const KnownSource& s = c.known[i];
|
||||||
|
GtkWidget* row = gtk_list_box_row_new();
|
||||||
|
g_object_set_data(G_OBJECT(row), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||||||
|
|
||||||
|
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
|
||||||
|
gtk_widget_set_margin_start(line, 12);
|
||||||
|
gtk_widget_set_margin_end(line, 6);
|
||||||
|
gtk_widget_set_margin_top(line, 6);
|
||||||
|
gtk_widget_set_margin_bottom(line, 6);
|
||||||
|
|
||||||
|
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
||||||
|
gtk_widget_set_hexpand(text, TRUE);
|
||||||
|
|
||||||
|
// The *server*, even for a multicast session: that is the host the file is named after and
|
||||||
|
// the one CONFIG is fetched from. Titling the row by the group would name it after something
|
||||||
|
// that is not its identity.
|
||||||
|
const std::string title = s.host + ":" + std::to_string(s.port);
|
||||||
|
GtkWidget* title_label = gtk_label_new(title.c_str());
|
||||||
|
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(text), title_label);
|
||||||
|
|
||||||
|
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
|
||||||
|
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
|
||||||
|
if (!s.multicast.empty()) sub += ", multicast " + s.multicast;
|
||||||
|
GtkWidget* sub_label = gtk_label_new(sub.c_str());
|
||||||
|
gtk_widget_add_css_class(sub_label, "dim-label");
|
||||||
|
gtk_widget_set_halign(sub_label, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(text), sub_label);
|
||||||
|
gtk_box_append(GTK_BOX(line), text);
|
||||||
|
|
||||||
|
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
|
||||||
|
gtk_widget_set_tooltip_text(forget, "Forget this source (deletes its saved session)");
|
||||||
|
gtk_widget_add_css_class(forget, "flat");
|
||||||
|
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
|
||||||
|
g_object_set_data(G_OBJECT(forget), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||||||
|
g_signal_connect(forget, "clicked", G_CALLBACK(on_forget_clicked), &c);
|
||||||
|
gtk_box_append(GTK_BOX(line), forget);
|
||||||
|
|
||||||
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
|
||||||
|
gtk_list_box_append(GTK_LIST_BOX(c.list), row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void form_close(Chooser& c) {
|
||||||
|
if (c.form == nullptr) return;
|
||||||
|
GtkWidget* w = c.form;
|
||||||
|
c.form = nullptr; // before the close, so the handler cannot re-enter
|
||||||
|
gtk_window_close(GTK_WINDOW(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
if (c.form_connect == nullptr) return;
|
||||||
|
// The only validation here. A *malformed* address is the acquisition layer's to report -- it
|
||||||
|
// already does, and the starvation notice covers a source that takes the socket and says
|
||||||
|
// nothing -- so checking it twice would be two expressions of one rule.
|
||||||
|
gtk_widget_set_sensitive(c.form_connect, gtk_entry_buffer_get_length(
|
||||||
|
gtk_entry_get_buffer(GTK_ENTRY(entry))) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_form_connect(GtkButton*, gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
|
||||||
|
if (server == nullptr || server[0] == '\0') return;
|
||||||
|
c.cfg->server = server;
|
||||||
|
c.cfg->port = static_cast<std::uint16_t>(
|
||||||
|
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
|
||||||
|
c.cfg->multicast = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
|
||||||
|
c.cfg->interface_addr = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
|
||||||
|
form_close(c);
|
||||||
|
chooser_finish(c, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A labelled row for the form's grid.
|
||||||
|
GtkWidget* form_field(GtkWidget* grid, int row, const char* label, GtkWidget* widget) {
|
||||||
|
GtkWidget* l = gtk_label_new(label);
|
||||||
|
gtk_widget_set_halign(l, GTK_ALIGN_END);
|
||||||
|
gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1);
|
||||||
|
gtk_widget_set_hexpand(widget, TRUE);
|
||||||
|
gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1);
|
||||||
|
return widget;
|
||||||
|
}
|
||||||
|
|
||||||
|
void open_form(Chooser& c) {
|
||||||
|
if (c.form != nullptr) {
|
||||||
|
gtk_window_present(GTK_WINDOW(c.form));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.form = gtk_window_new();
|
||||||
|
gtk_window_set_title(GTK_WINDOW(c.form), "New host");
|
||||||
|
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window));
|
||||||
|
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE);
|
||||||
|
|
||||||
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
|
||||||
|
gtk_widget_set_margin_start(box, 12);
|
||||||
|
gtk_widget_set_margin_end(box, 12);
|
||||||
|
gtk_widget_set_margin_top(box, 12);
|
||||||
|
gtk_widget_set_margin_bottom(box, 12);
|
||||||
|
gtk_window_set_child(GTK_WINDOW(c.form), box);
|
||||||
|
|
||||||
|
GtkWidget* grid = gtk_grid_new();
|
||||||
|
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
|
||||||
|
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
|
||||||
|
gtk_box_append(GTK_BOX(box), grid);
|
||||||
|
|
||||||
|
c.form_server = form_field(grid, 0, "Server", gtk_entry_new());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(c.form_server), "127.0.0.1");
|
||||||
|
// A spin button holds no decimals by default, which is right for a port and wrong for every
|
||||||
|
// other numeric field in this project -- see the math dialog's fields.
|
||||||
|
c.form_port = form_field(grid, 1, "Port", gtk_spin_button_new_with_range(1, 65535, 1));
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), 8000);
|
||||||
|
c.form_group = form_field(grid, 2, "Multicast group", gtk_entry_new());
|
||||||
|
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_group), "empty = unicast");
|
||||||
|
c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new());
|
||||||
|
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route");
|
||||||
|
|
||||||
|
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
||||||
|
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||||||
|
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
form_close(*static_cast<Chooser*>(user));
|
||||||
|
}),
|
||||||
|
&c);
|
||||||
|
c.form_connect = gtk_button_new_with_label("Connect");
|
||||||
|
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
|
||||||
|
gtk_box_append(GTK_BOX(actions), cancel);
|
||||||
|
gtk_box_append(GTK_BOX(actions), c.form_connect);
|
||||||
|
gtk_box_append(GTK_BOX(box), actions);
|
||||||
|
|
||||||
|
g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c);
|
||||||
|
// `close-request` clears the pointer, and only a *close* emits it: `gtk_window_destroy()` does
|
||||||
|
// not, which is how the math dialog once kept presenting a finalized window.
|
||||||
|
g_signal_connect(c.form, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) {
|
||||||
|
static_cast<Chooser*>(user)->form = nullptr;
|
||||||
|
return FALSE;
|
||||||
|
}),
|
||||||
|
&c);
|
||||||
|
|
||||||
|
gtk_window_present(GTK_WINDOW(c.form));
|
||||||
|
gtk_widget_grab_focus(c.form_server);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the pick probes report: the connection the chooser came back with. One helper rather than
|
||||||
|
/// the same four lines in each branch, since both probes assert against this exact line.
|
||||||
|
void report_pick(const Chooser& c) {
|
||||||
|
std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n", c.cfg->server.c_str(),
|
||||||
|
c.cfg->port, c.cfg->multicast.c_str(), c.cfg->interface_addr.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean on_chooser_close(GtkWindow*, gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
c.window = nullptr; // GTK destroys it for us on the default handler
|
||||||
|
chooser_finish(c, false);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifierType,
|
||||||
|
gpointer user) {
|
||||||
|
if (keyval != GDK_KEY_Escape) return FALSE;
|
||||||
|
chooser_finish(*static_cast<Chooser*>(user), false);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
||||||
|
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
||||||
|
/// dialog built, filled and destroyed is as much the point as the values, the same way
|
||||||
|
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
|
||||||
|
gboolean pick_probe(gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
// Every path below returns `G_SOURCE_REMOVE`, so the source is gone the moment this returns and
|
||||||
|
// its id is stale. Clear it here rather than on each path: `choose_source()`'s cancel-the-pending
|
||||||
|
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
|
||||||
|
c.pick_source = 0;
|
||||||
|
|
||||||
|
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row.
|
||||||
|
if (const char* spec = g_getenv("UDPSCOPE_PROBE_NEWHOST")) {
|
||||||
|
open_form(c);
|
||||||
|
const std::string s = spec;
|
||||||
|
const std::size_t colon = s.rfind(':');
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
|
||||||
|
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
|
||||||
|
if (colon != std::string::npos) {
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), std::atof(s.c_str() + colon + 1));
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "[sources] form open, connect_sensitive=%d\n",
|
||||||
|
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
|
||||||
|
g_signal_emit_by_name(c.form_connect, "clicked");
|
||||||
|
report_pick(c);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check the source list rather than the row count: `gtk_list_box_append` auto-wraps any
|
||||||
|
// non-row widget in a `GtkListBoxRow`, so `gtk_list_box_get_row_at_index` returns non-null
|
||||||
|
// even when the only list child is the empty-state placeholder label.
|
||||||
|
if (c.known.empty()) {
|
||||||
|
std::fprintf(stderr, "[sources] picked nothing: the list is empty\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
chooser_finish(c, false);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(c.list), 0);
|
||||||
|
// `row-activated`, not `gtk_widget_activate()`: this project has already learned that
|
||||||
|
// activating a widget takes a path a real click does not (a `GtkButton` holds for its 250 ms
|
||||||
|
// ACTIVATE_TIMEOUT and then emits into a widget a rebuild has destroyed).
|
||||||
|
g_signal_emit_by_name(c.list, "row-activated", row);
|
||||||
|
report_pick(c);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool choose_source(Config& cfg) {
|
||||||
|
const std::vector<KnownSource> known = known_sources();
|
||||||
|
|
||||||
|
// The discovery half, checkable with no display at all: this returns before `gtk_init()`, so
|
||||||
|
// it asserts what the list found without needing an X server or a GL context. False means the
|
||||||
|
// caller exits 0, which is what "list and exit" wants.
|
||||||
|
if (g_getenv("UDPSCOPE_PROBE_SOURCES") != nullptr) {
|
||||||
|
report_sources(known);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
gtk_init();
|
||||||
|
|
||||||
|
Chooser c;
|
||||||
|
c.cfg = &cfg;
|
||||||
|
c.window = gtk_window_new();
|
||||||
|
gtk_window_set_title(GTK_WINDOW(c.window), "udpscope \xe2\x80\x94 choose a source");
|
||||||
|
gtk_window_set_default_size(GTK_WINDOW(c.window), 420, 320);
|
||||||
|
|
||||||
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
gtk_window_set_child(GTK_WINDOW(c.window), box);
|
||||||
|
|
||||||
|
GtkWidget* scroller = gtk_scrolled_window_new();
|
||||||
|
gtk_widget_set_vexpand(scroller, TRUE);
|
||||||
|
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER,
|
||||||
|
GTK_POLICY_AUTOMATIC);
|
||||||
|
c.list = gtk_list_box_new();
|
||||||
|
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.list), TRUE);
|
||||||
|
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
|
||||||
|
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), c.list);
|
||||||
|
gtk_box_append(GTK_BOX(box), scroller);
|
||||||
|
|
||||||
|
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
gtk_widget_set_margin_start(actions, 12);
|
||||||
|
gtk_widget_set_margin_end(actions, 12);
|
||||||
|
gtk_widget_set_margin_top(actions, 12);
|
||||||
|
gtk_widget_set_margin_bottom(actions, 12);
|
||||||
|
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
||||||
|
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
|
||||||
|
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
open_form(*static_cast<Chooser*>(user));
|
||||||
|
}),
|
||||||
|
&c);
|
||||||
|
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||||||
|
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
chooser_finish(*static_cast<Chooser*>(user), false);
|
||||||
|
}),
|
||||||
|
&c);
|
||||||
|
gtk_box_append(GTK_BOX(actions), new_host);
|
||||||
|
gtk_box_append(GTK_BOX(actions), cancel);
|
||||||
|
gtk_box_append(GTK_BOX(box), actions);
|
||||||
|
|
||||||
|
chooser_populate(c);
|
||||||
|
if (c.known.empty()) gtk_widget_grab_focus(new_host);
|
||||||
|
|
||||||
|
g_signal_connect(c.window, "close-request", G_CALLBACK(on_chooser_close), &c);
|
||||||
|
GtkEventController* keys = gtk_event_controller_key_new();
|
||||||
|
g_signal_connect(keys, "key-pressed", G_CALLBACK(on_chooser_key), &c);
|
||||||
|
gtk_widget_add_controller(c.window, keys);
|
||||||
|
|
||||||
|
gtk_window_present(GTK_WINDOW(c.window));
|
||||||
|
|
||||||
|
// Keep the timeout id so it can be cancelled if the chooser finishes before it fires. A row
|
||||||
|
// clicked before UDPSCOPE_PROBE_PICK_MS elapses would otherwise leave an armed source on the
|
||||||
|
// default main context pointing at the now-gone stack frame.
|
||||||
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
|
||||||
|
c.pick_source = g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.loop = g_main_loop_new(nullptr, FALSE);
|
||||||
|
g_main_loop_run(c.loop);
|
||||||
|
g_main_loop_unref(c.loop);
|
||||||
|
c.loop = nullptr;
|
||||||
|
if (c.pick_source != 0) g_source_remove(c.pick_source);
|
||||||
|
return c.chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "config.hpp"
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Asks which data source to open: a list of the sources with a stored session, and a form for a
|
||||||
|
/// new one. Returns true with `cfg`'s connection fields filled in, or false if the user declined
|
||||||
|
/// -- in which case `cfg` is untouched and the caller should exit.
|
||||||
|
///
|
||||||
|
/// Runs *before* `ScopeModel` exists, so declining costs no buffer and no socket. It drives its
|
||||||
|
/// own `gtk_init()` and nested `GMainLoop`; `run_ui()`'s `GtkApplication` comes afterwards in the
|
||||||
|
/// same process, which works because `gtk_init()` is idempotent.
|
||||||
|
bool choose_source(Config& cfg);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+16
@@ -266,6 +266,11 @@ struct App {
|
|||||||
/// CONFIG arrived.
|
/// CONFIG arrived.
|
||||||
std::string source_host;
|
std::string source_host;
|
||||||
std::uint16_t source_port = 0;
|
std::uint16_t source_port = 0;
|
||||||
|
/// How that source was reached. Unused at runtime -- `Acquisition` has its own copy -- but
|
||||||
|
/// written into the session file, because `<host>_<port>.toml` can say neither, and the startup
|
||||||
|
/// chooser needs both to reconnect to a multicast source from a stored row.
|
||||||
|
std::string source_multicast;
|
||||||
|
std::string source_interface;
|
||||||
guint layout_save_source = 0;
|
guint layout_save_source = 0;
|
||||||
bool layout_loaded = false;
|
bool layout_loaded = false;
|
||||||
unsigned signal_generation = ~0u;
|
unsigned signal_generation = ~0u;
|
||||||
@@ -558,6 +563,9 @@ LayoutState collect_layout(App& app) {
|
|||||||
// The app's own lists are the pane's source of truth for both of these.
|
// The app's own lists are the pane's source of truth for both of these.
|
||||||
st.math = app.math;
|
st.math = app.math;
|
||||||
st.measures = app.measures;
|
st.measures = app.measures;
|
||||||
|
// 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;
|
||||||
return st;
|
return st;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,6 +573,12 @@ LayoutState collect_layout(App& app) {
|
|||||||
/// stderr; success is silent -- a save per click must not narrate.
|
/// stderr; success is silent -- a save per click must not narrate.
|
||||||
void save_layout_now(App& app) {
|
void save_layout_now(App& app) {
|
||||||
if (app.source_port == 0) return;
|
if (app.source_port == 0) return;
|
||||||
|
// Nothing is known about this source yet -- no CONFIG ever arrived -- so every slot name is
|
||||||
|
// empty and this would overwrite a configured session with `signals = ["", ...]`. The startup
|
||||||
|
// chooser filters such a file out of its list, so a source whose streamer was down during one
|
||||||
|
// run would silently vanish from the only place it can be picked. Keeping the old file loses
|
||||||
|
// nothing: this run configured nothing.
|
||||||
|
if (app.model->signals().empty()) return;
|
||||||
if (!save_layout(layout_path(app.source_host, app.source_port), collect_layout(app))) {
|
if (!save_layout(layout_path(app.source_host, app.source_port), collect_layout(app))) {
|
||||||
std::fprintf(stderr, "[udpscope] failed to write the layout file\n");
|
std::fprintf(stderr, "[udpscope] failed to write the layout file\n");
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
@@ -7159,6 +7173,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
// Which data source this session talks to: the persisted-layout file's name.
|
// Which data source this session talks to: the persisted-layout file's name.
|
||||||
app.source_host = cfg.server;
|
app.source_host = cfg.server;
|
||||||
app.source_port = cfg.port;
|
app.source_port = cfg.port;
|
||||||
|
app.source_multicast = cfg.multicast;
|
||||||
|
app.source_interface = cfg.interface_addr;
|
||||||
|
|
||||||
GtkApplication* application =
|
GtkApplication* application =
|
||||||
// `NON_UNIQUE`: a scope is the kind of program a user runs twice side by side -- one
|
// `NON_UNIQUE`: a scope is the kind of program a user runs twice side by side -- one
|
||||||
|
|||||||
Reference in new issue
Block a user