From 9da53cb4a78bb1e34a1e26b157800322aa09950c Mon Sep 17 00:00:00 2001 From: Josephine Pfeiffer Date: Tue, 16 Jun 2026 14:10:45 +0200 Subject: [PATCH 1/3] libnmt-newt: add a hotkey mechanism to NmtNewtForm nmt-newt has no per-widget key interception. Add a generic hook to NmtNewtForm: nmt_newt_form_add_hotkey() registers a key, and a "hotkey" signal is emitted when it (or Esc) is pressed. A handler returning TRUE consumes the key before the form's default handling, so Esc can cancel a transient mode instead of closing the form. This is used by the connection-list search added next. --- src/libnmt-newt/nmt-newt-form.c | 64 +++++++++++++++++++++++++++++++++ src/libnmt-newt/nmt-newt-form.h | 3 ++ 2 files changed, 67 insertions(+) diff --git a/src/libnmt-newt/nmt-newt-form.c b/src/libnmt-newt/nmt-newt-form.c index 3565799c6a..9f372e0b8d 100644 --- a/src/libnmt-newt/nmt-newt-form.c +++ b/src/libnmt-newt/nmt-newt-form.c @@ -38,6 +38,7 @@ typedef struct { gboolean dirty; NmtNewtWidget *focus; + GArray *hotkeys; #ifdef HAVE_NEWTFORMGETSCROLLPOSITION int scroll_position = 0; #endif @@ -60,6 +61,7 @@ enum { enum { QUIT, + HOTKEY, LAST_SIGNAL }; @@ -118,6 +120,7 @@ nmt_newt_form_finalize(GObject *object) g_free(priv->title_lc); g_clear_object(&priv->focus); + nm_clear_pointer(&priv->hotkeys, g_array_unref); G_OBJECT_CLASS(nmt_newt_form_parent_class)->finalize(object); } @@ -211,6 +214,12 @@ nmt_newt_form_build(NmtNewtForm *form) priv->form = newtForm(NULL, NULL, NEWT_FLAG_NOF12); newtFormAddHotKey(priv->form, NEWT_KEY_ESCAPE); + if (priv->hotkeys) { + guint h; + + for (h = 0; h < priv->hotkeys->len; h++) + newtFormAddHotKey(priv->form, nm_g_array_index(priv->hotkeys, int, h)); + } cos = nmt_newt_widget_get_components(priv->content); for (i = 0; cos[i]; i++) @@ -270,6 +279,17 @@ nmt_newt_form_iterate(NmtNewtForm *form) newtFormSetTimer(priv->form, 1); newtFormRun(priv->form, &es); + if (es.reason == NEWT_EXIT_HOTKEY) { + gboolean handled = FALSE; + + /* Give listeners (eg, the connection-list search) a chance to consume the + * key. Esc is included, so a listener can make it cancel a transient mode + * instead of closing the form. */ + g_signal_emit(form, signals[HOTKEY], 0, (int) es.u.key, &handled); + if (handled) + return; + } + if (es.reason == NEWT_EXIT_HOTKEY || es.reason == NEWT_EXIT_ERROR) { /* The user hit Esc or there was an error. */ g_clear_object(&priv->focus); @@ -434,6 +454,28 @@ nmt_newt_form_set_focus(NmtNewtForm *form, NmtNewtWidget *widget) g_object_ref(priv->focus); } +/** + * nmt_newt_form_add_hotkey: + * @form: an #NmtNewtForm + * @key: a key code (eg, a character, or an %NEWT_KEY_ value) + * + * Registers @key as a hotkey on @form. When the user presses it, the + * #NmtNewtForm::hotkey signal is emitted; a handler returning %TRUE consumes + * the key, otherwise the form's default handling applies. + */ +void +nmt_newt_form_add_hotkey(NmtNewtForm *form, int key) +{ + NmtNewtFormPrivate *priv = NMT_NEWT_FORM_GET_PRIVATE(form); + + if (!priv->hotkeys) + priv->hotkeys = g_array_new(FALSE, FALSE, sizeof(int)); + g_array_append_val(priv->hotkeys, key); + + if (priv->form) + newtFormAddHotKey(priv->form, key); +} + static void nmt_newt_form_set_property(GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) { @@ -579,6 +621,28 @@ nmt_newt_form_class_init(NmtNewtFormClass *form_class) G_TYPE_NONE, 0); + /** + * NmtNewtForm::hotkey: + * @form: the #NmtNewtForm + * @key: the key that was pressed + * + * Emitted when a key registered via nmt_newt_form_add_hotkey() (or Esc) is + * pressed. A handler returning %TRUE consumes the key; otherwise the form's + * default handling applies (Esc closes the form). + * + * Returns: %TRUE if the key was handled. + */ + signals[HOTKEY] = g_signal_new("hotkey", + G_OBJECT_CLASS_TYPE(object_class), + G_SIGNAL_RUN_LAST, + G_STRUCT_OFFSET(NmtNewtFormClass, hotkey), + g_signal_accumulator_true_handled, + NULL, + NULL, + G_TYPE_BOOLEAN, + 1, + G_TYPE_INT); + /** * NmtNewtForm:title: * diff --git a/src/libnmt-newt/nmt-newt-form.h b/src/libnmt-newt/nmt-newt-form.h index 6a2ff9dcb2..7e5b7dd60c 100644 --- a/src/libnmt-newt/nmt-newt-form.h +++ b/src/libnmt-newt/nmt-newt-form.h @@ -26,6 +26,7 @@ typedef struct { /* signals */ void (*quit)(NmtNewtForm *form); + gboolean (*hotkey)(NmtNewtForm *form, int key); /* methods */ void (*show)(NmtNewtForm *form); @@ -45,4 +46,6 @@ void nmt_newt_form_quit(NmtNewtForm *form); void nmt_newt_form_set_focus(NmtNewtForm *form, NmtNewtWidget *widget); +void nmt_newt_form_add_hotkey(NmtNewtForm *form, int key); + #endif /* NMT_NEWT_FORM_H */ From 5e2360afc14fbaa47c768ed53fd4234f226ae962 Mon Sep 17 00:00:00 2001 From: Josephine Pfeiffer Date: Tue, 16 Jun 2026 14:10:50 +0200 Subject: [PATCH 2/3] nmtui: add vim-style '/' search to connection lists The connection lists in "nmtui connect" and "nmtui edit" have no way to filter, so finding one profile among many means scrolling. Press '/' to reveal a search box that hides non-matching rows as you type; Enter keeps the filter applied, Esc clears it and restores the list. A row below the list is always present, so revealing the box does not resize the form. While typing it shows "Search:"; once a filter is applied and the box is hidden, it reports the active filter and the match count ("Matching '...' (N)") so it is clear the list is filtered. Key interception uses the NmtNewtForm hotkey mechanism. --- po/POTFILES.in | 1 + src/libnmt-newt/nmt-newt-form.c | 26 ++++ src/libnmt-newt/nmt-newt-form.h | 2 + src/libnmt-newt/nmt-newt-utils.c | 39 ++++++ src/libnmt-newt/nmt-newt-utils.h | 2 + src/nmtui/nmt-connect-connection-list.c | 56 +++++++- src/nmtui/nmt-connect-connection-list.h | 4 + src/nmtui/nmt-edit-connection-list.c | 75 +++++++++- src/nmtui/nmt-edit-connection-list.h | 2 + src/nmtui/nmt-utils.c | 177 ++++++++++++++++++++++++ src/nmtui/nmt-utils.h | 32 +++++ src/nmtui/nmtui-connect.c | 39 ++++++ src/nmtui/nmtui-edit.c | 2 + 13 files changed, 449 insertions(+), 8 deletions(-) diff --git a/po/POTFILES.in b/po/POTFILES.in index 605683df42..f1b5c98120 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -199,6 +199,7 @@ src/nmtui/nmt-password-fields.c src/nmtui/nmt-port-list.c src/nmtui/nmt-route-editor.c src/nmtui/nmt-route-table.c +src/nmtui/nmt-utils.c src/nmtui/nmt-widget-list.c src/nmtui/nmt-wireguard-peer-editor.c src/nmtui/nmt-wireguard-peer-list.c diff --git a/src/libnmt-newt/nmt-newt-form.c b/src/libnmt-newt/nmt-newt-form.c index 9f372e0b8d..aefc7eca77 100644 --- a/src/libnmt-newt/nmt-newt-form.c +++ b/src/libnmt-newt/nmt-newt-form.c @@ -31,9 +31,11 @@ typedef struct { NmtNewtWidget *content; guint x, y, width, height; + guint form_width_max; guint padding; gboolean fixed_x, fixed_y; gboolean fixed_width, fixed_height; + gboolean stable_width; char *title_lc; gboolean dirty; @@ -184,6 +186,15 @@ nmt_newt_form_build(NmtNewtForm *form) nmt_newt_widget_size_request(priv->content, &form_width, &form_height); newtGetScreenSize(&screen_width, &screen_height); + if (priv->stable_width) { + /* Never let the form get narrower across rebuilds. Otherwise content + * that shrinks (eg, a filtered list) makes the window recenter and + * "jump". Only forms that opt in get this; see + * nmt_newt_form_set_stable_width(). */ + priv->form_width_max = NM_MAX(priv->form_width_max, (guint) NM_MAX(form_width, 0)); + form_width = priv->form_width_max; + } + if (!priv->fixed_width) priv->width = NM_MIN(form_width + 2 * ((gint64) priv->padding), screen_width - 2); if (!priv->fixed_height) @@ -476,6 +487,21 @@ nmt_newt_form_add_hotkey(NmtNewtForm *form, int key) newtFormAddHotKey(priv->form, key); } +/** + * nmt_newt_form_set_stable_width: + * @form: an #NmtNewtForm + * + * Stops @form from getting narrower across rebuilds: its width grows to fit + * content but never shrinks. Use for forms whose content shrinks in place (eg, + * a list filtered by a search box) where recentering would make the window + * "jump". + */ +void +nmt_newt_form_set_stable_width(NmtNewtForm *form) +{ + NMT_NEWT_FORM_GET_PRIVATE(form)->stable_width = TRUE; +} + static void nmt_newt_form_set_property(GObject *object, guint prop_id, const GValue *value, GParamSpec *pspec) { diff --git a/src/libnmt-newt/nmt-newt-form.h b/src/libnmt-newt/nmt-newt-form.h index 7e5b7dd60c..63b64be6a3 100644 --- a/src/libnmt-newt/nmt-newt-form.h +++ b/src/libnmt-newt/nmt-newt-form.h @@ -48,4 +48,6 @@ void nmt_newt_form_set_focus(NmtNewtForm *form, NmtNewtWidget *widget); void nmt_newt_form_add_hotkey(NmtNewtForm *form, int key); +void nmt_newt_form_set_stable_width(NmtNewtForm *form); + #endif /* NMT_NEWT_FORM_H */ diff --git a/src/libnmt-newt/nmt-newt-utils.c b/src/libnmt-newt/nmt-newt-utils.c index 36a47b2b61..1864b4a443 100644 --- a/src/libnmt-newt/nmt-newt-utils.c +++ b/src/libnmt-newt/nmt-newt-utils.c @@ -17,6 +17,7 @@ #include #include "libnm-glib-aux/nm-io-utils.h" +#include "libnm-glib-aux/nm-str-buf.h" static void nmt_newt_dialog_g_log_handler(const char *log_domain, @@ -386,6 +387,44 @@ nmt_newt_text_width(const char *str) return width; } +/** + * nmt_newt_text_truncate + * @str: a UTF-8 string + * @max_width: the maximum width in terminal columns + * + * Truncates @str to at most @max_width columns, appending an ellipsis if + * anything was dropped (the ellipsis is counted in @max_width). + * + * Returns: (transfer full): a newly-allocated truncated copy of @str. + */ +char * +nmt_newt_text_truncate(const char *str, int max_width) +{ + nm_auto_str_buf NMStrBuf buf = NM_STR_BUF_INIT(0, FALSE); + const char *p; + int width = 0; + + if (max_width <= 0) + return g_strdup(""); + if (nmt_newt_text_width(str) <= max_width) + return g_strdup(str); + + max_width--; /* reserve one column for the ellipsis */ + + for (p = str; *p; p = g_utf8_next_char(p)) { + gunichar ch = g_utf8_get_char(p); + int w = g_unichar_iszerowidth(ch) ? 0 : (g_unichar_iswide(ch) ? 2 : 1); + + if (width + w > max_width) + break; + width += w; + nm_str_buf_append_len(&buf, p, g_utf8_next_char(p) - p); + } + nm_str_buf_append(&buf, "\xe2\x80\xa6"); /* U+2026 HORIZONTAL ELLIPSIS */ + + return nm_str_buf_dup_str(&buf); +} + /** * nmt_newt_edit_string: * @data: data to edit diff --git a/src/libnmt-newt/nmt-newt-utils.h b/src/libnmt-newt/nmt-newt-utils.h index 04089f9f8b..32237ae298 100644 --- a/src/libnmt-newt/nmt-newt-utils.h +++ b/src/libnmt-newt/nmt-newt-utils.h @@ -23,6 +23,8 @@ char *nmt_newt_locale_from_utf8(const char *str_utf8); int nmt_newt_text_width(const char *str); +char *nmt_newt_text_truncate(const char *str, int max_width); + void nmt_newt_message_dialog(const char *message, ...) _nm_printf(1, 2); int nmt_newt_choice_dialog(const char *button1, const char *button2, const char *message, ...) _nm_printf(3, 4); diff --git a/src/nmtui/nmt-connect-connection-list.c b/src/nmtui/nmt-connect-connection-list.c index 8e7dc807de..1980c30d90 100644 --- a/src/nmtui/nmt-connect-connection-list.c +++ b/src/nmtui/nmt-connect-connection-list.c @@ -17,6 +17,7 @@ #include "nmtui.h" #include "nmt-connect-connection-list.h" +#include "nmt-utils.h" #include "libnmc-base/nm-client-utils.h" G_DEFINE_TYPE(NmtConnectConnectionList, nmt_connect_connection_list, NMT_TYPE_NEWT_LISTBOX) @@ -47,6 +48,8 @@ typedef struct { typedef struct { GSList *nmt_devices; + char *filter_text; + int match_count; } NmtConnectConnectionListPrivate; /** @@ -444,6 +447,13 @@ connection_find_ac(NMConnection *conn, const GPtrArray *acs) return NULL; } +static gboolean +connection_matches(NmtConnectConnection *nmtconn, const char *needle) +{ + return nmt_utils_filter_match(nmtconn->name, needle) + || nmt_utils_filter_match(nmtconn->ssid, needle); +} + static void nmt_connect_connection_list_rebuild(NmtConnectConnectionList *list) { @@ -456,6 +466,8 @@ nmt_connect_connection_list_rebuild(NmtConnectConnectionList *list) GSList *nmt_devices, *diter, *citer; NmtConnectDevice *nmtdev; NmtConnectConnection *nmtconn; + gboolean did_group; + int n_matches = 0; g_slist_free_full(priv->nmt_devices, (GDestroyNotify) nmt_connect_device_free); priv->nmt_devices = NULL; @@ -486,18 +498,32 @@ nmt_connect_connection_list_rebuild(NmtConnectConnectionList *list) } } + did_group = FALSE; for (diter = nmt_devices; diter; diter = diter->next) { + gboolean dev_matches = FALSE; + nmtdev = diter->data; - if (nmtdev->conns) { - if (diter != nmt_devices) - nmt_newt_listbox_append(listbox, "", NULL); - nmt_newt_listbox_append(listbox, nmtdev->name, NULL); + for (citer = nmtdev->conns; citer; citer = citer->next) { + if (connection_matches(citer->data, priv->filter_text)) { + dev_matches = TRUE; + break; + } } + if (!dev_matches) + continue; + + if (did_group) + nmt_newt_listbox_append(listbox, "", NULL); + nmt_newt_listbox_append(listbox, nmtdev->name, NULL); + did_group = TRUE; for (citer = nmtdev->conns; citer; citer = citer->next) { nmtconn = citer->data; + if (!connection_matches(nmtconn, priv->filter_text)) + continue; + if (nmtconn->conn) nmtconn->active = connection_find_ac(nmtconn->conn, acs); if (nmtconn->active) { @@ -523,15 +549,36 @@ nmt_connect_connection_list_rebuild(NmtConnectConnectionList *list) nmt_newt_listbox_append(listbox, row, nmtconn); g_free(row); + n_matches++; } } priv->nmt_devices = nmt_devices; + priv->match_count = n_matches; g_object_notify(G_OBJECT(listbox), "active"); g_object_notify(G_OBJECT(listbox), "active-key"); } +void +nmt_connect_connection_list_set_filter_text(NmtConnectConnectionList *list, const char *text) +{ + NmtConnectConnectionListPrivate *priv = NMT_CONNECT_CONNECTION_LIST_GET_PRIVATE(list); + + if (nm_streq0(text, priv->filter_text)) + return; + + g_free(priv->filter_text); + priv->filter_text = g_strdup(text); + nmt_connect_connection_list_rebuild(list); +} + +int +nmt_connect_connection_list_get_match_count(NmtConnectConnectionList *list) +{ + return NMT_CONNECT_CONNECTION_LIST_GET_PRIVATE(list)->match_count; +} + static void rebuild_on_property_changed(GObject *object, GParamSpec *spec, gpointer list) { @@ -567,6 +614,7 @@ nmt_connect_connection_list_finalize(GObject *object) NmtConnectConnectionListPrivate *priv = NMT_CONNECT_CONNECTION_LIST_GET_PRIVATE(object); g_slist_free_full(priv->nmt_devices, (GDestroyNotify) nmt_connect_device_free); + nm_clear_g_free(&priv->filter_text); g_signal_handlers_disconnect_by_func(nm_client, G_CALLBACK(rebuild_on_property_changed), diff --git a/src/nmtui/nmt-connect-connection-list.h b/src/nmtui/nmt-connect-connection-list.h index bfc0c0073e..7b8d4a4be8 100644 --- a/src/nmtui/nmt-connect-connection-list.h +++ b/src/nmtui/nmt-connect-connection-list.h @@ -40,6 +40,10 @@ GType nmt_connect_connection_list_get_type(void); NmtNewtWidget *nmt_connect_connection_list_new(void); +void nmt_connect_connection_list_set_filter_text(NmtConnectConnectionList *list, const char *text); + +int nmt_connect_connection_list_get_match_count(NmtConnectConnectionList *list); + gboolean nmt_connect_connection_list_get_connection(NmtConnectConnectionList *list, const char *identifier, NMConnection **connection, diff --git a/src/nmtui/nmt-edit-connection-list.c b/src/nmtui/nmt-edit-connection-list.c index 1ea4400709..825e14941a 100644 --- a/src/nmtui/nmt-edit-connection-list.c +++ b/src/nmtui/nmt-edit-connection-list.c @@ -17,6 +17,7 @@ #include "nmtui-edit.h" #include "nmt-edit-connection-list.h" #include "nmt-editor.h" +#include "nmt-utils.h" #include "nm-editor-utils.h" @@ -35,6 +36,10 @@ typedef struct { NmtNewtListbox *listbox; NmtNewtButtonBox *buttons; + NmtSearch *search; + char *filter_text; + int match_count; + NmtNewtWidget *add; NmtNewtWidget *edit; NmtNewtWidget *delete; @@ -68,13 +73,16 @@ static void add_clicked(NmtNewtButton *button, gpointer list); static void edit_clicked(NmtNewtButton *button, gpointer list); static void delete_clicked(NmtNewtButton *button, gpointer list); static void listbox_activated(NmtNewtWidget *listbox, gpointer list); +static void edit_search_apply(gpointer list, const char *text); +static int edit_search_count(gpointer list); static void nmt_edit_connection_list_init(NmtEditConnectionList *list) { NmtEditConnectionListPrivate *priv = NMT_EDIT_CONNECTION_LIST_GET_PRIVATE(list); - NmtNewtWidget *listbox, *buttons; + NmtNewtWidget *listbox, *buttons, *search_row, *search_label, *search; NmtNewtGrid *grid = NMT_NEWT_GRID(list); + NmtNewtGrid *search_grid; listbox = g_object_new(NMT_TYPE_NEWT_LISTBOX, "flags", @@ -90,6 +98,28 @@ nmt_edit_connection_list_init(NmtEditConnectionList *list) | NMT_NEWT_GRID_EXPAND_Y); g_signal_connect(priv->listbox, "activated", G_CALLBACK(listbox_activated), list); + /* Search row below the listbox. The row is always present so revealing the + * entry does not resize the form; vim-style '/' shows the entry, and once a + * filter is applied the label reports it ("Matching '...' (N)"). */ + search_row = nmt_newt_grid_new(); + search_grid = NMT_NEWT_GRID(search_row); + nmt_newt_grid_add(grid, search_row, 0, 1); + nmt_newt_grid_set_flags(grid, search_row, NMT_NEWT_GRID_FILL_X | NMT_NEWT_GRID_EXPAND_X); + + search_label = nmt_newt_label_new(""); + nmt_newt_grid_add(search_grid, search_label, 0, 0); + + search = nmt_newt_entry_new(NMT_SEARCH_ENTRY_WIDTH, 0); + nmt_newt_grid_add(search_grid, search, 1, 0); + nmt_newt_widget_set_padding(search, 1, 0, 0, 0); + + priv->search = nmt_search_new(NMT_NEWT_ENTRY(search), + NMT_NEWT_LABEL(search_label), + NMT_NEWT_WIDGET(priv->listbox), + edit_search_apply, + edit_search_count, + list); + buttons = nmt_newt_button_box_new(NMT_NEWT_BUTTON_BOX_VERTICAL); priv->buttons = NMT_NEWT_BUTTON_BOX(buttons); nmt_newt_grid_add(grid, buttons, 1, 0); @@ -157,7 +187,7 @@ nmt_edit_connection_list_rebuild(NmtEditConnectionList *list) gboolean did_header = FALSE, did_vpn = FALSE, did_any = FALSE; NMEditorConnectionTypeData **types; NMConnection *conn, *selected_conn; - int i, row, selected_row; + int i, row, selected_row, n_matches = 0; selected_row = nmt_newt_listbox_get_active(priv->listbox); selected_conn = nmt_newt_listbox_get_active_key(priv->listbox); @@ -185,17 +215,21 @@ nmt_edit_connection_list_rebuild(NmtEditConnectionList *list) if (!priv->grouped) { /* Just add the connections in order */ - for (iter = priv->connections, row = 0; iter; iter = iter->next, row++) { + for (iter = priv->connections, row = 0; iter; iter = iter->next) { conn = iter->data; + if (!nmt_utils_filter_match(nm_connection_get_id(conn), priv->filter_text)) + continue; nmt_newt_listbox_append(priv->listbox, nm_connection_get_id(conn), conn); if (conn == selected_conn) selected_row = row; + row++; + n_matches++; } if (selected_row >= row) selected_row = row - 1; nmt_newt_listbox_set_active(priv->listbox, selected_row); - did_any = !!priv->connections; + did_any = n_matches > 0; goto done; } @@ -220,6 +254,8 @@ nmt_edit_connection_list_rebuild(NmtEditConnectionList *list) continue; if (!nm_connection_is_type(conn, nm_setting_get_name(setting))) continue; + if (!nmt_utils_filter_match(nm_connection_get_id(conn), priv->filter_text)) + continue; if (!did_header) { nmt_newt_listbox_append(priv->listbox, types[i]->name, NULL); @@ -240,6 +276,7 @@ nmt_edit_connection_list_rebuild(NmtEditConnectionList *list) if (conn == selected_conn) selected_row = row; row++; + n_matches++; } } @@ -248,6 +285,9 @@ nmt_edit_connection_list_rebuild(NmtEditConnectionList *list) nmt_newt_listbox_set_active(priv->listbox, selected_row); done: + priv->match_count = n_matches; + if (priv->search) + nmt_search_update_label(priv->search); nmt_newt_component_set_sensitive(NMT_NEWT_COMPONENT(priv->edit), did_any); nmt_newt_component_set_sensitive(NMT_NEWT_COMPONENT(priv->delete), did_any); } @@ -315,6 +355,31 @@ listbox_activated(NmtNewtWidget *listbox, gpointer list) edit_clicked(NMT_NEWT_BUTTON(priv->edit), list); } +static void +edit_search_apply(gpointer list, const char *text) +{ + NmtEditConnectionListPrivate *priv = NMT_EDIT_CONNECTION_LIST_GET_PRIVATE(list); + + if (nm_streq0(text, priv->filter_text)) + return; + + g_free(priv->filter_text); + priv->filter_text = g_strdup(text); + nmt_edit_connection_list_rebuild(list); +} + +static int +edit_search_count(gpointer list) +{ + return NMT_EDIT_CONNECTION_LIST_GET_PRIVATE(list)->match_count; +} + +void +nmt_edit_connection_list_bind_search(NmtEditConnectionList *list, NmtNewtForm *form) +{ + nmt_search_bind_form(NMT_EDIT_CONNECTION_LIST_GET_PRIVATE(list)->search, form); +} + static void connection_saved(GObject *conn, GAsyncResult *result, gpointer user_data) { @@ -348,6 +413,8 @@ nmt_edit_connection_list_finalize(GObject *object) free_connections(NMT_EDIT_CONNECTION_LIST(object)); g_clear_object(&priv->extra); + nm_clear_pointer(&priv->search, g_free); + nm_clear_g_free(&priv->filter_text); G_OBJECT_CLASS(nmt_edit_connection_list_parent_class)->finalize(object); } diff --git a/src/nmtui/nmt-edit-connection-list.h b/src/nmtui/nmt-edit-connection-list.h index 0d23f8b157..0846893f7c 100644 --- a/src/nmtui/nmt-edit-connection-list.h +++ b/src/nmtui/nmt-edit-connection-list.h @@ -42,4 +42,6 @@ typedef gboolean (*NmtEditConnectionListFilter)(NmtEditConnectionList *list, void nmt_edit_connection_list_recommit(NmtEditConnectionList *list); +void nmt_edit_connection_list_bind_search(NmtEditConnectionList *list, NmtNewtForm *form); + #endif /* NMT_EDIT_CONNECTION_LIST_H */ diff --git a/src/nmtui/nmt-utils.c b/src/nmtui/nmt-utils.c index 7a50054258..148391efec 100644 --- a/src/nmtui/nmt-utils.c +++ b/src/nmtui/nmt-utils.c @@ -12,6 +12,8 @@ #include "nmt-utils.h" +#include "libnmt-newt/nmt-newt.h" + /** * NmtSyncOp: * @@ -128,3 +130,178 @@ nmt_sync_op_complete_pointer(NmtSyncOp *op, gpointer result, GError *error) real->error = error ? g_error_copy(error) : NULL; real->complete = GUINT_TO_POINTER(TRUE); } + +/** + * nmt_utils_filter_match: + * @haystack: (nullable): the string to search in + * @needle: (nullable): the search term + * + * Case-insensitive UTF-8 substring test. An empty or %NULL @needle matches + * anything; a %NULL @haystack matches only an empty @needle. + * + * Returns: %TRUE if @haystack contains @needle. + */ +gboolean +nmt_utils_filter_match(const char *haystack, const char *needle) +{ + gs_free char *h = NULL; + gs_free char *n = NULL; + + if (!needle || !needle[0]) + return TRUE; + if (!haystack) + return FALSE; + + h = g_utf8_casefold(haystack, -1); + n = g_utf8_casefold(needle, -1); + return strstr(h, n) != NULL; +} + +/* + * Renders the search state into @label: "Search:" while typing, + * "Matching '...' (N)" once a filter is applied with the entry hidden, or + * empty when idle. + * + * When the entry is hidden the text is padded to the width the row occupies + * while searching ("Search:" plus the entry), so revealing or hiding the entry + * never changes the form's width. + */ +static void +set_search_label(NmtNewtLabel *label, const char *filter_text, int match_count, gboolean searching) +{ + gs_free char *body = NULL; + int reserve, body_width; + + if (searching) { + nmt_newt_label_set_text(label, _("Search:")); + return; + } + + reserve = nmt_newt_text_width(_("Search:")) + 1 + NMT_SEARCH_ENTRY_WIDTH; + + if (!nm_str_is_empty(filter_text)) { + gs_free char *shown = NULL; + int overhead; + + /* Echo the filter, but elide it so the confirmed label never exceeds + * the reserved width; otherwise the form grows when a long search is + * confirmed with Enter. */ + body = g_strdup_printf(_("Matching '%s' (%d)"), "", match_count); + overhead = nmt_newt_text_width(body); + nm_clear_g_free(&body); + + shown = nmt_newt_text_truncate(filter_text, reserve - overhead); + body = g_strdup_printf(_("Matching '%s' (%d)"), shown, match_count); + } else + body = g_strdup(""); + + body_width = nmt_newt_text_width(body); + if (body_width < reserve) { + gs_free char *padded = NULL; + + padded = g_strdup_printf("%s%*s", body, reserve - body_width, ""); + nmt_newt_label_set_text(label, padded); + } else + nmt_newt_label_set_text(label, body); +} + +struct _NmtSearch { + NmtNewtEntry *entry; + NmtNewtLabel *label; + NmtNewtWidget *focus; + NmtSearchApplyFunc apply; + NmtSearchCountFunc count; + gpointer user_data; +}; + +void +nmt_search_update_label(NmtSearch *search) +{ + set_search_label(search->label, + nmt_newt_entry_get_text(search->entry), + search->count(search->user_data), + nmt_newt_widget_get_visible(NMT_NEWT_WIDGET(search->entry))); +} + +static void +search_text_changed(GObject *entry, GParamSpec *pspec, gpointer user_data) +{ + NmtSearch *search = user_data; + + search->apply(search->user_data, nmt_newt_entry_get_text(search->entry)); + nmt_search_update_label(search); +} + +static void +search_activated(NmtNewtWidget *entry, gpointer user_data) +{ + NmtSearch *search = user_data; + NmtNewtForm *form = nmt_newt_widget_get_form(search->focus); + + /* Enter: hide the entry but keep the filter; the label now reports it. */ + nmt_newt_widget_set_visible(NMT_NEWT_WIDGET(search->entry), FALSE); + nmt_search_update_label(search); + if (form) + nmt_newt_form_set_focus(form, search->focus); +} + +static gboolean +search_hotkey(NmtNewtForm *form, int key, gpointer user_data) +{ + NmtSearch *search = user_data; + const char *filter = nmt_newt_entry_get_text(search->entry); + + if (key == '/') { + nmt_newt_widget_set_visible(NMT_NEWT_WIDGET(search->entry), TRUE); + nmt_search_update_label(search); + nmt_newt_form_set_focus(form, NMT_NEWT_WIDGET(search->entry)); + return TRUE; + } + if (key == NEWT_KEY_ESCAPE + && (nmt_newt_widget_get_visible(NMT_NEWT_WIDGET(search->entry)) + || !nm_str_is_empty(filter))) { + /* Esc: clear the filter (via notify::text) and return to the list. */ + nmt_newt_entry_set_text(search->entry, ""); + nmt_newt_widget_set_visible(NMT_NEWT_WIDGET(search->entry), FALSE); + nmt_search_update_label(search); + nmt_newt_form_set_focus(form, search->focus); + return TRUE; + } + return FALSE; +} + +NmtSearch * +nmt_search_new(NmtNewtEntry *entry, + NmtNewtLabel *label, + NmtNewtWidget *focus, + NmtSearchApplyFunc apply, + NmtSearchCountFunc count, + gpointer user_data) +{ + NmtSearch *search = g_new0(NmtSearch, 1); + + search->entry = entry; + search->label = label; + search->focus = focus; + search->apply = apply; + search->count = count; + search->user_data = user_data; + + nmt_newt_widget_set_visible(NMT_NEWT_WIDGET(entry), FALSE); + g_signal_connect(entry, "notify::text", G_CALLBACK(search_text_changed), search); + g_signal_connect(entry, "activated", G_CALLBACK(search_activated), search); + + return search; +} + +void +nmt_search_bind_form(NmtSearch *search, NmtNewtForm *form) +{ + g_signal_connect(form, "hotkey", G_CALLBACK(search_hotkey), search); + nmt_newt_form_add_hotkey(form, '/'); + nmt_newt_form_set_stable_width(form); + + /* Reserve the search row's width up front so the form does not grow when + * the entry is first revealed. */ + nmt_search_update_label(search); +} diff --git a/src/nmtui/nmt-utils.h b/src/nmtui/nmt-utils.h index 3b780ae76f..d73b88c49d 100644 --- a/src/nmtui/nmt-utils.h +++ b/src/nmtui/nmt-utils.h @@ -6,6 +6,8 @@ #ifndef NMT_UTILS_H #define NMT_UTILS_H +#include "libnmt-newt/nmt-newt-types.h" + typedef struct { gpointer private[3]; } NmtSyncOp; @@ -18,4 +20,34 @@ void nmt_sync_op_complete_boolean(NmtSyncOp *op, gboolean result, GError *er gpointer nmt_sync_op_wait_pointer(NmtSyncOp *op, GError **error); void nmt_sync_op_complete_pointer(NmtSyncOp *op, gpointer result, GError *error); +gboolean nmt_utils_filter_match(const char *haystack, const char *needle); + +#define NMT_SEARCH_ENTRY_WIDTH 34 + +/** + * NmtSearch: + * + * Vim-style '/' search glue shared by the connection lists. The caller builds + * the @label and @entry into its own layout and hands them over; NmtSearch + * wires '/' to reveal the entry, Enter to confirm, Esc to clear, keeps the + * status label in sync, and pins the form width so the list does not jump. + * + * @apply is invoked with the current text whenever the filter changes; @count + * returns the live match count for the label. + */ +typedef struct _NmtSearch NmtSearch; + +typedef void (*NmtSearchApplyFunc)(gpointer user_data, const char *text); +typedef int (*NmtSearchCountFunc)(gpointer user_data); + +NmtSearch *nmt_search_new(NmtNewtEntry *entry, + NmtNewtLabel *label, + NmtNewtWidget *focus, + NmtSearchApplyFunc apply, + NmtSearchCountFunc count, + gpointer user_data); + +void nmt_search_bind_form(NmtSearch *search, NmtNewtForm *form); +void nmt_search_update_label(NmtSearch *search); + #endif /* NMT_UTILS_H */ diff --git a/src/nmtui/nmtui-connect.c b/src/nmtui/nmtui-connect.c index e2d91e7ffc..492bc13d05 100644 --- a/src/nmtui/nmtui-connect.c +++ b/src/nmtui/nmtui-connect.c @@ -583,12 +583,26 @@ wifi_rescan(NmtNewtButton *button, gpointer data_batch) } } +static void +connect_search_apply(gpointer list, const char *text) +{ + nmt_connect_connection_list_set_filter_text(NMT_CONNECT_CONNECTION_LIST(list), text); +} + +static int +connect_search_count(gpointer list) +{ + return nmt_connect_connection_list_get_match_count(NMT_CONNECT_CONNECTION_LIST(list)); +} + static NmtNewtForm * nmt_connect_connection_list(gboolean is_top) { int screen_width, screen_height; NmtNewtForm *form; NmtNewtWidget *list, *activate, *quit, *bbox, *grid, *rescan; + NmtNewtWidget *search_row, *search_label, *search_entry; + NmtSearch *search; RescanBatch *batch_data; gs_unref_ptrarray GPtrArray *all_active_wifi_devices = NULL; @@ -634,6 +648,31 @@ nmt_connect_connection_list(gboolean is_top) quit = nmt_newt_button_box_add_end(NMT_NEWT_BUTTON_BOX(bbox), is_top ? _("Quit") : _("Back")); nmt_newt_widget_set_exit_on_activate(quit, TRUE); + /* Search row below the list. The row is always present so revealing the + * entry does not resize the form; vim-style '/' shows the entry, and once a + * filter is applied the label reports it ("Matching '...' (N)"). */ + search_row = nmt_newt_grid_new(); + nmt_newt_grid_add(NMT_NEWT_GRID(grid), search_row, 0, 1); + nmt_newt_grid_set_flags(NMT_NEWT_GRID(grid), + search_row, + NMT_NEWT_GRID_FILL_X | NMT_NEWT_GRID_EXPAND_X); + + search_label = nmt_newt_label_new(""); + nmt_newt_grid_add(NMT_NEWT_GRID(search_row), search_label, 0, 0); + + search_entry = nmt_newt_entry_new(NMT_SEARCH_ENTRY_WIDTH, 0); + nmt_newt_grid_add(NMT_NEWT_GRID(search_row), search_entry, 1, 0); + nmt_newt_widget_set_padding(search_entry, 1, 0, 0, 0); + + search = nmt_search_new(NMT_NEWT_ENTRY(search_entry), + NMT_NEWT_LABEL(search_label), + list, + connect_search_apply, + connect_search_count, + list); + nmt_search_bind_form(search, form); + g_object_set_data_full(G_OBJECT(form), "search-data", search, g_free); + nmt_newt_form_set_content(form, grid); return form; } diff --git a/src/nmtui/nmtui-edit.c b/src/nmtui/nmtui-edit.c index 0ba3bd0da7..1bc1424579 100644 --- a/src/nmtui/nmtui-edit.c +++ b/src/nmtui/nmtui-edit.c @@ -107,6 +107,8 @@ nmt_edit_main_connection_list(gboolean is_top) g_signal_connect(list, "edit-connection", G_CALLBACK(list_edit_connection), form); g_signal_connect(list, "remove-connection", G_CALLBACK(list_remove_connection), form); + nmt_edit_connection_list_bind_search(NMT_EDIT_CONNECTION_LIST(list), form); + nmt_newt_form_set_content(form, list); return form; } From 0d70933c3beecf525d795b8b67eca455c9e90bdf Mon Sep 17 00:00:00 2001 From: Josephine Pfeiffer Date: Tue, 16 Jun 2026 20:26:15 +0200 Subject: [PATCH 3/3] NEWS: mention the nmtui connection-list search --- NEWS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NEWS b/NEWS index 2b805b95e0..8a83616005 100644 --- a/NEWS +++ b/NEWS @@ -71,6 +71,8 @@ USE AT YOUR OWN RISK. NOT RECOMMENDED FOR PRODUCTION USE! secrets when activating a connection, matching the connection editor. * Fix an out-of-bounds read in the internal DHCPv4 client that an on-link attacker could trigger with a malformed UDP packet, crashing NetworkManager. +* The nmtui connection lists ("nmtui connect" and "nmtui edit") support a + vim-style "/" search that filters the list to matching entries as you type. ============================================= NetworkManager-1.56