diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-23 09:24:40 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-23 09:24:40 +0200 |
| commit | 6159042d0cb3870e6a7c6f8d9a97e175d59e6f80 (patch) | |
| tree | c6422654298a744b0a791de8f1e8ccb8e1c55071 | |
| parent | 53071bb69f5d21b35b8a61c2b1dd18832ce781d4 (diff) | |
| download | bokf-6159042d0cb3870e6a7c6f8d9a97e175d59e6f80.tar.gz bokf-6159042d0cb3870e6a7c6f8d9a97e175d59e6f80.zip | |
tui: 'g' + a letter searches list rows
The goto prompt decides on its first key: digits jump to a row number as
before, any other printable key searches the row texts of the list or
menu (case-insensitive, also åäö), with up/down stepping through the
matches. Keys typed into the prompt no longer reach the screen's key
hook, and Enter in the action menu closes an open prompt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | clients/tui.c | 192 | ||||
| -rw-r--r-- | clients/tui.h | 11 | ||||
| -rw-r--r-- | docs/STATE.md | 6 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 5 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 29 | ||||
| -rw-r--r-- | tests/test_tui.c | 93 |
6 files changed, 284 insertions, 52 deletions
diff --git a/clients/tui.c b/clients/tui.c index aa859e2..4bc1d66 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -991,6 +991,69 @@ static int nav_count(const struct tui_list_nav *v) return c; } +/* Lower-cases ASCII and the two-byte Latin-1 capitals (U+00C0-U+00DE except + U+00D7) into dst. */ +static void fold_copy(char *dst, size_t n, const char *src) +{ + size_t o = 0; + for (const unsigned char *p = (const unsigned char *)src; + *p && o + 1 < n; p++) { + unsigned char c = *p; + if (c >= 'A' && c <= 'Z') { + c = (unsigned char)(c + 32); + } else if (c == 0xC3 && p[1] >= 0x80 && p[1] <= 0x9E && + p[1] != 0x97 && o + 2 < n) { + dst[o++] = (char)c; + c = (unsigned char)(*++p + 0x20); + } + dst[o++] = (char)c; + } + if (n) + dst[o] = '\0'; +} + +int tui_text_match(const char *hay, const char *needle) +{ + if (!needle || !*needle) + return 1; + if (!hay) + return 0; + char h[1024], nd[128]; + fold_copy(h, sizeof h, hay); + fold_copy(nd, sizeof nd, needle); + return strstr(h, nd) != NULL; +} + +/* Next selectable row matching the search, starting at `from` and moving in + dir (wrapping); `from` itself counts. -1 when nothing matches. */ +static int nav_find(const struct tui_list_nav *v, int from, int dir) +{ + if (!v->items || v->n <= 0) + return -1; + for (int k = 0; k < v->n; k++) { + int i = ((from + k * dir) % v->n + v->n) % v->n; + if (nav_selectable(v, i) && tui_text_match(v->items[i], v->gotobuf)) + return i; + } + return -1; +} + +static void nav_search_to(struct tui_list_nav *v, int from, int dir) +{ + int r = nav_find(v, from, dir); + v->goto_miss = r < 0; + if (r >= 0) + v->sel = r; +} + +static void nav_goto_close(struct tui_list_nav *v) +{ + v->goto_active = 0; + v->goto_search = 0; + v->goto_miss = 0; + v->gotobuf[0] = '\0'; +} + /* Keys return the same codes the screens have always used: the selected index, or -1 back, -2 refresh, -4 new, -6 remove, -7 toggle. */ int tui_nav_key(struct tui_list_nav *v, int ch, int allow_new, @@ -1009,26 +1072,51 @@ int tui_nav_key(struct tui_list_nav *v, int ch, int allow_new, return TUI_NAV_NONE; } if (v->goto_active) { - if (ch >= '0' && ch <= '9' && strlen(v->gotobuf) < 9) { - size_t gl = strlen(v->gotobuf); - v->gotobuf[gl] = (char)ch; - v->gotobuf[gl + 1] = '\0'; - int r = nav_row_of(v, atoi(v->gotobuf) - 1); - if (r >= 0) - v->sel = r; + /* the first key decides: digits jump to a row number, anything + else printable searches the row texts */ + size_t gl = strlen(v->gotobuf); + int printable = ch >= 32 && ch < 256 && ch != 127; + if (!v->goto_search && ch >= '0' && ch <= '9') { + if (gl < 9) { + v->gotobuf[gl] = (char)ch; + v->gotobuf[gl + 1] = '\0'; + int r = nav_row_of(v, atoi(v->gotobuf) - 1); + if (r >= 0) + v->sel = r; + } + } else if (printable && v->items && + (v->goto_search || (gl == 0 && ch != ' '))) { + if (gl + 1 < sizeof v->gotobuf) { + v->gotobuf[gl] = (char)ch; + v->gotobuf[gl + 1] = '\0'; + } + v->goto_search = 1; + /* search once the UTF-8 character is complete */ + if ((ch & 0xC0) != 0xC0) + nav_search_to(v, 0, 1); + } else if (v->goto_search && (ch == KEY_DOWN || ch == KEY_UP)) { + int dir = ch == KEY_DOWN ? 1 : -1; + nav_search_to(v, v->sel + dir, dir); } else if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { - size_t gl = strlen(v->gotobuf); + while (gl > 0 && + ((unsigned char)v->gotobuf[gl - 1] & 0xC0) == 0x80) + gl--; if (gl) - v->gotobuf[gl - 1] = '\0'; - if (v->gotobuf[0]) { + gl--; + v->gotobuf[gl] = '\0'; + if (!gl) { + v->goto_search = 0; + v->goto_miss = 0; + } else if (v->goto_search) { + nav_search_to(v, 0, 1); + } else { int r = nav_row_of(v, atoi(v->gotobuf) - 1); if (r >= 0) v->sel = r; } } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER || ch == 27 || - ch == 'g' || ch == 'G') { - v->goto_active = 0; - v->gotobuf[0] = '\0'; + (!v->goto_search && (ch == 'g' || ch == 'G'))) { + nav_goto_close(v); } return -3; } @@ -1060,8 +1148,8 @@ int tui_nav_key(struct tui_list_nav *v, int ch, int allow_new, return v->sel; /* menus activate directly */ } } else if (ch == 'g' || ch == 'G') { + nav_goto_close(v); v->goto_active = 1; - v->gotobuf[0] = '\0'; } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) return v->sel; else if (ch == KEY_F(5)) @@ -1087,6 +1175,33 @@ int tui_nav_key(struct tui_list_nav *v, int ch, int allow_new, /* list and menu rendering */ /* ------------------------------------------------------------------ */ +/* The goto/search prompt on the line above the hints (blank when closed). */ +static void nav_goto_draw(const struct tui_list_nav *v) +{ + move(LINES - 2, 2); + clrtoeol(); + if (!v->goto_active) + return; + char label[64]; + size_t ll = strlen(v->gotolabel); + if (v->goto_search) + snprintf(label, sizeof label, "Sök: "); + else if (!v->gotobuf[0] && v->items && ll > 2) + snprintf(label, sizeof label, "%.*s eller sök (bokstav): ", + (int)(ll - 2), v->gotolabel); + else + snprintf(label, sizeof label, "%s", v->gotolabel); + tui_style(TUI_ACCENT); + printw("%s%s", label, v->gotobuf); + tui_style_reset(); + tui_style(TUI_DIM); + if (v->goto_miss) + addstr(" (ingen träff)"); + else if (v->goto_search) + addstr(" upp/ned = fler träffar Enter/Esc = stäng"); + tui_style_reset(); +} + static void list_draw(const char *title, char **items, int n, const struct tui_list_nav *v, const char *hint) { @@ -1106,16 +1221,7 @@ static void list_draw(const char *title, char **items, int n, if (idx == v->sel) attroff(A_REVERSE); } - if (v->goto_active) { - move(LINES - 2, 2); - tui_style(TUI_ACCENT); - printw("%s%s", v->gotolabel, v->gotobuf); - tui_style_reset(); - clrtoeol(); - } else { - move(LINES - 2, 2); - clrtoeol(); - } + nav_goto_draw(v); if (g_hints) g_hints(hint); refresh(); @@ -1155,6 +1261,7 @@ int tui_select_list_hook(const char *title, char **items, int n, int start, v.sel = n - 1; v.view = LINES - 4; v.numw = tui_num_width(n); + v.items = (const char *const *)items; snprintf(v.gotolabel, sizeof v.gotolabel, "Gå till rad: "); for (;;) { if (cursor) @@ -1164,7 +1271,7 @@ int tui_select_list_hook(const char *title, char **items, int n, int start, if (archive_action) snprintf(dbuf, sizeof dbuf, " d = %s", archive_action); snprintf(hint, sizeof hint, - "upp/ned, 1-9 = hoppa, g = gå till, PgUp/PgDn, Home/End," + "upp/ned, 1-9 = hoppa, g = gå till/sök, PgUp/PgDn, Home/End," " Enter%s%s%s%s Esc/q = tillbaka ^C = avsluta", allow_refresh ? " F5 = uppdatera" : "", allow_new ? " ^N = ny" : "", dbuf, @@ -1175,9 +1282,10 @@ int tui_select_list_hook(const char *title, char **items, int n, int start, } list_draw(title, items, n, &v, hint); int ch = in_key(); + int in_goto = v.goto_active; int r = tui_nav_key(&v, ch, allow_new, archive_action != NULL, allow_toggle, 0); - if (r == TUI_NAV_NONE && key) { + if (r == TUI_NAV_NONE && key && !in_goto && !v.goto_active) { int h = key(ud, ch); if (h == TUI_HOOK_BACK) return -1; @@ -1219,6 +1327,7 @@ int tui_menu(const char *title, const char *const *items, int n, if (v.numw < 2) v.numw = 2; v.view = (LINES - 4) / 2; + v.items = items; snprintf(v.gotolabel, sizeof v.gotolabel, "Gå till nummer: "); for (;;) { if (cursor) @@ -1248,22 +1357,13 @@ int tui_menu(const char *title, const char *const *items, int n, if (idx == v.sel) attroff(A_REVERSE); } - if (v.goto_active) { - move(LINES - 2, 2); - tui_style(TUI_ACCENT); - printw("%s%s", v.gotolabel, v.gotobuf); - tui_style_reset(); - clrtoeol(); - } else { - move(LINES - 2, 2); - clrtoeol(); - } + nav_goto_draw(&v); if (g_hints) g_hints(allow_new - ? "upp/ned, 1-9 = snabbval, g = gå till, PgUp/PgDn," + ? "upp/ned, 1-9 = snabbval, g = gå till/sök, PgUp/PgDn," " Home/End, Enter ^N = ny Esc/q = tillbaka" " ^C = avsluta" - : "upp/ned, 1-9 = snabbval, g = gå till, PgUp/PgDn," + : "upp/ned, 1-9 = snabbval, g = gå till/sök, PgUp/PgDn," " Home/End, Enter Esc/q = tillbaka ^C = avsluta"); refresh(); int r = tui_nav_key(&v, in_key(), allow_new, 0, 0, 1); @@ -1723,6 +1823,7 @@ int tui_action_menu(const char *title, const struct tui_action *acts, int n, if (v.numw < 2) v.numw = 2; v.view = LINES - 4; + v.items = (const char *const *)items; snprintf(v.gotolabel, sizeof v.gotolabel, "Gå till nummer: "); for (;;) { int num = 0; @@ -1762,22 +1863,13 @@ int tui_action_menu(const char *title, const struct tui_action *acts, int n, if (acts[idx].enabled != 1) attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); } - if (v.goto_active) { - move(LINES - 2, 2); - tui_style(TUI_ACCENT); - printw("%s%s", v.gotolabel, v.gotobuf); - tui_style_reset(); - clrtoeol(); - } else { - move(LINES - 2, 2); - clrtoeol(); - } + nav_goto_draw(&v); if (g_hints) - g_hints("upp/ned, 1-9 = hoppa, g = gå till, PgUp/PgDn, Home/End," + g_hints("upp/ned, 1-9 = hoppa, g = gå till/sök, PgUp/PgDn, Home/End," " Enter = utför Esc/q = avbryt"); refresh(); int ch = in_key(); - if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { + if (!v.goto_active && (ch == '\n' || ch == '\r' || ch == KEY_ENTER)) { if (!ok[v.sel]) continue; if (acts[v.sel].enabled != 1) { diff --git a/clients/tui.h b/clients/tui.h index c4fe116..4ae0da6 100644 --- a/clients/tui.h +++ b/clients/tui.h @@ -130,14 +130,23 @@ int tui_choice_prompt(const char *label, const char *const *opts, int n, struct tui_list_nav { int n, sel, top, view, numw; - char gotobuf[12]; + char gotobuf[64]; char gotolabel[32]; int goto_active; + /* The goto prompt became a search: its first key was not a digit. */ + int goto_search; + int goto_miss; /* the search text matches no row */ /* Optional row flags: 0 marks a non-selectable row (a menu section header). Navigation, numbering and goto skip them. */ const unsigned char *selectable; + /* Optional row texts for the goto search; NULL: digits only. */ + const char *const *items; }; +/* Whether needle occurs in hay, ignoring case for ASCII and the Latin-1 + letters (Å/Ä/Ö/É…). An empty needle matches. Pure. */ +int tui_text_match(const char *hay, const char *needle); + /* Returns the selected index, or -1 back, -2 refresh, -4 new, -6 remove, -7 toggle, TUI_NAV_NONE when nothing happened. */ int tui_nav_key(struct tui_list_nav *v, int ch, int allow_new, diff --git a/docs/STATE.md b/docs/STATE.md index d77cd78..9d954cd 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -14,6 +14,12 @@ unit tests and the docs consistency check. ## Resume here (2026-09-22) +- **Goto search (2026-09-23, branch `eff/goto-search`)**: `g` followed by a + letter searches the row texts of any list or menu (case-insensitive, åäö + too; up/down = next/previous match); a digit still jumps to a row number. + Keys typed into the prompt no longer reach the screen's key hook, and + Enter in the F2 action menu closes an open prompt instead of running the + action. - **Verifikat list/detail (2026-09-23, branch `eff/voucher-nav`)**: the detail's columns are padded by display width (the header's `Benämning` used to shift a column), zero amounts are blank, row texts and a `Summa` diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md index 4895c9f..c2aff3d 100644 --- a/docs/TUI-GUIDELINES.md +++ b/docs/TUI-GUIDELINES.md @@ -141,7 +141,7 @@ there. | `Ctrl+R` | Reload the client in place: re-execs the installed binary and restores session, org, fiscal year and the current view (for after a rebuild) | | `Ctrl+A` | Close or reopen the highlighted räkenskapsår (Räkenskapsår screen; asks for confirmation) | | `1`–`9` | In lists: jump to that row. In menus: activate that item | -| `g` | Live goto: "Gå till rad/nummer:" updates the selection as you type digits (backspace steps back); Enter closes the prompt without opening anything | +| `g` | Live goto or search in every list and menu. The first key decides: a digit makes it "Gå till rad/nummer:" (the selection follows the number as you type, backspace steps back, `g` closes); any other printable key makes it "Sök:" — the selection jumps to the first row (from the top) whose text contains the typed text, case-insensitive also for åäö; up/down step to the next/previous match and wrap; "(ingen träff)" when nothing matches. Digits, `g`, `q` and screen keys (`s` = sortera, `d`…) are text while searching. Backspace to empty returns to the undecided prompt. Enter/Esc close the prompt without opening anything; the selection stays | | arrows, PgUp/PgDn, Home/End | Move/scroll; selection always stays visible. Pages stop at the first/last row, they never wrap | | `e` | Edit the shown object (IB, where applicable) | | `a` | Add/upload (Underlag) | @@ -373,6 +373,9 @@ only place that touches ncurses. Rules: to the key hook (record stepping) and the footer says `PgUp/PgDn/Home/End rullar` instead. Plain `tui_pager` keeps `s` on when a save hint is passed. Pager lines go through `tui_markup` (see below). +- `struct tui_list_nav.items` holds the row texts the goto search matches + (`tui_text_match`); `tui_select_list*`, `tui_menu` and `tui_action_menu` + set it. Keys typed into the goto prompt never reach a screen's key hook. - Styles come from `tui_style`/`tui_style_attrs`; report markup and menu sections from `tui_markup`/`TUI_MARK_HEADING`. Their fallbacks and the navigation over section headers are unit-tested in `tests/test_tui.c`. diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py index 864210c..71e1273 100755 --- a/scripts/tui-golden.py +++ b/scripts/tui-golden.py @@ -589,6 +589,35 @@ SCENARIOS = [ ], }, { + # 'g' + a letter searches the list rows; the screen's own keys + # ('s' = sortera) are text while the prompt is open. + "name": "voucher-goto-search", + "screen": "vouchers", + "expect": ["sorterade på nummer, stigande", "F1"], + "steps": [ + { + "keys": ["g"], + "expect": ["Gå till rad eller sök (bokstav):"], + }, + { + "keys": ["s"], + "expect": ["Sök: s", "sorterade på nummer, stigande"], + }, + { + "keys": ["backspace", "zzz"], + "expect": ["Sök: zzz", "(ingen träff)"], + }, + { + "keys": ["esc", "g", "GOLDEN VER"], + "expect": ["Sök: GOLDEN VER", "upp/ned = fler träffar"], + }, + { + "keys": ["enter", "enter"], + "expect": ["Verifikat A1", "Golden verifikat"], + }, + ], + }, + { # Backspace in a freshly opened field edits the value; the next # key must not replace everything. "name": "form-edit-backspace", diff --git a/tests/test_tui.c b/tests/test_tui.c index 7ab969b..d01a546 100644 --- a/tests/test_tui.c +++ b/tests/test_tui.c @@ -453,6 +453,98 @@ static void test_sticky(void) static const unsigned char SECTION_FLAGS[5] = { 0, 1, 1, 0, 1 }; +static void test_goto_search(void) +{ + const char *rows[] = { + " x A1 2026-01-01 Golden verifikat", + " A2 2026-01-05 Hyra kontor", + " A10 2026-02-01 Företagsförsäkring", + " F1 2026-02-03 Faktura 1 Testkund AB", + " A11 2026-03-01 Hyra kontor mars", + }; + struct tui_list_nav v; + nav_init(&v, 5); + v.items = rows; + + /* case-insensitive, also for åäö */ + CHECK(tui_text_match("Företagsförsäkring", "FÖRSÄK")); + CHECK(tui_text_match("ÅRSREDOVISNING", "årsred")); + CHECK(tui_text_match("abc", "")); + CHECK(!tui_text_match("abc", "abd")); + CHECK(!tui_text_match(NULL, "a")); + + /* a letter first makes the prompt a search from the top */ + CHECK(tui_nav_key(&v, 'g', 0, 0, 0, 0) == TUI_NAV_NONE && v.goto_active); + CHECK(tui_nav_key(&v, 'h', 0, 0, 0, 0) == TUI_NAV_NONE); + CHECK(v.goto_search && v.sel == 1); + tui_nav_key(&v, 'y', 0, 0, 0, 0); + tui_nav_key(&v, 'r', 0, 0, 0, 0); + CHECK(v.sel == 1 && !v.goto_miss); + /* down/up step through the matches and wrap */ + tui_nav_key(&v, KEY_DOWN, 0, 0, 0, 0); + CHECK(v.sel == 4); + tui_nav_key(&v, KEY_DOWN, 0, 0, 0, 0); + CHECK(v.sel == 1); + tui_nav_key(&v, KEY_UP, 0, 0, 0, 0); + CHECK(v.sel == 4); + /* digits, 'g' and 'q' are text once searching */ + tui_nav_key(&v, KEY_BACKSPACE, 0, 0, 0, 0); + tui_nav_key(&v, KEY_BACKSPACE, 0, 0, 0, 0); + tui_nav_key(&v, KEY_BACKSPACE, 0, 0, 0, 0); + CHECK(!v.goto_search && v.goto_active && v.gotobuf[0] == '\0'); + tui_nav_key(&v, 'a', 0, 0, 0, 0); + tui_nav_key(&v, '1', 0, 0, 0, 0); + tui_nav_key(&v, '0', 0, 0, 0, 0); + CHECK(v.goto_search && v.sel == 2 && strcmp(v.gotobuf, "a10") == 0); + /* no match keeps the selection and flags the miss */ + tui_nav_key(&v, 'q', 0, 0, 0, 0); + CHECK(v.goto_active && v.goto_miss && v.sel == 2); + tui_nav_key(&v, KEY_BACKSPACE, 0, 0, 0, 0); + CHECK(!v.goto_miss); + /* multi-byte input searches once the character is complete */ + tui_nav_key(&v, 21, 0, 0, 0, 0); /* ^U is ignored in the prompt */ + while (v.gotobuf[0]) + tui_nav_key(&v, KEY_BACKSPACE, 0, 0, 0, 0); + tui_nav_key(&v, 0xC3, 0, 0, 0, 0); /* "ö" */ + tui_nav_key(&v, 0xB6, 0, 0, 0, 0); + CHECK(v.goto_search && v.sel == 2 && strcmp(v.gotobuf, "ö") == 0); + tui_nav_key(&v, KEY_BACKSPACE, 0, 0, 0, 0); + CHECK(v.gotobuf[0] == '\0' && !v.goto_search); + /* Enter closes without opening; the selection stays */ + tui_nav_key(&v, 'f', 0, 0, 0, 0); + tui_nav_key(&v, '1', 0, 0, 0, 0); + CHECK(v.sel == 3); + CHECK(tui_nav_key(&v, '\n', 0, 0, 0, 0) == TUI_NAV_NONE); + CHECK(!v.goto_active && !v.goto_search && v.gotobuf[0] == '\0' && + v.sel == 3); + + /* digits first still jump to a row number */ + tui_nav_key(&v, 'g', 0, 0, 0, 0); + tui_nav_key(&v, '2', 0, 0, 0, 0); + CHECK(!v.goto_search && v.sel == 1); + tui_nav_key(&v, 'g', 0, 0, 0, 0); /* 'g' closes a number prompt */ + CHECK(!v.goto_active); + + /* without row texts a letter does not search */ + v.items = NULL; + tui_nav_key(&v, 'g', 0, 0, 0, 0); + tui_nav_key(&v, 'h', 0, 0, 0, 0); + CHECK(v.goto_active && !v.goto_search && v.sel == 1); + tui_nav_key(&v, 27, 0, 0, 0, 0); + CHECK(!v.goto_active); + + /* menu section headers are never a match */ + const char *menu[] = { "\001Hyra", "Hyra kontor", "Övrigt" }; + const unsigned char sel[] = { 0, 1, 1 }; + nav_init(&v, 3); + v.items = menu; + v.selectable = sel; + v.sel = 2; + tui_nav_key(&v, 'g', 0, 0, 0, 0); + tui_nav_key(&v, 'h', 0, 0, 0, 0); + CHECK(v.sel == 1); +} + static void test_nav_sections(void) { struct tui_list_nav v; @@ -1001,6 +1093,7 @@ int main(void) test_sticky(); test_nav(); test_nav_sections(); + test_goto_search(); test_form_nav(); test_form_actions(); test_form_focus(); |
