diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md
index 8d3c546..d459748 100644
--- a/docs/claude/invariants-gtk-and-chrome.md
+++ b/docs/claude/invariants-gtk-and-chrome.md
@@ -298,3 +298,14 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
than no mnemonic, because the underscore is there in the source for a reader to trust. `Conn_ect` freed
it. Rows on different pages of a preferences dialog may share a letter: an unshown page cannot be
activated.
+
+- **A `GtkExpander`'s child is not in the widget-parent chain.** `gtk_widget_get_parent()` on the widget an
+ expander holds answers NULL, before *and* after the window is presented, while `gtk_expander_get_child()`
+ returns it -- measured all three ways (the `.ui`'s `child` property, a code `gtk_expander_set_child()`, a
+ code-made expander). So a probe that walks parents to prove a widget is inside a panel reads 0 for
+ everything behind an expander, on a panel that is entirely correct; the walk has to start at
+ `gtk_expander_get_child()`. That mistake cost a false regression, a "the property does not attach it"
+ conclusion drawn from it, and a pointless second root in the file before the throwaway program settled it.
+ Related: `gtk_expander_set_child()` asserts that its child has no parent yet, so a block the `.ui` has
+ already put in the panel cannot then be handed to an expander -- the file's `child` property is the right
+ way, and it works.
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index a40e93f..09ed8b1 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -1161,3 +1161,21 @@ mnemonic. It is FALSE on `AdwPreferencesRow` by default, which is why these titl
literal underscore; anything below the full count is that bug back. Read by property name rather than
through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form's line appears only when
`UDPSCOPE_PROBE_NEWHOST` drives the form.
+
+## `trigger panel:`
+
+```
+[udpscope] trigger panel: boxes=13 controls_inside=13/13 search_widgets=4/4 search_inside=4/4
+```
+
+The trigger panel's frame, read from the window: `boxes` is the row boxes the `.ui` holds, `controls_inside`
+counts the thirteen controls the code built and then appended into those rows, `search_widgets` is the four
+search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
+opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
+`controls_inside` below 13 while every other line still looks right, which is the panel that has a field
+missing.
+
+The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the
+widget-parent chain at all, so walking up from a search button reaches no ancestor of the panel (see
+`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's seven spins in tree order, so
+the two lines together read what the file gave and what the code put in it.
diff --git a/src/ui.cpp b/src/ui.cpp
index 03c7ea7..b56f6a2 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -5585,6 +5585,64 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
"toggle off=%d\n",
app.dbg_trigger_revealed_default, app.dbg_trigger_items,
app.dbg_trigger_selected, app.dbg_side_scroll_after_hide);
+ {
+ // The panel's *frame* read back: the row boxes the `.ui` holds, and that every control the code built
+ // ended up inside the panel rather than in a row that is not there. A control is created in code and
+ // placed by an id, so an id that drifted leaves it built and nowhere -- which looks like a panel
+ // missing a field, and is what `controls_inside` counts. `[defaults]` prints the same panel's spins
+ // in tree order, so the two together read what the file gave and what the code put in it.
+ const auto inside = [](GtkWidget* w, GtkWidget* root) {
+ if (w == nullptr) return 0;
+ for (GtkWidget* p = w; p != nullptr; p = gtk_widget_get_parent(p)) {
+ if (p == root) return 1;
+ }
+ return 0;
+ };
+ int boxes = 0;
+ for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_panel); c != nullptr;
+ c = gtk_widget_get_next_sibling(c)) {
+ if (GTK_IS_BOX(c)) ++boxes;
+ }
+ // `search_widgets` separated from `search_inside` on purpose: the first says the ids resolved, the
+ // second says they are in the panel. A `.ui` that lost one and a `.ui` that lost its *place* are two
+ // different mistakes, and only one of them shows as a missing button.
+ // The expander's child is **not** in the widget-parent chain: `gtk_widget_get_parent()` on it stays
+ // NULL even after the window is presented, while `gtk_expander_get_child()` returns it (measured with
+ // a throwaway program, all three ways of setting it). So the walk the search widgets are read with has
+ // to start at the expander's child and not at the panel -- walking to the panel reads 0/4 on a panel
+ // that is entirely correct, which is what this line did first.
+ GtkWidget* search_root = app.trigger_panel;
+ for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_panel); c != nullptr;
+ c = gtk_widget_get_next_sibling(c)) {
+ if (GTK_IS_EXPANDER(c)) {
+ if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(c))) search_root = inner;
+ break;
+ }
+ }
+ const int search_present =
+ (app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
+ (app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
+ std::printf(
+ "[udpscope] trigger panel: boxes=%d controls_inside=%d/13 search_widgets=%d/4 "
+ "search_inside=%d/4\n",
+ boxes,
+ inside(app.trigger_signal_drop, app.trigger_panel) +
+ inside(app.trigger_type_drop, app.trigger_panel) +
+ inside(app.trigger_edge_drop, app.trigger_panel) +
+ inside(app.trigger_cmp_drop, app.trigger_panel) +
+ inside(app.trigger_mode_drop, app.trigger_panel) +
+ inside(app.trigger_width_spin, app.trigger_panel) +
+ inside(app.trigger_width2_spin, app.trigger_panel) +
+ inside(app.trigger_level2_spin, app.trigger_panel) +
+ inside(app.trigger_timeout_spin, app.trigger_panel) +
+ inside(app.trigger_level_spin, app.trigger_panel) +
+ inside(app.trigger_pre_spin, app.trigger_panel) +
+ inside(app.trigger_holdoff_spin, app.trigger_panel) +
+ inside(app.events_record_switch, app.trigger_panel),
+ search_present,
+ inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
+ inside(app.search_prev_button, search_root) + inside(app.search_next_button, search_root));
+ }
{
plot::Plot& p = *rows.row(0).plot;
const plot::PlotLayout& lay = p.layout();
diff --git a/src/ui/trigger-panel.ui b/src/ui/trigger-panel.ui
new file mode 100644
index 0000000..9659bb0
--- /dev/null
+++ b/src/ui/trigger-panel.ui
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
diff --git a/src/ui/udpscope-ui.gresource.xml b/src/ui/udpscope-ui.gresource.xml
index e4b06af..d551c26 100644
--- a/src/ui/udpscope-ui.gresource.xml
+++ b/src/ui/udpscope-ui.gresource.xml
@@ -17,5 +17,6 @@
main-window.uicursors-panel.uimeasures-panel.ui
+ trigger-panel.ui
diff --git a/src/ui_trigger.cpp b/src/ui_trigger.cpp
index 09a9942..5fe1b55 100644
--- a/src/ui_trigger.cpp
+++ b/src/ui_trigger.cpp
@@ -99,12 +99,17 @@ void sync_trigger_rows(App& app) {
width_cmp_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_cmp_drop)));
const bool pulse = type == TriggerType::PulseWidth;
const bool window = type == TriggerType::Window;
- gtk_widget_set_visible(app.trigger_cmp_row, pulse);
- gtk_widget_set_visible(app.trigger_width_row, pulse);
- gtk_widget_set_visible(app.trigger_width2_row,
- pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside));
- gtk_widget_set_visible(app.trigger_level2_row, window);
- gtk_widget_set_visible(app.trigger_timeout_row, type == TriggerType::Dropout);
+ // The rows come from the panel's `.ui`, so a missing one is a mistake there rather than an impossibility
+ // here: `show()` names that by doing nothing, and `[udpscope] trigger panel:` is what proves the row
+ // exists.
+ const auto show = [](GtkWidget* row, bool visible) {
+ if (row != nullptr) gtk_widget_set_visible(row, visible);
+ };
+ show(app.trigger_cmp_row, pulse);
+ show(app.trigger_width_row, pulse);
+ show(app.trigger_width2_row, pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside));
+ show(app.trigger_level2_row, window);
+ show(app.trigger_timeout_row, type == TriggerType::Dropout);
// A band of zero width reduces the condition to a float-equality test on a real-valued
// signal, which is unusable: it fires on the exact value and nothing else, which in practice
// is never. The panel is where that is refused: the second level is pushed at least one
@@ -197,47 +202,57 @@ void on_go_live(GtkButton*, gpointer user) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), FALSE);
}
-GtkWidget* build_trigger_panel(App& app) {
- GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
- gtk_widget_set_margin_top(box, 8);
- gtk_widget_set_margin_bottom(box, 8);
- gtk_widget_set_margin_start(box, 8);
- gtk_widget_set_margin_end(box, 8);
+/// The row the `.ui` declares for a control, with the control appended to it.
+///
+/// The label is the file's, because it is text a translator owns; the control is code's, because a
+/// dropdown's model and a spin button's range are the application's. `labeled_box()` did both in code and
+/// this is what replaces it, row by row. `nullptr` comes back when the file has no such row: the control is
+/// then built and never placed, which is what `[udpscope] trigger panel:` reads.
+GtkWidget* row_for(GtkBuilder* ui, const char* id, GtkWidget* child) {
+ GtkWidget* row = GTK_WIDGET(gtk_builder_get_object(ui, id));
+ if (row == nullptr) return nullptr;
+ gtk_widget_set_hexpand(child, true);
+ gtk_box_append(GTK_BOX(row), child);
+ return row;
+}
- GtkWidget* heading = gtk_label_new("Trigger");
- gtk_widget_set_halign(heading, GTK_ALIGN_START);
- gtk_box_append(GTK_BOX(box), heading);
+GtkWidget* build_trigger_panel(App& app) {
+ // The panel's frame -- the heading, one labelled row per setting, the two buttons and the search block --
+ // is a UI file. What is not is every control that carries a value: a dropdown's model and a spin button's
+ // range are the application's, so they are built here and appended into the file's rows. `make test-ui`
+ // checks the file's types against the GIR; the ids below are the only thing that keeps the code and the
+ // file agreeing on the names.
+ GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/trigger-panel.ui");
+ GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_panel"));
+ if (box == nullptr) return gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
app.trigger_signal_drop = gtk_drop_down_new(G_LIST_MODEL(gtk_string_list_new(nullptr)), nullptr);
g_signal_connect(app.trigger_signal_drop, "notify::selected",
G_CALLBACK(on_trigger_signal_changed), &app);
- gtk_box_append(GTK_BOX(box), labeled_box("signal", app.trigger_signal_drop));
+ row_for(ui, "signal_row", app.trigger_signal_drop);
const char* types[] = {"edge", "pulse width", "window", "dropout", nullptr};
app.trigger_type_drop = gtk_drop_down_new_from_strings(types);
gtk_widget_set_tooltip_text(app.trigger_type_drop,
"what counts as an event; the fields below change to suit it");
- gtk_box_append(GTK_BOX(box), labeled_box("condition", app.trigger_type_drop));
+ row_for(ui, "condition_row", app.trigger_type_drop);
const char* edges[] = {"rising", "falling", "both", nullptr};
app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges);
- gtk_box_append(GTK_BOX(box), labeled_box("edge", app.trigger_edge_drop));
+ row_for(ui, "edge_row", app.trigger_edge_drop);
const char* cmps[] = {"<", ">", "within", "outside", nullptr};
app.trigger_cmp_drop = gtk_drop_down_new_from_strings(cmps);
gtk_widget_set_tooltip_text(app.trigger_cmp_drop,
"how the measured pulse width is compared: '<' is the glitch "
"trigger -- a pulse shorter than it should be");
- app.trigger_cmp_row = labeled_box("width is", app.trigger_cmp_drop);
- gtk_box_append(GTK_BOX(box), app.trigger_cmp_row);
+ app.trigger_cmp_row = row_for(ui, "cmp_row", app.trigger_cmp_drop);
app.trigger_width_spin = spin(0.0, 3600.0, 0.001, 6, 0.001);
- app.trigger_width_row = labeled_box("width s", app.trigger_width_spin);
- gtk_box_append(GTK_BOX(box), app.trigger_width_row);
+ app.trigger_width_row = row_for(ui, "width_row", app.trigger_width_spin);
app.trigger_width2_spin = spin(0.0, 3600.0, 0.001, 6, 0.002);
- app.trigger_width2_row = labeled_box("and s", app.trigger_width2_spin);
- gtk_box_append(GTK_BOX(box), app.trigger_width2_row);
+ app.trigger_width2_row = row_for(ui, "width2_row", app.trigger_width2_spin);
// Zero, not the range's minimum: the level this row reads is unset until a channel is chosen,
// and the panel's sync writes the declared range's middle then -- but a dialog that opens at
@@ -249,30 +264,28 @@ GtkWidget* build_trigger_panel(App& app) {
"condition becomes an exact float-equality test against the level, "
"which an ordinary signal never satisfies and a signal sitting at "
"the level satisfies on every transition");
- app.trigger_level2_row = labeled_box("to level", app.trigger_level2_spin);
- gtk_box_append(GTK_BOX(box), app.trigger_level2_row);
+ app.trigger_level2_row = row_for(ui, "level2_row", app.trigger_level2_spin);
app.trigger_timeout_spin = spin(0.0, 3600.0, 0.001, 6, 0.001);
- app.trigger_timeout_row = labeled_box("timeout s", app.trigger_timeout_spin);
- gtk_box_append(GTK_BOX(box), app.trigger_timeout_row);
+ app.trigger_timeout_row = row_for(ui, "timeout_row", app.trigger_timeout_spin);
app.trigger_level_spin = spin(-1.0e12, 1.0e12, 0.01, 4, 0.0);
g_signal_connect(app.trigger_level_spin, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) {
static_cast(u)->trigger_level_touched = true;
}),
&app);
- gtk_box_append(GTK_BOX(box), labeled_box("level", app.trigger_level_spin));
+ row_for(ui, "level_row", app.trigger_level_spin);
app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0, 50.0);
- gtk_box_append(GTK_BOX(box), labeled_box("pre-trigger %", app.trigger_pre_spin));
+ row_for(ui, "pre_row", app.trigger_pre_spin);
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
app.model->config().trigger_holdoff_s);
- gtk_box_append(GTK_BOX(box), labeled_box("hold-off s", app.trigger_holdoff_spin));
+ row_for(ui, "holdoff_row", app.trigger_holdoff_spin);
const char* modes[] = {"normal", "single", nullptr};
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
- gtk_box_append(GTK_BOX(box), labeled_box("mode", app.trigger_mode_drop));
+ row_for(ui, "mode_row", app.trigger_mode_drop);
app.events_record_switch = gtk_switch_new();
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
@@ -282,29 +295,22 @@ GtkWidget* build_trigger_panel(App& app) {
"picture of each; the history button goes with it");
g_signal_connect(app.events_record_switch, "notify::active",
G_CALLBACK(on_events_record_toggled), &app);
- // Not `labeled_box()`: that expands its child, which is what a spin button wants and what a switch
- // must not have -- a switch's natural width *is* its size, so an expanded one stretches into a wide
- // pill that reads as a slider. The label takes the slack instead.
- {
- GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
- GtkWidget* text = gtk_label_new("record events");
- gtk_widget_set_halign(text, GTK_ALIGN_START);
- gtk_widget_set_hexpand(text, TRUE);
- gtk_box_append(GTK_BOX(row), text);
+ // Not `row_for()`: that expands its child, which is what a spin button wants and what a switch must not
+ // have -- a switch's natural width *is* its size, so an expanded one stretches into a wide pill that
+ // reads as a slider. The label takes the slack instead, which is why the file's label carries the
+ // `hexpand` on this one row and no other.
+ if (GtkWidget* record_row = GTK_WIDGET(gtk_builder_get_object(ui, "record_row"))) {
gtk_widget_set_halign(app.events_record_switch, GTK_ALIGN_END);
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
- gtk_box_append(GTK_BOX(row), app.events_record_switch);
- gtk_box_append(GTK_BOX(box), row);
+ gtk_box_append(GTK_BOX(record_row), app.events_record_switch);
}
- app.arm_button = gtk_button_new_with_label("Arm");
- g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
- gtk_box_append(GTK_BOX(box), app.arm_button);
-
- GtkWidget* live = gtk_button_new_with_label("Go live (trigger off)");
- gtk_widget_set_tooltip_text(live, "disable trigger mode and stream continuously");
- g_signal_connect(live, "clicked", G_CALLBACK(on_go_live), &app);
- gtk_box_append(GTK_BOX(box), live);
+ app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm"));
+ if (app.arm_button != nullptr) {
+ g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
+ }
+ GtkWidget* live = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_live"));
+ if (live != nullptr) g_signal_connect(live, "clicked", G_CALLBACK(on_go_live), &app);
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
app.trigger_width_spin, app.trigger_width2_spin,
@@ -319,98 +325,68 @@ GtkWidget* build_trigger_panel(App& app) {
// Searching *is* the trigger's condition applied to history, so it belongs in this panel
// rather than in one of its own: a second place to spell the same condition would be a
// second place to get it wrong. Collapsed by default -- it is not part of running a trigger.
- GtkWidget* search_exp = gtk_expander_new("Search the buffer");
- gtk_widget_set_tooltip_text(search_exp,
- "find every place in the retained buffer where the condition "
- "above holds, and step through them");
- GtkWidget* sbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
- gtk_widget_set_margin_top(sbox, 4);
-
- GtkWidget* srun = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
- app.search_run_button = gtk_button_new_with_label("Search");
- gtk_widget_set_hexpand(app.search_run_button, true);
- g_signal_connect(app.search_run_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
- App* a = static_cast(user);
- // Published, not read back off the widgets: the producer's copy is the
- // one the live trigger uses, so the search asks exactly its question.
- publish_trigger_fields(a);
- EventCondition c;
- int channel = -1;
- {
- std::lock_guard lock(a->model->mutex());
- c = a->model->trigger().condition;
- channel = a->model->trigger().channel;
- }
- a->search.start(c, channel);
- update_search_panel(*a);
- }),
- &app);
- gtk_box_append(GTK_BOX(srun), app.search_run_button);
- app.search_cancel_button = gtk_button_new_with_label("Stop");
- gtk_widget_set_sensitive(app.search_cancel_button, false);
- g_signal_connect(app.search_cancel_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
- App* a = static_cast(user);
- a->search.cancel();
- update_search_panel(*a);
- }),
- &app);
- gtk_box_append(GTK_BOX(srun), app.search_cancel_button);
- gtk_box_append(GTK_BOX(sbox), srun);
-
- app.search_status_label = gtk_label_new("not searched");
- gtk_widget_set_halign(app.search_status_label, GTK_ALIGN_START);
- gtk_label_set_wrap(GTK_LABEL(app.search_status_label), true);
- gtk_widget_add_css_class(app.search_status_label, "dim-label");
- gtk_box_append(GTK_BOX(sbox), app.search_status_label);
-
- GtkWidget* snav = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
- app.search_prev_button = gtk_button_new_from_icon_name("go-previous-symbolic");
- gtk_widget_set_tooltip_text(app.search_prev_button, "the previous hit");
- gtk_widget_set_sensitive(app.search_prev_button, false);
- g_signal_connect(app.search_prev_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
- App* a = static_cast(user);
- if (a->search.prev()) search_jump(*a, a->search.hits()[static_cast(
- a->search.cursor())]);
- update_search_panel(*a);
- }),
- &app);
- gtk_box_append(GTK_BOX(snav), app.search_prev_button);
-
- app.search_pos_label = gtk_label_new("--");
- gtk_widget_set_hexpand(app.search_pos_label, true);
- gtk_box_append(GTK_BOX(snav), app.search_pos_label);
-
- app.search_next_button = gtk_button_new_from_icon_name("go-next-symbolic");
- gtk_widget_set_tooltip_text(app.search_next_button, "the next hit");
- gtk_widget_set_sensitive(app.search_next_button, false);
- g_signal_connect(app.search_next_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
- App* a = static_cast(user);
- // The first press with no cursor goes to the first hit, not the second.
- if (a->search.cursor() < 0) {
- a->search.select(0);
- } else if (!a->search.next()) {
+ // Its widgets come from the same file, and only their signals are wired here.
+ app.search_run_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_run"));
+ app.search_cancel_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_cancel"));
+ app.search_status_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_status"));
+ app.search_prev_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_prev"));
+ app.search_pos_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_pos"));
+ app.search_next_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_next"));
+ app.search_marks_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_marks"));
+ if (app.search_run_button != nullptr) {
+ g_signal_connect(app.search_run_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
+ App* a = static_cast(user);
+ // Published, not read back off the widgets: the producer's copy is the
+ // one the live trigger uses, so the search asks exactly its question.
+ publish_trigger_fields(a);
+ EventCondition c;
+ int channel = -1;
+ {
+ std::lock_guard lock(a->model->mutex());
+ c = a->model->trigger().condition;
+ channel = a->model->trigger().channel;
+ }
+ a->search.start(c, channel);
update_search_panel(*a);
- return;
- }
- search_jump(*a, a->search.hits()[static_cast(
- a->search.cursor())]);
- update_search_panel(*a);
- }),
- &app);
- gtk_box_append(GTK_BOX(snav), app.search_next_button);
- gtk_box_append(GTK_BOX(sbox), snav);
-
- app.search_marks_label = gtk_label_new(" ");
- gtk_widget_set_halign(app.search_marks_label, GTK_ALIGN_START);
- gtk_widget_add_css_class(app.search_marks_label, "dim-label");
- // Ellipsize so the label's minimum width is bounded: without this, the longest text
- // (~58 chars) would widen the side pane just as `search_status_label` would without its own
- // wrap — the same fix the statistics label carries at line 2780.
- gtk_label_set_ellipsize(GTK_LABEL(app.search_marks_label), PANGO_ELLIPSIZE_END);
- gtk_box_append(GTK_BOX(sbox), app.search_marks_label);
-
- gtk_expander_set_child(GTK_EXPANDER(search_exp), sbox);
- gtk_box_append(GTK_BOX(box), search_exp);
+ }),
+ &app);
+ }
+ if (app.search_cancel_button != nullptr) {
+ g_signal_connect(app.search_cancel_button, "clicked",
+ G_CALLBACK(+[](GtkButton*, gpointer user) {
+ App* a = static_cast(user);
+ a->search.cancel();
+ update_search_panel(*a);
+ }),
+ &app);
+ }
+ if (app.search_prev_button != nullptr) {
+ g_signal_connect(app.search_prev_button, "clicked",
+ G_CALLBACK(+[](GtkButton*, gpointer user) {
+ App* a = static_cast(user);
+ if (a->search.prev()) {
+ search_jump(*a, a->search.hits()[static_cast(a->search.cursor())]);
+ }
+ update_search_panel(*a);
+ }),
+ &app);
+ }
+ if (app.search_next_button != nullptr) {
+ g_signal_connect(app.search_next_button, "clicked",
+ G_CALLBACK(+[](GtkButton*, gpointer user) {
+ App* a = static_cast(user);
+ // The first press with no cursor goes to the first hit, not the second.
+ if (a->search.cursor() < 0) {
+ a->search.select(0);
+ } else if (!a->search.next()) {
+ update_search_panel(*a);
+ return;
+ }
+ search_jump(*a, a->search.hits()[static_cast(a->search.cursor())]);
+ update_search_panel(*a);
+ }),
+ &app);
+ }
g_signal_connect(app.trigger_type_drop, "notify::selected",
G_CALLBACK(on_trigger_shape_changed), &app);