summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--clients/tui.c192
-rw-r--r--clients/tui.h11
-rw-r--r--docs/STATE.md6
-rw-r--r--docs/TUI-GUIDELINES.md5
-rwxr-xr-xscripts/tui-golden.py29
-rw-r--r--tests/test_tui.c93
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();