src/ui/main-window.ui holds the window, its two panes, the plot column with the status line under it, and the sidebar's scroller. What stays in code is what depends on the stream: the events pane, the two plot areas, the sidebar's panels and the status popover, each put into a slot by id. The header bar is deliberately not in the file. It is the window's own titlebar, every button on it is wired to an action, and an AdwApplicationWindow refuses a titlebar outright -- a fatal Adwaita-ERROR rather than a warning that leaves the window merely ugly -- so the window is a GtkApplicationWindow and the header bar stays where it was. gtk_paned_set_position() is the other thing that had to stay in code, and the reason is order rather than shape: it clamps against its children *now*, so stating it before the sidebar's panels exist moved the plot column by ~180 px. The layout, events and box-zoom probes read what they read before this change, and the header reads window_button_w=130 bar_w=756 as it always has -- checked by building the previous commit and running the same probe against it, because the layout numbers in the notes turned out to be stale from an earlier session. Five UI files now.
276 lines
24 KiB
Markdown
276 lines
24 KiB
Markdown
# Invariants: GTK traps, the status bar, the icons and the help
|
|
|
|
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages.
|
|
|
|
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
|
|
|
- **The status line's per-second fields are measured once a second, not per frame.**
|
|
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
|
|
tick as the channel dump; the status line only formats what is already there. The
|
|
badge's markup is likewise rewritten on a change of phase (twice a second), never on
|
|
all sixty frames.
|
|
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
|
|
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
|
|
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
|
|
serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot
|
|
and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns.
|
|
That is why the trigger panel's spin buttons and its dropdowns have separate thin entry
|
|
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
|
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
|
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
|
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
|
|
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
|
|
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
|
back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading
|
|
into the window entry and picks the unit for it, and the late notify had
|
|
`on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into
|
|
499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection
|
|
equal to it is ours, since GTK only notifies on a change. Any new control that both writes
|
|
and reads a `GtkDropDown` needs the same shape; the flag alone is not enough.
|
|
- **The header bar's buttons are icon-only**, so headless probes must reach them
|
|
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
|
|
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
|
|
`measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed
|
|
is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into
|
|
an internal box), and the cursors pane owns its own
|
|
**Reset** button rather than the overflow menu owning one for it. The dump prints
|
|
`panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget
|
|
tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the
|
|
pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally
|
|
settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and
|
|
one of them with reality): it is computed on the first *tick*, because the dump
|
|
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
|
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
|
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
|
|
- **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`.
|
|
- **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"`,
|
|
`"plus minus"` (a space-separated pair is the "either of these" form the label draws as
|
|
`+ / -`), never the prose `"Ctrl+E"` or `"+ / -"`. Neither GTK's syntax nor libadwaita's
|
|
knows a **wheel or a drag**: `"Wheel"` costs a `Failed to parse` warning and draws an empty
|
|
cell. Those six rows therefore carry `accel = ""` and put the prose in `subtitle`, which is
|
|
the one field that is free text. A row added with a parse error is otherwise silent — the
|
|
overview still opens and the row is simply blank — so read the probe run's stderr for
|
|
`Failed to parse` after touching either table.
|
|
- **The status bar is two tiers, and the dump reads the detail one.** `format_glance()` writes
|
|
the bar and `format_detail()` writes both the popover's prose and `App::status_detail`, which
|
|
is what `exit_probe_step3`/`exit_probe_step2` copy into `dbg_mode_live`/`dbg_mode_triggered`.
|
|
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`,
|
|
`plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce
|
|
every documented `mode …:` assertion to four fields. The glance line's state field is **one
|
|
word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`,
|
|
`disarmed`) for the layout reason above; a word **wider than the widest already there**, or a
|
|
field whose width tracks the stream, breaks the row the plot is laid out under. The set may
|
|
grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the
|
|
check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and
|
|
sits **ahead of every other branch**, including the display mode's: with no signal list, every
|
|
other word claims something that does not exist yet. The `mode` prose branches the same way for
|
|
the same reason, which is what keeps the two tiers from disagreeing.
|
|
- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the
|
|
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
|
|
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
|
|
the dump is the assertion.
|
|
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
|
|
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is
|
|
exactly **one `GtkWindow`** among the three — the paged help window. It closes with
|
|
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
|
|
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
|
|
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
|
|
next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`)
|
|
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
|
|
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
|
|
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
|
|
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
|
|
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is
|
|
the shape that prevents the double-open defect across all three.
|
|
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
|
|
configure time (so `add_executable` can name the file) and again on every build through
|
|
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
|
|
`git` absent leaves `kCommit` at `"unknown"` and the build succeeds. The About dialog and the
|
|
`[help]` probe both read `kVersion`, `kCommit` and `kTimestamp` from it; `settings=` in the
|
|
probe reads `config_dir()`, which is the path the session file is written to.
|
|
- **The keyboard zoom's modifiers are not the wheel's.** `+`/`-` with **Ctrl** restricts to x
|
|
and with **Alt** to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The
|
|
two cannot agree: Shift is unusable on the keyboard path because `key_from()` folds `'+'` and
|
|
`'='` onto `kKeyZoomIn` — `+` *is* Shift+`=` on common layouts, so a bare zoom-in already
|
|
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
|
|
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
|
|
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
|
|
- **A help page is one `.txt`, one `<file>` line in the GResource manifest, and one `kPages`
|
|
row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its
|
|
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
|
|
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
|
|
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
|
|
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
|
|
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
|
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
|
with a silent gap where its screenshots should be — which is what the first version did.
|
|
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
|
|
Putting the events pane in a paned is what makes its width the user's to drag -- but with the
|
|
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
|
|
while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that
|
|
says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before*
|
|
zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is
|
|
still needed, or the minimum would clamp even a zero position.
|
|
- **An unrevealed `GtkRevealer` measures 0x0 and is still given the leftover width.** `reveal_child
|
|
= FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it
|
|
everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a
|
|
310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel
|
|
window. Every vertical revealer in the side panel hides this, because there the leftover goes down
|
|
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.
|
|
- **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
|
|
`Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is
|
|
*not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does
|
|
not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the
|
|
widgets go (the exit probe says so in a comment: "stop the tick before the widgets go"), so a handler
|
|
that can fire during teardown checks that first, rather than asking GTK whether a pointer is still a
|
|
widget.
|
|
- **A `GtkPicture`'s natural size *is* its content, so a size request on it is a floor, not a cap.**
|
|
A 480x160 texture in a row of a list claims the row's whole width and squeezes the text beside it
|
|
out of the row -- the box gives a non-expanding child its natural size and grows it further when
|
|
the expanding child has nothing left to take. Give the picture a **frame** whose size request equals
|
|
the **scaled texture's** size (natural == minimum, which is what stops the box growing it), and
|
|
scale the texture once at decode: it is also what keeps a per-row cache at 12 KB per picture rather
|
|
than 300.
|
|
- **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's
|
|
picture across `refresh_events_pane()`, which removes every row first: the next reader of that
|
|
pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals
|
|
read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it.
|
|
- **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image
|
|
loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the
|
|
picture keeps a *paint-able* and takes an allocation, so a probe that checks
|
|
`gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event
|
|
pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this
|
|
thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says
|
|
so. A picture that must be on screen is also a picture with a **height request**: `can_shrink`
|
|
alone asks for no height at all in a box.
|
|
- **Unparenting a popover inside its own `closed` emission is a use-after-free.** Dismissals go
|
|
through `gtk_popover_popdown()`, which emits `closed`; a handler that unparents the popover there
|
|
drops the reference the parent held, the widget dies, and GTK's own popdown path then runs on freed
|
|
memory — `gtk_popover_get_autohide: assertion 'GTK_IS_POPOVER (popover)' failed`, on every menu
|
|
entry that dismisses its popover, which is every entry there is. `on_popover_closed()` therefore
|
|
defers the unparent by one idle and holds a reference across it
|
|
(`g_idle_add_full(..., g_object_ref(popover), g_object_unref)`), so the popover outlives the
|
|
emission that dismissed it.
|
|
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
|
|
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
|
|
Caps Lock is on.
|
|
- **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live in
|
|
`src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`,
|
|
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`. `preprocess="xml-stripblanks"`
|
|
catches a *malformed* file at build time — and that includes an XML rule easy to break by hand: **a
|
|
comment may not contain a double hyphen**, so prose written the way every C++ comment in this project
|
|
is written (` -- like this -- `) fails the build with a caret under the offending character. Use an em
|
|
dash in a UI file. `preprocess="xml-stripblanks"`
|
|
catches a *malformed* file at build time -- and nothing else does, which is why it is on every file in
|
|
every bundle. What it cannot catch is a file that is valid XML but wrong for GTK: `GtkBuilder` reports
|
|
that with a **`Gtk-ERROR`, which aborts the process** by default, so a window built by hand from bad
|
|
markup takes the application down rather than failing to appear. The `[help]` probe opening every
|
|
window is what catches that. Two specifics already paid for: a `GIcon` property takes an **object**, not
|
|
a name (`<property name="icon">foo</property>` is read as a reference to another object and fails with
|
|
"Object with ID foo not found" -- the icon is a nested `GThemedIcon`), and a `.ui` big enough to exceed
|
|
4095 characters needs `-Wno-overlength-strings` on its generated source, because glib-compile-resources
|
|
writes each file as one C string literal and the warning fires on generated *data* (the icon and help
|
|
bundles are small enough per file never to need it).
|
|
- **Deprecated widgets may be the right widget, as long as no deprecated *function* is called.** The
|
|
shortcut overview is a `GtkShortcutsWindow` (`gtk/deprecated/`, deprecated in 4.20) because libadwaita's
|
|
successor, `AdwShortcutsDialog`, cannot draw a mouse gesture: `AdwShortcutsItem` has no icon property at
|
|
all, so its shortcut cell can only be an accelerator, and its cell reads "No Shortcut" when there is
|
|
none. `GtkShortcutsShortcut` has one generic gesture type whose `icon` *is* drawn. Loading the window
|
|
from a `.ui` and presenting it as a `GtkWindow` names no deprecated function, so the build stays at its
|
|
four warnings. When such a window closes, its builder is **replaced on the next opening**, not kept: the
|
|
window belongs to the builder's object list and a destroyed `GtkWindow` cannot be presented again.
|
|
- **An `AdwDialog` is not a `GtkWindow`, and not every toplevel is a widget either.** The preferences
|
|
are an `AdwDialog`, which is a `GObject` that a *window* presents, so it cannot parent a
|
|
`GtkFileDialog`: the form keeps the window it was opened with (`PrefsForm::parent`, the caller's) and
|
|
hands that to the picker. Two things follow, and each cost a run. In its floating presentation mode an
|
|
`AdwDialog` adds a **native surface** to `gtk_window_get_toplevels()`, so that list holds things that
|
|
are neither `GtkWindow`s nor widgets -- `GTK_WINDOW()` on one is a segfault inside `gtk_window_close()`,
|
|
and `g_object_unref()` on one is a `GLib-GObject-CRITICAL`. Anything walking it must test
|
|
`GTK_IS_WIDGET()` *and* `GTK_IS_WINDOW()` first, and a probe should not walk it at all where a
|
|
cancellable will do.
|
|
- **A folder chooser outlives the dialog that opened it unless something cancels it.** The picker's
|
|
answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is
|
|
cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none
|
|
that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse.
|
|
- **An `AdwApplicationWindow` refuses a titlebar, and refuses it by *aborting*.** `gtk_window_set_titlebar()`
|
|
on one is a fatal `Adwaita-ERROR` (`is not supported for AdwApplicationWindow`), not a warning that
|
|
leaves the window merely ugly. The main window is therefore a `GtkApplicationWindow` -- an Adw window
|
|
would want its header as an `AdwToolbarView`'s top bar instead -- and `build_toolbar()`'s `GtkHeaderBar`
|
|
stays the window's own titlebar, which is also what gives the window its controls: a `GtkHeaderBar`
|
|
draws them only as a titlebar.
|
|
- **libadwaita's rows do not share one property set, and a `.ui` is where that shows.** An
|
|
`AdwEntryRow` is an `AdwPreferencesRow`: it has a `title` and **no `subtitle`**. `AdwSpinRow` and
|
|
`AdwSwitchRow` are `AdwActionRow`s and have both. A hint about an entry -- "empty = unicast" --
|
|
therefore belongs in the *group's* `description`, which is the better place for it anyway: a
|
|
placeholder vanishes as soon as there is a value, and a description does not. And an `AdwDialog` has
|
|
**no `content` property**: its child is a plain `<child>`, where an `AdwToolbarView` does take
|
|
`content`. Both mistakes are a `Gtk-ERROR` at load, so both abort the process, and both are caught by
|
|
the probe that opens every window rather than by anything at build time.
|
|
- **A dialog reachable from two windows cannot own its state in either.** The preferences open from
|
|
the main window's menu *and* from the source chooser, which exists before `App` does (`main()`
|
|
chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the
|
|
caller owns — `App::prefs`, `Chooser::prefs` — and what a change *means* is a callback, because it
|
|
means different things on the two sides. Both are plain members of their owners, on the same stack
|
|
frame, so nothing can outlive the frame that built it.
|
|
- **A transient window is destroyed before its parent.** `chooser_finish()` destroys the preferences
|
|
window before the chooser, and nulls the pointer *before* the destroy so `close-request` cannot
|
|
re-enter a dead window. GTK does not promise to take a transient down with its parent, and a window
|
|
whose handlers point into a returned stack frame is how this project already earned one
|
|
use-after-free.
|