feat: the host form, and the help window, are UI files
src/ui/source-form.ui is the form a source is added or edited with: an AdwDialog of AdwPreferencesGroup rows (AdwEntryRow, AdwSpinRow, AdwSwitchRow) with its action in the header bar, where the label says which mode the form is in -- Connect for a new host, Save for an existing configuration. The grid of labels and bare entries it replaces had its hints in placeholders, which vanish as soon as there is a value; they are the groups' descriptions now. src/ui/help.ui is the help window's chrome: an AdwWindow with a header bar and an AdwNavigationSplitView whose sidebar is the page list. The rows stay in C++ because the fourteen pages are data, not template. Four UI files now, 653 lines of template that used to be widget construction. Two libadwaita traps cost a run each and are in docs/claude/invariants-gtk-and-chrome.md: an AdwDialog has no content property (its child is a plain <child>, unlike an AdwToolbarView), and AdwEntryRow has no subtitle (it is an AdwPreferencesRow, not an AdwActionRow). The unused form_field() helper went with them. Every probe reading is unchanged -- [sources] form:, [sources] edit form:, [help] -- which is what refactoring a shape rather than a behaviour should look like.
This commit is contained in:
1 parent
4c56172005
commit
68743a7d7f
8 files changed
+382
-178
No files matched your search
@@ -213,6 +213,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
- **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
|
||||
@@ -244,6 +248,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
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.
|
||||
- **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
|
||||
|
||||
@@ -43,6 +43,11 @@ needs a probe that proves it *loads* and that its contents match whatever drives
|
||||
|
||||
## Already done
|
||||
|
||||
- `src/ui/source-form.ui` -- the form a source is added or edited with: an `AdwDialog` of
|
||||
`AdwPreferencesGroup` rows, with its action in the header bar where the label says which mode it is in.
|
||||
- `src/ui/help.ui` -- the help window's chrome: an `AdwWindow` with a header bar and an
|
||||
`AdwNavigationSplitView` whose sidebar is the page list. The rows stay in C++ because the fourteen
|
||||
pages are data, not template.
|
||||
- `src/ui/preferences.ui` -- the preferences, as an `AdwPreferencesDialog` with `AdwEntryRow`,
|
||||
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow` and `AdwButtonRow` rows. The Apply button is gone: a row
|
||||
applies as it changes, which is what those widgets are designed around, and `[prefs-probe]` reads the
|
||||
@@ -56,8 +61,7 @@ needs a probe that proves it *loads* and that its contents match whatever drives
|
||||
|
||||
| where | what | why it is the right next step |
|
||||
|---|---|---|
|
||||
| `src/help_window.cpp` (351 lines) | the help window's chrome as a `.ui` (the pages themselves stay resources) | small, self-contained, and its probe already opens it |
|
||||
| `src/source_dialog.cpp` (~900 lines) | the source chooser and the host form: `.ui` + `AdwDialog`, with `AdwEntryRow`/`AdwSpinRow` for the form | the form is the second-biggest hand-built surface, and its probes drive every branch of it |
|
||||
| `src/source_dialog.cpp` (the chooser's own window) | the chooser: `.ui` for the window, its two pages and its action row. Its **host form is done**; what is left is the list, the notebook and the buttons around it | the last hand-built UI outside `ui.cpp`, and the file is already open |
|
||||
| `src/ui.cpp` (5774 lines) | the main window's **skeleton** only -- the `AdwApplicationWindow`, the header bar, the plot area's placeholder, the panes' containers, the status bar -- with the custom widgets, popovers and dialogs left in code | one file, one window: converting the skeleton is reviewable, converting the whole file is not. Do it in slices (header bar, then the panes, then the trigger panel, then the left panes) with the probe suite after each |
|
||||
|
||||
Nothing in this list should be done in one pass. Each row is its own change, verified on its own.
|
||||
+51
-63
@@ -20,6 +20,13 @@ namespace {
|
||||
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
|
||||
/// `::closed` and closes with `adw_dialog_close()`.
|
||||
GtkWidget* g_help = nullptr;
|
||||
/// The help window is a `.ui` file too, so its builder, the label one page's markup goes into and the
|
||||
/// column its screenshots go into are all kept beside the window's own pointer. All four are cleared by
|
||||
/// the window's own close handler: the page switch reads them, and it cannot run after the window is
|
||||
/// gone.
|
||||
GtkBuilder* g_help_ui = nullptr;
|
||||
GtkWidget* g_help_body = nullptr;
|
||||
GtkWidget* g_help_shots = nullptr;
|
||||
AdwDialog* g_about = nullptr;
|
||||
/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window
|
||||
/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
|
||||
@@ -27,6 +34,17 @@ AdwDialog* g_about = nullptr;
|
||||
GtkWidget* g_shortcuts = nullptr;
|
||||
GtkBuilder* g_shortcuts_ui = nullptr;
|
||||
|
||||
/// The help window's own close handler, rather than the shared one: this window also owns the label a
|
||||
/// page is written into and the column its screenshots are built in, and a page switch that ran after
|
||||
/// the window was gone would read both.
|
||||
gboolean on_help_close(GtkWindow* window, gpointer) {
|
||||
(void)window;
|
||||
g_help = nullptr;
|
||||
g_help_body = nullptr;
|
||||
g_help_shots = nullptr;
|
||||
return FALSE; // let the default handler destroy it
|
||||
}
|
||||
|
||||
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||
(void)window;
|
||||
*static_cast<GtkWidget**>(slot) = nullptr;
|
||||
@@ -107,20 +125,18 @@ std::string load_page(const HelpPage& page, bool* ok) {
|
||||
return body;
|
||||
}
|
||||
|
||||
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label) {
|
||||
if (row == nullptr) return;
|
||||
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer) {
|
||||
if (row == nullptr || g_help_body == nullptr) return;
|
||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
|
||||
if (idx < 0 || idx >= kPageCount) return;
|
||||
bool ok = false;
|
||||
const std::string body = load_page(kPages[idx], &ok);
|
||||
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
|
||||
// The screenshots above it, if this page has any. The column is the label's previous sibling
|
||||
// inside the page's box, which is what lets this handler stay a matter of the label alone --
|
||||
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
|
||||
// also the only way to be sure the pictures are this page's and not the last one's.
|
||||
GtkWidget* label = GTK_WIDGET(body_label);
|
||||
GtkWidget* parent = gtk_widget_get_parent(label);
|
||||
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
|
||||
gtk_label_set_markup(GTK_LABEL(g_help_body), body.c_str());
|
||||
// The screenshots above it, if this page has any. The column comes from the `.ui` by id rather than
|
||||
// being found as the label's previous sibling: refilling it is a page *switch*, not a frame, so
|
||||
// rebuilding it here is cheap and is also the only way to be sure the pictures are this page's and
|
||||
// not the last one's.
|
||||
GtkWidget* shots = g_help_shots;
|
||||
if (shots != nullptr && GTK_IS_BOX(shots)) {
|
||||
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
|
||||
gtk_box_remove(GTK_BOX(shots), old);
|
||||
@@ -200,47 +216,31 @@ void show_help(GtkWindow* parent) {
|
||||
return;
|
||||
}
|
||||
|
||||
GtkWidget* window = gtk_window_new();
|
||||
g_help = window;
|
||||
gtk_window_set_title(GTK_WINDOW(window), "udpscope help");
|
||||
gtk_window_set_default_size(GTK_WINDOW(window), 860, 640);
|
||||
gtk_window_set_transient_for(GTK_WINDOW(window), parent);
|
||||
// A builder per opening, as with every other `.ui` here: the window belongs to its object list and
|
||||
// `close-request` destroys the window, so the resource is parsed again rather than a dead window
|
||||
// fetched. This is where the previous one goes, and it is safe because that window has been closed
|
||||
// for as long as it takes someone to ask for help again.
|
||||
g_clear_object(&g_help_ui);
|
||||
g_help_ui = gtk_builder_new_from_resource("/org/udpscope/ui/help.ui");
|
||||
if (g_help_ui == nullptr) return;
|
||||
GObject* window = gtk_builder_get_object(g_help_ui, "help_window");
|
||||
GObject* list = gtk_builder_get_object(g_help_ui, "page_list");
|
||||
GObject* body = gtk_builder_get_object(g_help_ui, "body");
|
||||
GObject* shots = gtk_builder_get_object(g_help_ui, "shot_box");
|
||||
// A missing id is the one mistake the build and `GtkBuilder` both miss; every reader below would be a
|
||||
// null widget on the first page switch.
|
||||
if (window == nullptr || list == nullptr || body == nullptr || shots == nullptr) return;
|
||||
g_help = GTK_WIDGET(window);
|
||||
g_help_body = GTK_WIDGET(body);
|
||||
g_help_shots = GTK_WIDGET(shots);
|
||||
gtk_window_set_transient_for(GTK_WINDOW(g_help), parent);
|
||||
|
||||
// One label, width-locked by the scrolled window, holding one page's markup. The
|
||||
// sidebar swaps the markup rather than rebuilding anything.
|
||||
GtkWidget* body = gtk_label_new(nullptr);
|
||||
gtk_label_set_use_markup(GTK_LABEL(body), TRUE);
|
||||
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
|
||||
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
|
||||
gtk_label_set_yalign(GTK_LABEL(body), 0.0f);
|
||||
gtk_label_set_selectable(GTK_LABEL(body), TRUE);
|
||||
gtk_widget_set_margin_start(body, 18);
|
||||
gtk_widget_set_margin_end(body, 18);
|
||||
gtk_widget_set_margin_top(body, 14);
|
||||
gtk_widget_set_margin_bottom(body, 14);
|
||||
|
||||
GtkWidget* body_scroll = gtk_scrolled_window_new();
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
|
||||
GTK_POLICY_AUTOMATIC);
|
||||
// The page's own column: its screenshots above its text. Built once and *refilled* by the page
|
||||
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
|
||||
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||
gtk_widget_set_margin_start(shot_box, 18);
|
||||
gtk_widget_set_margin_end(shot_box, 18);
|
||||
gtk_widget_set_margin_top(shot_box, 14);
|
||||
gtk_widget_set_visible(shot_box, false);
|
||||
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_box_append(GTK_BOX(page_box), shot_box);
|
||||
gtk_box_append(GTK_BOX(page_box), body);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
|
||||
gtk_widget_set_hexpand(body_scroll, TRUE);
|
||||
|
||||
GtkWidget* list = gtk_list_box_new();
|
||||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_SINGLE);
|
||||
// The rows are the pages, which is data and not template: a heading row per section, then one row per
|
||||
// page carrying its own index. A template cannot know the fourteen pages, and a sectioned list is not
|
||||
// what a GtkListView with one item factory can draw.
|
||||
const char* current_section = nullptr;
|
||||
for (int i = 0; i < kPageCount; ++i) {
|
||||
if (current_section == nullptr ||
|
||||
std::string(current_section) != kPages[i].section) {
|
||||
if (current_section == nullptr || std::string(current_section) != kPages[i].section) {
|
||||
current_section = kPages[i].section;
|
||||
GtkWidget* heading = gtk_label_new(nullptr);
|
||||
char markup[128];
|
||||
@@ -267,26 +267,14 @@ void show_help(GtkWindow* parent) {
|
||||
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
|
||||
gtk_list_box_append(GTK_LIST_BOX(list), row);
|
||||
}
|
||||
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), body);
|
||||
|
||||
GtkWidget* list_scroll = gtk_scrolled_window_new();
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(list_scroll), GTK_POLICY_NEVER,
|
||||
GTK_POLICY_AUTOMATIC);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(list_scroll), list);
|
||||
gtk_widget_set_size_request(list_scroll, 220, -1);
|
||||
|
||||
GtkWidget* split = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
||||
gtk_box_append(GTK_BOX(split), list_scroll);
|
||||
gtk_box_append(GTK_BOX(split), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
|
||||
gtk_box_append(GTK_BOX(split), body_scroll);
|
||||
gtk_window_set_child(GTK_WINDOW(window), split);
|
||||
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), nullptr);
|
||||
|
||||
// Select the first page so the window never opens blank.
|
||||
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
|
||||
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
|
||||
|
||||
g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_help);
|
||||
gtk_window_present(GTK_WINDOW(window));
|
||||
g_signal_connect(g_help, "close-request", G_CALLBACK(on_help_close), &g_help);
|
||||
gtk_window_present(GTK_WINDOW(g_help));
|
||||
}
|
||||
|
||||
void show_shortcuts(GtkWindow* parent) {
|
||||
|
||||
+84
-113
@@ -79,19 +79,23 @@ struct Chooser {
|
||||
/// 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;
|
||||
AdwDialog* form = nullptr;
|
||||
/// The form is a `.ui` file (`src/ui/source-form.ui`), so its builder is held with it, and its rows
|
||||
/// are typed: a switch is read with `adw_switch_row_get_active()` and not off a widget.
|
||||
GtkBuilder* form_ui = nullptr;
|
||||
AdwWindowTitle* form_title = nullptr;
|
||||
AdwEntryRow* form_server = nullptr;
|
||||
AdwSpinRow* form_port = nullptr;
|
||||
AdwEntryRow* form_group = nullptr;
|
||||
AdwEntryRow* form_iface = nullptr;
|
||||
GtkWidget* form_connect = nullptr;
|
||||
/// The configuration half of the form: what a source's *session* is called and the settings that
|
||||
/// can only be applied before the model exists. A new host needs all of them, and so does an
|
||||
/// existing one -- which is why the same form is used for both.
|
||||
GtkWidget* form_name = nullptr;
|
||||
GtkWidget* form_note = nullptr;
|
||||
GtkWidget* form_buffer = nullptr;
|
||||
GtkWidget* form_relative = nullptr;
|
||||
AdwEntryRow* form_name = nullptr;
|
||||
AdwEntryRow* form_note = nullptr;
|
||||
AdwSpinRow* form_buffer = nullptr;
|
||||
AdwSwitchRow* form_relative = nullptr;
|
||||
/// The configuration the form is editing: empty for a new host. Copied rather than referenced,
|
||||
/// because `chooser_populate()` rebuilds `known` under the form.
|
||||
std::string edit_host;
|
||||
@@ -119,9 +123,9 @@ void chooser_finish(Chooser& c, bool chosen) {
|
||||
// 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));
|
||||
AdwDialog* d = c.form;
|
||||
c.form = nullptr; // before the close, so the `closed` handler cannot re-enter
|
||||
adw_dialog_force_close(d);
|
||||
}
|
||||
// The preferences go first: they are presented *on* this window, and a dialog still up when its
|
||||
// window is destroyed is the lifetime this project has got wrong once already. `prefs_close()` is
|
||||
@@ -357,10 +361,9 @@ void chooser_populate(Chooser& c) {
|
||||
}
|
||||
|
||||
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));
|
||||
// The form's own `closed` handler clears the pointer, so this is the only place that has to know how
|
||||
// an `AdwDialog` closes.
|
||||
if (c.form != nullptr) adw_dialog_close(c.form);
|
||||
}
|
||||
|
||||
void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
||||
@@ -369,8 +372,8 @@ void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
||||
// 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);
|
||||
const char* text = gtk_editable_get_text(entry);
|
||||
gtk_widget_set_sensitive(c.form_connect, text != nullptr && text[0] != '\0');
|
||||
}
|
||||
|
||||
void on_form_connect(GtkButton*, gpointer user) {
|
||||
@@ -378,14 +381,13 @@ void on_form_connect(GtkButton*, gpointer user) {
|
||||
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
|
||||
if (server == nullptr || server[0] == '\0') return;
|
||||
const std::string host = server;
|
||||
const std::uint16_t port = static_cast<std::uint16_t>(
|
||||
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
|
||||
const std::uint16_t port = static_cast<std::uint16_t>(adw_spin_row_get_value(c.form_port));
|
||||
const std::string group = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
|
||||
const std::string iface = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
|
||||
std::string name = gtk_editable_get_text(GTK_EDITABLE(c.form_name));
|
||||
const std::string note = gtk_editable_get_text(GTK_EDITABLE(c.form_note));
|
||||
const double buffer_mb = gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer));
|
||||
const bool relative = gtk_switch_get_active(GTK_SWITCH(c.form_relative));
|
||||
const double buffer_mb = adw_spin_row_get_value(c.form_buffer);
|
||||
const bool relative = adw_switch_row_get_active(c.form_relative) != FALSE;
|
||||
if (name.empty()) name = "default";
|
||||
|
||||
c.cfg->server = host;
|
||||
@@ -433,16 +435,6 @@ void on_form_connect(GtkButton*, gpointer user) {
|
||||
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;
|
||||
}
|
||||
|
||||
/// Opens the connection form: on a new host, or on an existing configuration (`edit_index`). One
|
||||
/// form for both, because the fields are the same and a configuration *is* a host plus its
|
||||
/// settings -- the only difference is where the values start and what the button is called.
|
||||
@@ -459,95 +451,74 @@ void open_form(Chooser& c, int edit_index = -1) {
|
||||
c.edit_port = src != nullptr ? src->port : 0;
|
||||
c.edit_config = src != nullptr ? src->config : std::string();
|
||||
|
||||
c.form = gtk_window_new();
|
||||
gtk_window_set_title(GTK_WINDOW(c.form), src != nullptr ? "Edit configuration" : "New host");
|
||||
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window));
|
||||
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE);
|
||||
// A builder per opening, as with every other `.ui` here: the dialog belongs to its object list and
|
||||
// `closed` destroys it, so the resource is parsed again rather than a dead dialog fetched.
|
||||
g_clear_object(&c.form_ui);
|
||||
c.form_ui = gtk_builder_new_from_resource("/org/udpscope/ui/source-form.ui");
|
||||
if (c.form_ui == nullptr) return;
|
||||
GObject* dialog = gtk_builder_get_object(c.form_ui, "source_form");
|
||||
c.form_server = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "server_row"));
|
||||
c.form_port = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "port_row"));
|
||||
c.form_group = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "group_row"));
|
||||
c.form_iface = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "iface_row"));
|
||||
c.form_name = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "name_row"));
|
||||
c.form_note = ADW_ENTRY_ROW(gtk_builder_get_object(c.form_ui, "note_row"));
|
||||
c.form_buffer = ADW_SPIN_ROW(gtk_builder_get_object(c.form_ui, "buffer_row"));
|
||||
c.form_relative = ADW_SWITCH_ROW(gtk_builder_get_object(c.form_ui, "form_relative_row"));
|
||||
c.form_title = ADW_WINDOW_TITLE(gtk_builder_get_object(c.form_ui, "form_title"));
|
||||
c.form_connect = GTK_WIDGET(gtk_builder_get_object(c.form_ui, "form_apply"));
|
||||
// A missing id is the one mistake neither the build nor `GtkBuilder` sees, and every read below would
|
||||
// be a null row.
|
||||
if (dialog == nullptr || c.form_server == nullptr || c.form_port == nullptr ||
|
||||
c.form_group == nullptr || c.form_iface == nullptr || c.form_name == nullptr ||
|
||||
c.form_note == nullptr || c.form_buffer == nullptr || c.form_relative == nullptr ||
|
||||
c.form_connect == nullptr) {
|
||||
g_clear_object(&c.form_ui);
|
||||
return;
|
||||
}
|
||||
c.form = ADW_DIALOG(dialog);
|
||||
|
||||
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);
|
||||
// Which mode the form is in, said in the header bar: the title names it and the action's label says
|
||||
// what pressing it does.
|
||||
const bool editing = src != nullptr;
|
||||
if (c.form_title != nullptr) {
|
||||
adw_window_title_set_title(c.form_title, editing ? "Edit configuration" : "New host");
|
||||
}
|
||||
gtk_button_set_label(GTK_BUTTON(c.form_connect), editing ? "Save" : "Connect");
|
||||
|
||||
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),
|
||||
src != nullptr ? src->host.c_str() : "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), src != nullptr ? src->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");
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_group),
|
||||
src != nullptr ? src->multicast.c_str() : "");
|
||||
c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new());
|
||||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route");
|
||||
// The values: the configuration being edited, or what the command line asked for. The hints live in
|
||||
// the rows' subtitles, so an empty field still says what empty means.
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_server), src != nullptr ? src->host.c_str() : "127.0.0.1");
|
||||
adw_spin_row_set_value(c.form_port, src != nullptr ? src->port : 8000);
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_group), src != nullptr ? src->multicast.c_str() : "");
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_iface),
|
||||
src != nullptr ? src->interface_addr.c_str() : "");
|
||||
|
||||
// The session half: what this source's configuration is called, and the settings that have to be
|
||||
// decided before the first socket is opened.
|
||||
GtkWidget* heading = gtk_label_new(nullptr);
|
||||
gtk_label_set_markup(GTK_LABEL(heading), "<b>Configuration</b>");
|
||||
gtk_widget_set_halign(heading, GTK_ALIGN_START);
|
||||
gtk_widget_set_margin_top(heading, 8);
|
||||
gtk_grid_attach(GTK_GRID(grid), heading, 0, 4, 2, 1);
|
||||
|
||||
c.form_name = form_field(grid, 5, "Name", gtk_entry_new());
|
||||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_name), "default");
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_name),
|
||||
src != nullptr ? src->config.c_str() : "default");
|
||||
c.form_note = form_field(grid, 6, "Note", gtk_entry_new());
|
||||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_note), "what this configuration is for");
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_note),
|
||||
src != nullptr ? src->comment.c_str() : "");
|
||||
c.form_buffer = form_field(grid, 7, "Buffer MB", gtk_spin_button_new_with_range(1, 65536, 1));
|
||||
gtk_widget_set_tooltip_text(c.form_buffer,
|
||||
"sample buffer per source, applied when this source is opened");
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_buffer),
|
||||
src != nullptr && src->buffer_mb > 0.0
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_name), src != nullptr ? src->config.c_str() : "default");
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_note), src != nullptr ? src->comment.c_str() : "");
|
||||
adw_spin_row_set_value(c.form_buffer, src != nullptr && src->buffer_mb > 0.0
|
||||
? src->buffer_mb
|
||||
: std::max(1.0, c.cfg->buffer_mb));
|
||||
GtkWidget* relative = gtk_switch_new();
|
||||
gtk_widget_set_halign(relative, GTK_ALIGN_START);
|
||||
gtk_widget_set_tooltip_text(relative,
|
||||
"plot time from the packet counter and the declared rate, instead "
|
||||
"of the stream's own time signal (--relative-time)");
|
||||
gtk_switch_set_active(GTK_SWITCH(relative),
|
||||
src != nullptr ? src->relative_time : c.cfg->relative_time);
|
||||
c.form_relative = form_field(grid, 8, "Relative time", relative);
|
||||
adw_switch_row_set_active(c.form_relative,
|
||||
(src != nullptr ? src->relative_time : c.cfg->relative_time) ? TRUE
|
||||
: FALSE);
|
||||
|
||||
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");
|
||||
if (GObject* cancel = gtk_builder_get_object(c.form_ui, "form_cancel"); cancel != nullptr) {
|
||||
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(src != nullptr ? "Save" : "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) {
|
||||
// `closed` clears the pointer. An `AdwDialog` emits that and not `close-request`, and it fires only
|
||||
// when the dialog really went away -- which is what the comment here used to be about.
|
||||
g_signal_connect(c.form, "closed", G_CALLBACK(+[](AdwDialog*, 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);
|
||||
adw_dialog_present(c.form, GTK_WIDGET(c.window));
|
||||
gtk_widget_grab_focus(GTK_WIDGET(c.form_server));
|
||||
}
|
||||
|
||||
/// What the pick probes report: the connection the chooser came back with. One helper rather than
|
||||
@@ -665,19 +636,19 @@ gboolean pick_probe(gpointer user) {
|
||||
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));
|
||||
adw_spin_row_set_value(c.form_port, std::atof(s.c_str() + colon + 1));
|
||||
}
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_name), "probe-host");
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "made by the new-host probe");
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_buffer), 32);
|
||||
gtk_switch_set_active(GTK_SWITCH(c.form_relative), TRUE);
|
||||
adw_spin_row_set_value(c.form_buffer, 32);
|
||||
adw_switch_row_set_active(c.form_relative, TRUE);
|
||||
std::fprintf(stderr,
|
||||
"[sources] form: name='%s' note='%s' buffer=%.6g relative=%d "
|
||||
"connect_sensitive=%d\n",
|
||||
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
|
||||
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
|
||||
gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer)),
|
||||
gtk_switch_get_active(GTK_SWITCH(c.form_relative)) ? 1 : 0,
|
||||
adw_spin_row_get_value(c.form_buffer),
|
||||
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
|
||||
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
|
||||
g_signal_emit_by_name(c.form_connect, "clicked");
|
||||
report_pick(c);
|
||||
@@ -708,12 +679,12 @@ gboolean pick_probe(gpointer user) {
|
||||
"[sources] edit form: name='%s' note='%s' buffer=%.6g relative=%d button='%s'\n",
|
||||
gtk_editable_get_text(GTK_EDITABLE(c.form_name)),
|
||||
gtk_editable_get_text(GTK_EDITABLE(c.form_note)),
|
||||
gtk_spin_button_get_value(GTK_SPIN_BUTTON(c.form_buffer)),
|
||||
gtk_switch_get_active(GTK_SWITCH(c.form_relative)) ? 1 : 0,
|
||||
adw_spin_row_get_value(c.form_buffer),
|
||||
adw_switch_row_get_active(c.form_relative) ? 1 : 0,
|
||||
gtk_button_get_label(GTK_BUTTON(c.form_connect)));
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_note), "edited by the probe");
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_buffer), 16);
|
||||
gtk_switch_set_active(GTK_SWITCH(c.form_relative), FALSE);
|
||||
adw_spin_row_set_value(c.form_buffer, 16);
|
||||
adw_switch_row_set_active(c.form_relative, FALSE);
|
||||
// `UDPSCOPE_PROBE_EDITNAME=new-name` also renames it, which is a *move*: the old file must not
|
||||
// survive, or the chooser lists the same session twice.
|
||||
if (const char* rename_to = g_getenv("UDPSCOPE_PROBE_EDITNAME")) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The help window's chrome: a window, its own header bar, and a two-pane split whose sidebar is the
|
||||
list of pages and whose content is one page at a time.
|
||||
|
||||
The split is AdwNavigationSplitView rather than a GtkBox with a separator in it, so the sidebar
|
||||
collapses on a narrow window and comes back behind the header bar's button instead of squeezing the
|
||||
text. On a wide one it is the same two panes it always was.
|
||||
|
||||
The page list itself is *not* here: its rows are the fourteen pages in `help_window.cpp`, each with
|
||||
section headings between the groups, and a template cannot know them. What is here is the shape they
|
||||
are put into: an id to find the list by, the scroller around the page, and the label that holds one
|
||||
page's markup with the screenshot column above it.
|
||||
-->
|
||||
<interface>
|
||||
<object class="AdwWindow" id="help_window">
|
||||
<property name="title" translatable="yes">udpscope help</property>
|
||||
<property name="default-width">860</property>
|
||||
<property name="default-height">640</property>
|
||||
<property name="content">
|
||||
<object class="AdwToolbarView">
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="show-title">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<property name="content">
|
||||
<object class="AdwNavigationSplitView">
|
||||
<property name="sidebar">
|
||||
<object class="AdwNavigationPage">
|
||||
<property name="title" translatable="yes">Pages</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="vscrollbar-policy">automatic</property>
|
||||
<property name="width-request">220</property>
|
||||
<property name="child">
|
||||
<object class="GtkListBox" id="page_list">
|
||||
<property name="selection-mode">single</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
<property name="content">
|
||||
<object class="AdwNavigationPage">
|
||||
<property name="title" translatable="yes">Page</property>
|
||||
<property name="child">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="vscrollbar-policy">automatic</property>
|
||||
<property name="hexpand">1</property>
|
||||
<property name="child">
|
||||
<object class="GtkBox" id="page_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<child>
|
||||
<object class="GtkBox" id="shot_box">
|
||||
<property name="orientation">vertical</property>
|
||||
<property name="spacing">8</property>
|
||||
<property name="margin-start">18</property>
|
||||
<property name="margin-end">18</property>
|
||||
<property name="margin-top">14</property>
|
||||
<property name="visible">0</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkLabel" id="body">
|
||||
<property name="use-markup">1</property>
|
||||
<property name="wrap">1</property>
|
||||
<property name="xalign">0</property>
|
||||
<property name="yalign">0</property>
|
||||
<property name="selectable">1</property>
|
||||
<property name="margin-start">18</property>
|
||||
<property name="margin-end">18</property>
|
||||
<property name="margin-top">14</property>
|
||||
<property name="margin-bottom">14</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The form a source is added or edited with: one dialog, two groups, eight rows.
|
||||
|
||||
A grid of labels and bare entries is what this was, and a grid is the wrong shape for it. Each row
|
||||
here is a whole row — a title, a subtitle under it where the hint used to be a placeholder, and the
|
||||
editing widget on the right — so the form reads the way every GNOME application's does, and the hints
|
||||
survive being typed over (a placeholder does not: it disappears the moment there is a value, which is
|
||||
exactly when a user may want to know what empty meant).
|
||||
|
||||
The action is in the header bar rather than a button row at the foot: it is what the dialog is *for*,
|
||||
it is what the keyboard's Enter reaches, and its label says which mode the form is in — `Connect` for
|
||||
a new host, `Save` for an existing configuration, which `source_dialog.cpp` sets.
|
||||
|
||||
Nothing here carries a value. The dialog is filled from the source being edited, or from the command
|
||||
line for a new one, by the code that opens it.
|
||||
-->
|
||||
<interface>
|
||||
<object class="AdwDialog" id="source_form">
|
||||
<property name="content-width">420</property>
|
||||
<property name="content-height">560</property>
|
||||
<child>
|
||||
<object class="AdwToolbarView">
|
||||
<child type="top">
|
||||
<object class="AdwHeaderBar">
|
||||
<property name="title-widget">
|
||||
<object class="AdwWindowTitle" id="form_title">
|
||||
<property name="title" translatable="yes">New host</property>
|
||||
</object>
|
||||
</property>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="form_cancel">
|
||||
<property name="label" translatable="yes">_Cancel</property>
|
||||
<property name="use-underline">1</property>
|
||||
</object>
|
||||
</child>
|
||||
<child type="end">
|
||||
<object class="GtkButton" id="form_apply">
|
||||
<property name="label" translatable="yes">_Connect</property>
|
||||
<property name="use-underline">1</property>
|
||||
<property name="receives-default">1</property>
|
||||
<property name="css-classes">suggested-action</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<property name="content">
|
||||
<object class="GtkScrolledWindow">
|
||||
<property name="hscrollbar-policy">never</property>
|
||||
<property name="propagate-natural-height">1</property>
|
||||
<property name="child">
|
||||
<object class="AdwPreferencesPage">
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<property name="title" translatable="yes">Connection</property>
|
||||
<property name="description" translatable="yes">Leave the multicast group and the interface empty for unicast on the default route</property>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="server_row">
|
||||
<property name="title" translatable="yes">_Server</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwSpinRow" id="port_row">
|
||||
<property name="title" translatable="yes">_Port</property>
|
||||
<property name="adjustment">
|
||||
<object class="GtkAdjustment">
|
||||
<property name="lower">1</property>
|
||||
<property name="upper">65535</property>
|
||||
<property name="step-increment">1</property>
|
||||
<property name="value">8000</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="group_row">
|
||||
<property name="title" translatable="yes">Multicast _group</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="iface_row">
|
||||
<property name="title" translatable="yes">_Interface</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwPreferencesGroup">
|
||||
<property name="title" translatable="yes">Configuration</property>
|
||||
<property name="description" translatable="yes">The name is what this source's session is called. The buffer and the clock are decided before the first socket is opened.</property>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="name_row">
|
||||
<property name="title" translatable="yes">_Name</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwEntryRow" id="note_row">
|
||||
<property name="title" translatable="yes">N_ote</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwSpinRow" id="buffer_row">
|
||||
<property name="title" translatable="yes">_Buffer MB</property>
|
||||
<property name="subtitle" translatable="yes">sample buffer per source, applied when this source is opened</property>
|
||||
<property name="adjustment">
|
||||
<object class="GtkAdjustment">
|
||||
<property name="lower">1</property>
|
||||
<property name="upper">65536</property>
|
||||
<property name="step-increment">1</property>
|
||||
<property name="value">64</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="AdwSwitchRow" id="form_relative_row">
|
||||
<property name="title" translatable="yes">_Relative time</property>
|
||||
<property name="subtitle" translatable="yes">plot time from the packet counter and the declared rate, instead of the stream's own time signal</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</property>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>
|
||||
@@ -11,5 +11,7 @@
|
||||
<gresource prefix="/org/udpscope/ui">
|
||||
<file preprocess="xml-stripblanks">shortcuts.ui</file>
|
||||
<file preprocess="xml-stripblanks">preferences.ui</file>
|
||||
<file preprocess="xml-stripblanks">help.ui</file>
|
||||
<file preprocess="xml-stripblanks">source-form.ui</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@@ -27,5 +27,7 @@
|
||||
10. [ ] move the interface into `.ui` resources, and to current GTK4/libadwaita widgets while doing it:
|
||||
- the shortcut overview is **done** (`src/ui/shortcuts.ui`, with the mouse gestures drawn as icons, and `[help]`'s `ui=` against `tables=` as the guard)
|
||||
- the preferences are **done** (`src/ui/preferences.ui`: `AdwPreferencesDialog` with `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`/`AdwComboRow`, applying as they change, no Apply button)
|
||||
- the help window's chrome is **done** (`src/ui/help.ui`: `AdwWindow` + header bar + `AdwNavigationSplitView`)
|
||||
- the host form is **done** (`src/ui/source-form.ui`); the source chooser's own window is next
|
||||
- next: the preferences (`AdwPreferencesDialog` + `AdwEntryRow`/`AdwSpinRow`/`AdwSwitchRow`), the help window's chrome, the source chooser and its host form, then the main window's skeleton
|
||||
- plan, conventions and acceptance for each slice: `docs/superpowers/plans/2026-10-11-ui-files-and-gtk4-practice.md`
|
||||
Reference in new issue
Block a user