ui: the math editor's window, identity row and canvas are a .ui file

The second half of this plan row, and the half of that dialog which is template.
src/ui/math-editor.ui holds the window (its size and modality), the box around it,
the identity row -- the name and the unit -- and the body with the canvas in it.

What stays in code is the drawing: the canvas is a GtkDrawingArea the file *declares*
and the code draws, with the gestures that make it a graph editor, and the parameter
form follows which node is selected. The palette and the buttons go next.

The builder is held on `App` and cleared on the *next* opening rather than with the
window: the window belongs to the builder's object list, so freeing the builder under a
live window frees the window with it. That is the source form's pattern, and the probe's
close-then-reopen path is what exercises it -- `reopened: dialog=ok, nodes=2`.

The identity row is read back too, since a `.ui` can hold the widget but not the value:
`dialog build: ... name='math3' unit='' ...`, the entries having been filled from the
channel being edited. check_ui is at 12 ok, 0 failed.
This commit is contained in:
Martino Ferrari committed 2026-10-12 15:57:43 +02:00
1 parent 90a17eb186
commit 1a02946656
7 files changed
+123 -36

No files matched your search

+4 -1
View File
@@ -39,7 +39,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the `math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
anywhere in 78--101 ns/slot is one reading of the same thing. anywhere in 78--101 ns/slot is one reading of the same thing.
The probe also asserts the new editor mechanics. Each line carries `(want ...)` and the reading The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
channel being edited, so a row that stopped being the one the code writes shows there. Each line carries
`(want ...)` and the reading
taken from a real run is the reference: taken from a real run is the reference:
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the * `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
output square straddles the box edge, so a hit test that recovers the node by a box test misses output square straddles the box edge, so a hit test that recovers the node by a box test misses
@@ -74,6 +74,11 @@ needs a probe that proves it *loads* and that its contents match whatever drives
| `src/ui/ui_layout.cpp` (~20 kB) | the layout editor's overlay buttons, which are positioned by code | only the buttons are template | | `src/ui/ui_layout.cpp` (~20 kB) | the layout editor's overlay buttons, which are positioned by code | only the buttons are template |
| `build_toolbar` (`src/ui.cpp`, ~212 lines) | the header bar's layout, with every button still wired in code | delicate: the `[udpscope] header:` probe reads its geometry | | `build_toolbar` (`src/ui.cpp`, ~212 lines) | the header bar's layout, with every button still wired in code | delicate: the `[udpscope] header:` probe reads its geometry |
**Done since, one row per commit:** the source chooser's window (`0a64e9e`), the measurements panel
(`d344361`, `07fcf61`), the trigger panel (`1ccc70c`), the signals panel (`c37fbdd`), the math panel
(`90a17eb`) and the math editor's window, identity row and canvas. What is left of that row is the palette
and the buttons around the canvas, which stay in code for the reason the canvas's *drawing* does.
**Done since:** the sidebar is an `AdwOverlaySplitView` (`src/ui/main-window.ui`) rather than a paned's end **Done since:** the sidebar is an `AdwOverlaySplitView` (`src/ui/main-window.ui`) rather than a paned's end
child, so its width is its own 320 px and it overlays the plot instead of collapsing to zero — see child, so its width is its own 320 px and it overlays the plot instead of collapsing to zero — see
`[sidebar-probe]`. `[sidebar-probe]`.
+34 -32
View File
@@ -1943,11 +1943,29 @@ void open_math_dialog(App& app, int edit) {
} }
d->selected = d->output; d->selected = d->output;
GtkWidget* window = gtk_window_new(); // The window and its identity row are a `.ui` file (`src/ui/math-editor.ui`). The canvas, the palette and
d->window = window; // the parameter form below them stay in code: the canvas is drawn rather than declared, and the form
// follows which node is selected.
g_clear_object(&app.math_dialog_ui);
app.math_dialog_ui = gtk_builder_new_from_resource("/org/udpscope/ui/math-editor.ui");
GtkBuilder* ui = app.math_dialog_ui;
d->window = GTK_WIDGET(gtk_builder_get_object(ui, "math_window"));
GtkWidget* outer = GTK_WIDGET(gtk_builder_get_object(ui, "math_outer"));
GtkWidget* head = GTK_WIDGET(gtk_builder_get_object(ui, "math_head"));
d->name = GTK_WIDGET(gtk_builder_get_object(ui, "math_name"));
d->unit = GTK_WIDGET(gtk_builder_get_object(ui, "math_unit"));
if (d->window == nullptr || outer == nullptr || head == nullptr || d->name == nullptr ||
d->unit == nullptr) {
// A missing id is a mistake in the `.ui`. There is no editor to open, so this says so and gives up
// rather than building the rest of a window around a hole; `[math-probe] editor` reads the difference.
std::fprintf(stderr, "[math] editor: math-editor.ui is missing an id this code reads\n");
delete d;
return;
}
GtkWidget* window = d->window;
gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel"); gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel");
gtk_window_set_default_size(GTK_WINDOW(window), 720, 420); // The dialog's own state, freed with the window: a plain local here would leak the graph and every drag
gtk_window_set_modal(GTK_WINDOW(window), true); // it is halfway through on each opening.
g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d, g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d,
+[](gpointer p) { delete static_cast<MathDialog*>(p); }); +[](gpointer p) { delete static_cast<MathDialog*>(p); });
g_signal_connect(window, "close-request", G_CALLBACK(+[](GtkWindow* w, gpointer) { g_signal_connect(window, "close-request", G_CALLBACK(+[](GtkWindow* w, gpointer) {
@@ -1959,35 +1977,22 @@ void open_math_dialog(App& app, int edit) {
}), }),
nullptr); nullptr);
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); // The two values the file cannot hold: what this channel is called and the unit its readouts carry. The
gtk_widget_set_margin_top(outer, 10); // name is also how another graph refers to this channel as a source, which is why it takes the slack.
gtk_widget_set_margin_bottom(outer, 10);
gtk_widget_set_margin_start(outer, 10);
gtk_widget_set_margin_end(outer, 10);
gtk_window_set_child(GTK_WINDOW(window), outer);
// The channel's own identity: the name is also how another graph names it as a source.
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
d->name = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(d->name), "name");
if (edit >= 0 && edit < static_cast<int>(app.math.size())) { if (edit >= 0 && edit < static_cast<int>(app.math.size())) {
gtk_editable_set_text(GTK_EDITABLE(d->name), app.math[static_cast<std::size_t>(edit)].name.c_str()); gtk_editable_set_text(GTK_EDITABLE(d->name), app.math[static_cast<std::size_t>(edit)].name.c_str());
}
d->unit = gtk_entry_new();
gtk_entry_set_placeholder_text(GTK_ENTRY(d->unit), "unit (optional)");
if (edit >= 0 && edit < static_cast<int>(app.math.size())) {
gtk_editable_set_text(GTK_EDITABLE(d->unit), app.math[static_cast<std::size_t>(edit)].unit.c_str()); gtk_editable_set_text(GTK_EDITABLE(d->unit), app.math[static_cast<std::size_t>(edit)].unit.c_str());
} }
gtk_widget_set_hexpand(d->name, true);
gtk_box_append(GTK_BOX(head), d->name);
gtk_box_append(GTK_BOX(head), d->unit);
gtk_box_append(GTK_BOX(outer), head);
GtkWidget* body = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10); // The body and the canvas are the file's as well; what is code is the *drawing* -- the function below and
d->canvas = gtk_drawing_area_new(); // the gestures under it, which are what make the area a graph editor rather than a picture.
gtk_widget_set_size_request(d->canvas, 420, 300); GtkWidget* body = GTK_WIDGET(gtk_builder_get_object(ui, "math_body"));
gtk_widget_set_hexpand(d->canvas, true); d->canvas = GTK_WIDGET(gtk_builder_get_object(ui, "math_canvas"));
gtk_widget_set_vexpand(d->canvas, true); if (body == nullptr || d->canvas == nullptr) {
std::fprintf(stderr, "[math] editor: math-editor.ui is missing its body or canvas\n");
delete d;
return;
}
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(d->canvas), math_canvas_draw, d, nullptr); gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(d->canvas), math_canvas_draw, d, nullptr);
GtkGesture* click = gtk_gesture_click_new(); GtkGesture* click = gtk_gesture_click_new();
g_signal_connect(click, "pressed", G_CALLBACK(+[](GtkGestureClick* g, int, double x, double y, g_signal_connect(click, "pressed", G_CALLBACK(+[](GtkGestureClick* g, int, double x, double y,
@@ -2028,8 +2033,6 @@ void open_math_dialog(App& app, int edit) {
g_signal_connect(keys, "key-pressed", G_CALLBACK(math_canvas_key), d); g_signal_connect(keys, "key-pressed", G_CALLBACK(math_canvas_key), d);
gtk_widget_add_controller(d->canvas, keys); gtk_widget_add_controller(d->canvas, keys);
gtk_box_append(GTK_BOX(body), d->canvas);
// The form: every row exists from the start and is shown or hidden per op, so no rebuild can // The form: every row exists from the start and is shown or hidden per op, so no rebuild can
// invalidate a callback's handle. // invalidate a callback's handle.
GtkWidget* form = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); GtkWidget* form = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
@@ -2181,8 +2184,7 @@ void open_math_dialog(App& app, int edit) {
gtk_box_append(GTK_BOX(buttons), fit); gtk_box_append(GTK_BOX(buttons), fit);
gtk_box_append(GTK_BOX(buttons), tidy); gtk_box_append(GTK_BOX(buttons), tidy);
gtk_box_append(GTK_BOX(form), buttons); gtk_box_append(GTK_BOX(form), buttons);
gtk_box_append(GTK_BOX(body), form); gtk_box_append(GTK_BOX(body), form); // the form is the body's second child; the canvas is the file's
gtk_box_append(GTK_BOX(outer), body);
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* cancel = gtk_button_new_with_label("Cancel"); GtkWidget* cancel = gtk_button_new_with_label("Cancel");
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!--
The math channel editor's window and its identity row: the name the channel is called, and the unit its
readouts carry.
The canvas below them is *declared* here but *drawn* by code (`GtkDrawingArea`), and the palette, the
parameter form and the buttons around it are code as well, because the form follows the selection: that is
the half of this dialog which is not template. The row here is the half that is.
The window's title is not here: it says which mode the editor is in ("Edit math channel" against "New math
channel"), which the code that opens it knows and a file cannot.
The name takes the slack and the unit does not: the name is also how another graph refers to this channel
as a source, so it is the one that should grow as the window does.
-->
<interface>
<object class="GtkWindow" id="math_window">
<property name="default-width">720</property>
<property name="default-height">420</property>
<property name="modal">1</property>
<child>
<object class="GtkBox" id="math_outer">
<property name="orientation">vertical</property>
<property name="spacing">8</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<child>
<object class="GtkBox" id="math_head">
<property name="orientation">horizontal</property>
<property name="spacing">8</property>
<child>
<object class="GtkEntry" id="math_name">
<property name="placeholder-text" translatable="yes">name</property>
<property name="hexpand">1</property>
</object>
</child>
<child>
<object class="GtkEntry" id="math_unit">
<property name="placeholder-text" translatable="yes">unit (optional)</property>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="GtkBox" id="math_body">
<property name="orientation">horizontal</property>
<property name="spacing">10</property>
<child>
<object class="GtkDrawingArea" id="math_canvas">
<property name="width-request">420</property>
<property name="height-request">300</property>
<property name="hexpand">1</property>
<property name="vexpand">1</property>
</object>
</child>
</object>
</child>
</object>
</interface>
+1
View File
@@ -18,6 +18,7 @@
<file preprocess="xml-stripblanks">cursors-panel.ui</file> <file preprocess="xml-stripblanks">cursors-panel.ui</file>
<file preprocess="xml-stripblanks">measures-panel.ui</file> <file preprocess="xml-stripblanks">measures-panel.ui</file>
<file preprocess="xml-stripblanks">math-panel.ui</file> <file preprocess="xml-stripblanks">math-panel.ui</file>
<file preprocess="xml-stripblanks">math-editor.ui</file>
<file preprocess="xml-stripblanks">trigger-panel.ui</file> <file preprocess="xml-stripblanks">trigger-panel.ui</file>
<file preprocess="xml-stripblanks">signals-panel.ui</file> <file preprocess="xml-stripblanks">signals-panel.ui</file>
</gresource> </gresource>
+4
View File
@@ -246,6 +246,10 @@ struct App {
GtkWidget* math_toggle = nullptr; GtkWidget* math_toggle = nullptr;
GtkWidget* math_revealer = nullptr; GtkWidget* math_revealer = nullptr;
GtkWidget* math_list = nullptr; GtkWidget* math_list = nullptr;
/// The math editor's builder. The editor's window belongs to the builder's object list, so the builder is
/// held for as long as the window is up and cleared on the *next* opening rather than with the window --
/// freeing it under a live window frees the window with it. That is the source form's pattern.
GtkBuilder* math_dialog_ui = nullptr;
GtkWidget* math_dialog = nullptr; GtkWidget* math_dialog = nullptr;
/// One value label per measure, in `measures` order; rebuilt with the list. /// One value label per measure, in `measures` order; rebuilt with the list.
std::vector<GtkWidget*> measure_labels; std::vector<GtkWidget*> measure_labels;
+10 -3
View File
@@ -389,6 +389,12 @@ gboolean math_probe(gpointer user) {
const std::size_t built = ed->nodes.size(); const std::size_t built = ed->nodes.size();
const double gain_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().gain; const double gain_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().gain;
const double offset_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().offset; const double offset_read = ed->nodes.empty() ? 0.0 : ed->nodes.back().offset;
// The identity row, which the file cannot hold values for: the entries were filled from the channel
// being edited, so reading them back is what shows the row is the same one the code writes.
const std::string name_read =
ed->name != nullptr ? gtk_editable_get_text(GTK_EDITABLE(ed->name)) : "-";
const std::string unit_read =
ed->unit != nullptr ? gtk_editable_get_text(GTK_EDITABLE(ed->unit)) : "-";
on_math_apply(nullptr, ed); // frees the dialog: nothing below may touch `ed` on_math_apply(nullptr, ed); // frees the dialog: nothing below may touch `ed`
const std::size_t saved = a->math.empty() const std::size_t saved = a->math.empty()
? 0u ? 0u
@@ -397,9 +403,10 @@ gboolean math_probe(gpointer user) {
a->math.empty() ? -1 : a->math[static_cast<std::size_t>(index)].output; a->math.empty() ? -1 : a->math[static_cast<std::size_t>(index)].output;
std::fprintf(stderr, std::fprintf(stderr,
"[math-probe] dialog build: nodes=%zu, output after add=%d (want %zu); " "[math-probe] dialog build: nodes=%zu, output after add=%d (want %zu); "
"reading back gain=%.2f offset=%.2f; saved nodes=%zu output=%d\n", "reading back gain=%.2f offset=%.2f; name='%s' unit='%s'; "
built, output_after_add, built - 1, gain_read, offset_read, saved, "saved nodes=%zu output=%d\n",
output_saved); built, output_after_add, built - 1, gain_read, offset_read, name_read.c_str(),
unit_read.c_str(), saved, output_saved);
} }
} }
// Closed and reopened: `gtk_window_destroy()` does not emit `close-request`, so a dialog closed // Closed and reopened: `gtk_window_destroy()` does not emit `close-request`, so a dialog closed