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 @@ + + + + + + + vertical + 6 + 8 + 8 + 8 + 8 + + + Trigger + start + + + + + horizontal + 6 + + + signal + start + + + + + + + horizontal + 6 + + + condition + start + + + + + + + horizontal + 6 + + + edge + start + + + + + + + horizontal + 6 + + + width is + start + + + + + + + horizontal + 6 + + + width s + start + + + + + + + horizontal + 6 + + + and s + start + + + + + + + horizontal + 6 + + + to level + start + + + + + + + horizontal + 6 + + + timeout s + start + + + + + + + horizontal + 6 + + + level + start + + + + + + + horizontal + 6 + + + pre-trigger % + start + + + + + + + horizontal + 6 + + + hold-off s + start + + + + + + + horizontal + 6 + + + mode + start + + + + + + + horizontal + 6 + + + record events + start + 1 + + + + + + + Arm + + + + + Go live (trigger off) + disable trigger mode and stream continuously + + + + + Search the buffer + find every place in the retained buffer where the condition above holds, and step through them + + + + vertical + 4 + 4 + + + horizontal + 6 + + + Search + 1 + + + + + Stop + 0 + + + + + + + not searched + start + 1 + dim-label + + + + + horizontal + 6 + + + go-previous-symbolic + the previous hit + 0 + + + + + -- + 1 + + + + + go-next-symbolic + the next hit + 0 + + + + + + + + start + dim-label + 3 + + + + + + + + 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.ui cursors-panel.ui measures-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);