From a61cfeefb0599e74fc9189b189b16dd0b6d36f0f Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Sun, 20 Sep 2026 00:05:51 +0200 Subject: tui: remember the focused row in forms and the bokslut hub --- clients/bokftui.c | 15 +++++++---- clients/tui.c | 71 ++++++++++++++++++++++++++++++++++++-------------- clients/tui.h | 17 +++++++++--- docs/STATE.md | 5 ++++ docs/TUI-GUIDELINES.md | 13 +++++++-- tests/test_tui.c | 62 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 29 deletions(-) diff --git a/clients/bokftui.c b/clients/bokftui.c index 3258f22..f7500bf 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -320,6 +320,7 @@ static void update_status(struct app *a) /* Create a new fiscal year. Returns the new id (>0) or 0. */ static int64_t fy_new_form(struct app *a) { + static int sel = 0; char label[32] = "", start[16] = "", end[16] = ""; char *resp = client_rpc(&a->conn, "fiscal_year.list", a->session, a->org, "{}"); @@ -372,7 +373,7 @@ static int64_t fy_new_form(struct app *a) ff[2].value = end; ff[2].cap = sizeof end; ff[2].kind = TUI_F_DATE; - int r = tui_form_run("Nytt räkenskapsår", ff, 3, 1); + int r = tui_form_run("Nytt räkenskapsår", ff, 3, 1, &sel); if (r == TUI_FORM_BACK) return 0; if (r != TUI_FORM_REFRESH && r != TUI_FORM_SUBMIT) @@ -4130,6 +4131,7 @@ static void board_screen(struct app *a) static void company_screen(struct app *a) { + static int sel = 0; const int nf = (int)(sizeof COMPANY_FIELDS / sizeof COMPANY_FIELDS[0]); int owner = a->role[0] && strcmp(a->role, "owner") == 0; for (;;) { @@ -4164,7 +4166,7 @@ static void company_screen(struct app *a) free(resp); ff[nf].label = "Styrelseledamöter"; ff[nf].kind = TUI_F_ACTION; - int r = tui_form_run("Företagsuppgifter", ff, nf + 1, owner); + int r = tui_form_run("Företagsuppgifter", ff, nf + 1, owner, &sel); if (r >= 0 && r < nf) { if (!company_field_valid(&COMPANY_FIELDS[r], vals[r])) { tui_message("Ogiltigt värde", "%s", COMPANY_FIELDS[r].label); @@ -4204,6 +4206,7 @@ static const char *const SETTING_LABELS[] = { "Standardserie", static void settings_screen(struct app *a) { + static int sel = 0; const int nset = (int)(sizeof SETTING_KEYS / sizeof SETTING_KEYS[0]); for (;;) { if (g_quit) @@ -4231,7 +4234,7 @@ static void settings_screen(struct app *a) ff[i].kind = TUI_F_TEXT; } free(resp); - int r = tui_form_run("Inställningar", ff, nset, 1); + int r = tui_form_run("Inställningar", ff, nset, 1, &sel); if (r >= 0) { yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); @@ -4472,6 +4475,7 @@ static void bokslut_plan(struct app *a, const char *fond, const char *rate, view. New years inherit the stable fields. */ static void bokslut_screen(struct app *a) { + static int sel = 0; static const char *const labels[6] = { "Väsentliga händelser", "Årsstämma (YYYY-MM-DD)", @@ -4574,7 +4578,7 @@ static void bokslut_screen(struct app *a) " bokslutsplan ^Enter = bokför planen (frågar" " först) Esc/q = tillbaka ^C = avsluta"); int r = tui_form_run_actions("Bokslut", ff, nf + 2, can_edit, acts, 5, - NULL); + NULL, &sel); if (r >= 0 && r < nf) { yyjson_mut_doc *d = yyjson_mut_doc_new(NULL); yyjson_mut_val *o = yyjson_mut_obj(d); @@ -5606,6 +5610,7 @@ static int try_login(struct app *a, const char *user, const char *pass, static int login_screen(struct app *a) { + static int sel = 0; char socket_path[256], user[64], pass[128]; snprintf(socket_path, sizeof socket_path, "%s", a->socket); const char *env_pass = getenv("BOKFD_PASSWORD"); @@ -5631,7 +5636,7 @@ static int login_screen(struct app *a) ff[3].kind = TUI_F_ACTION; for (;;) { - int r = tui_form_run("bokf — inloggning", ff, 4, 1); + int r = tui_form_run("bokf — inloggning", ff, 4, 1, &sel); if (r == TUI_FORM_BACK) { if (g_quit) { endwin(); diff --git a/clients/tui.c b/clients/tui.c index a5b0cd2..c52538c 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -1346,6 +1346,36 @@ static int act_selectable(const struct tui_form_action *acts, int i, int na) return acts && i >= 0 && i < na && acts[i].enabled != -1; } +int tui_form_focus_clamp(int focus, int nf, + const struct tui_form_action *acts, int na) +{ + if (nf < 0) + nf = 0; + if (na < 0) + na = 0; + int n = nf + na; + if (n <= 0) + return -1; + int pos = focus; + if (pos < 0) + pos = 0; + if (pos >= n) + pos = n - 1; + for (int i = 0; i < n; i++) { + if (pos < nf || act_selectable(acts, pos - nf, na)) + return pos; + pos = (pos + 1) % n; + } + return -1; +} + +int tui_form_focus_store(int *focus, int sel, int ret) +{ + if (focus) + *focus = sel; + return ret; +} + int tui_form_act_step(int focus, int nf, const struct tui_form_action *acts, int na, int dir) { @@ -1440,13 +1470,14 @@ static void form_draw_actions(const struct tui_form_action *acts, int na, static int form_run(const char *title, struct tui_form_field *f, int nf, int can_edit, const struct tui_form_action *acts, int na, - const char *hint, int (*key)(void *ud, int ch), void *ud) + const char *hint, int (*key)(void *ud, int ch), void *ud, + int *focus) { - int sel = nf > 0 ? 0 : tui_form_act_first(0, acts, na); + int sel = tui_form_focus_clamp(focus ? *focus : -1, nf, acts, na); for (;;) { int ntot = nf + (na > 0 ? na : 0); if (ntot <= 0) - return TUI_FORM_BACK; + return tui_form_focus_store(focus, sel, TUI_FORM_BACK); if (sel < 0) sel = tui_form_act_first(nf, acts, na); if (sel >= ntot) @@ -1523,15 +1554,15 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, } if (nxt == TUI_FORM_REFRESH || nxt == TUI_FORM_SUBMIT || nxt == TUI_FORM_BACK) - return nxt; + return tui_form_focus_store(focus, sel, nxt); if (key) { int hk = key(ud, ch); if (hk == TUI_HOOK_BACK) - return TUI_FORM_BACK; + return tui_form_focus_store(focus, sel, TUI_FORM_BACK); if (hk == TUI_HOOK_REFRESH) - return TUI_FORM_REFRESH; + return tui_form_focus_store(focus, sel, TUI_FORM_REFRESH); if (hk == TUI_HOOK_SUBMIT) - return TUI_FORM_SUBMIT; + return tui_form_focus_store(focus, sel, TUI_FORM_SUBMIT); if (hk != TUI_HOOK_NONE) continue; } @@ -1550,7 +1581,8 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, } if (act->enabled == -1) continue; - return TUI_FORM_ACTION + (sel - nf); + return tui_form_focus_store(focus, sel, + TUI_FORM_ACTION + (sel - nf)); } if (!can_edit) { tui_message(title, "Endast behöriga kan ändra."); @@ -1558,49 +1590,50 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, } struct tui_form_field *fl = &f[sel]; if (fl->kind == TUI_F_ACTION) - return sel; + return tui_form_focus_store(focus, sel, sel); char label[192]; snprintf(label, sizeof label, "%s: ", fl->label); if (fl->kind == TUI_F_TEXT) { if (fl->value && tui_prompt_into(fl->value, fl->cap, label, fl->value, fl->mask)) - return sel; + return tui_form_focus_store(focus, sel, sel); } else if (fl->kind == TUI_F_DATE) { if (fl->value && tui_date_prompt_into(fl->value, fl->cap, label, fl->value)) - return sel; + return tui_form_focus_store(focus, sel, sel); } else if (fl->kind == TUI_F_AMOUNT) { if (fl->ore && tui_amount_prompt_into(fl->ore, label, *fl->ore)) - return sel; + return tui_form_focus_store(focus, sel, sel); } else if (fl->kind == TUI_F_CHOICE && fl->choice) { int c = tui_choice_prompt(label, fl->choices, fl->nchoices, *fl->choice); if (c >= 0) { *fl->choice = c; - return sel; + return tui_form_focus_store(focus, sel, sel); } } } } int tui_form_run(const char *title, struct tui_form_field *f, int n, - int can_edit) + int can_edit, int *focus) { - return form_run(title, f, n, can_edit, NULL, 0, NULL, NULL, NULL); + return form_run(title, f, n, can_edit, NULL, 0, NULL, NULL, NULL, focus); } int tui_form_run_hook(const char *title, struct tui_form_field *f, int n, - int can_edit, int (*key)(void *ud, int ch), void *ud) + int can_edit, int (*key)(void *ud, int ch), void *ud, + int *focus) { - return form_run(title, f, n, can_edit, NULL, 0, NULL, key, ud); + return form_run(title, f, n, can_edit, NULL, 0, NULL, key, ud, focus); } int tui_form_run_actions(const char *title, struct tui_form_field *f, int nf, int can_edit, const struct tui_form_action *acts, - int na, const char *hint) + int na, const char *hint, int *focus) { - return form_run(title, f, nf, can_edit, acts, na, hint, NULL, NULL); + return form_run(title, f, nf, can_edit, acts, na, hint, NULL, NULL, focus); } /* ------------------------------------------------------------------ */ diff --git a/clients/tui.h b/clients/tui.h index d0656da..7250c0b 100644 --- a/clients/tui.h +++ b/clients/tui.h @@ -179,11 +179,13 @@ struct tui_form_field { }; int tui_form_next(int sel, int n, int ch); + int tui_form_run(const char *title, struct tui_form_field *f, int n, - int can_edit); + int can_edit, int *focus); void tui_form_hint(const char *hint); /* overrides the footer for the next run */ int tui_form_run_hook(const char *title, struct tui_form_field *f, int n, - int can_edit, int (*key)(void *ud, int ch), void *ud); + int can_edit, int (*key)(void *ud, int ch), void *ud, + int *focus); /* --- action list in a form (tui_form_run_actions) --- */ /* enabled: 1 = selectable and runs; 0 = dimmed but selectable (Enter shows @@ -197,6 +199,15 @@ struct tui_form_action { #define TUI_FORM_ACTION 1000 /* action i is returned as TUI_FORM_ACTION + i */ +/* Focus memory: the screen owns an int and passes its address to + tui_form_run*; on entry *focus is clamped to a selectable row — out of + range snaps to the nearest end and a heading row snaps forward, wrapping + — and on every return the row the form stopped on is written back. + Passing NULL starts at the first focusable row and stores nothing. */ +int tui_form_focus_clamp(int focus, int nf, + const struct tui_form_action *acts, int na); +int tui_form_focus_store(int *focus, int sel, int ret); + /* Focus ring over fields (0..nf-1) and actions (nf..nf+na-1); heading rows are skipped and the ring wraps. dir +1 = Tab/Down, -1 = Shift-Tab/Up. */ int tui_form_act_step(int focus, int nf, const struct tui_form_action *acts, @@ -218,7 +229,7 @@ void tui_form_act_label(const struct tui_form_action *a, char *buf, size_t n); footer; tui_form_hint() still takes precedence. */ int tui_form_run_actions(const char *title, struct tui_form_field *f, int nf, int can_edit, const struct tui_form_action *acts, - int na, const char *hint); + int na, const char *hint, int *focus); /* --- row table --- */ enum { TUI_RT_EDIT = 0, TUI_RT_INFO, TUI_RT_DATE }; diff --git a/docs/STATE.md b/docs/STATE.md index a108a52..d75f3cb 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -102,6 +102,11 @@ server/protocol/ledger only. closed or already posted year). Årsredovisning and INK2/SRU left the Rapporter menu, and Ingående balans moved from Bokföring to the dashboard's Räkenskapsår section next to Räkenskapsår. + The form widgets remember focus across runs (`int *focus` as the last + argument to `tui_form_run*`: clamped to a selectable row on entry and + written back on every return; screens own a `static int sel`), so a + sub-screen round-trip or a field save returns the highlight to the + same row. ## Pending decisions diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md index 3bed983..3bd3c65 100644 --- a/docs/TUI-GUIDELINES.md +++ b/docs/TUI-GUIDELINES.md @@ -65,6 +65,14 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. - Field navigation: `Tab` / `Shift-Tab` forward/back, arrows Up/Down between rows. `Enter` advances in simple forms; it never saves unless the screen is a one-line prompt. +- Focus memory: the screen keeps the focused row in an `int` and passes its + address as the last argument to `tui_form_run`, `tui_form_run_hook` and + `tui_form_run_actions` (like a list cursor). On entry `*focus` is clamped + to a selectable row — out of range snaps to the nearest end, a heading row + snaps forward and wraps — and on every return (`Esc`, `F5`, `^Enter`, a + chosen action, an edited field) the row is written back, so a re-run after + a save or a round-trip through another screen lands on the same row. + `NULL` starts at the first focusable row and stores nothing. - The active field is drawn reverse-video and holds the hardware cursor at the caret. The caret is a real position: ←/→/Home/End/Del work inside the field, `Ctrl+U` clears it (see `field_edit`). @@ -104,8 +112,9 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. `label (reason)`, `Enter` shows the reason in a message; `-1` is a dim non-selectable heading/status row like a menu section. The call returns the field index after an edit, `TUI_FORM_ACTION + i` for a chosen action, - or `TUI_FORM_BACK`/`REFRESH`/`SUBMIT`; `tui_form_hint()` overrides the - footer of the next run as usual. + or `TUI_FORM_BACK`/`REFRESH`/`SUBMIT`, and stores the row it stopped on in + the `int *focus` argument (see focus memory above); `tui_form_hint()` + overrides the footer of the next run as usual. - `Bokslut` is the year-end hub in one view: the year fields, the status heading `Bokslut bokfört: ja/nej` (derived from the `Skatt på årets resultat` voucher that `bokslut.post` creates; a closed year counts as diff --git a/tests/test_tui.c b/tests/test_tui.c index 15a44c7..a024e27 100644 --- a/tests/test_tui.c +++ b/tests/test_tui.c @@ -433,6 +433,67 @@ static void test_form_actions(void) CHECK(tui_form_act_step(1, 0, ACTS, 6, -1) == 5); } +static void test_form_focus(void) +{ + const int nf = 3; + + /* start focus: a remembered row is kept, out of range clamps to the + nearest end, fields are always selectable */ + CHECK(tui_form_focus_clamp(0, nf, ACTS, 6) == 0); + CHECK(tui_form_focus_clamp(2, nf, ACTS, 6) == 2); + CHECK(tui_form_focus_clamp(4, nf, ACTS, 6) == 4); + CHECK(tui_form_focus_clamp(6, nf, ACTS, 6) == 6); /* dimmed, selectable */ + CHECK(tui_form_focus_clamp(8, nf, ACTS, 6) == 8); + CHECK(tui_form_focus_clamp(-1, nf, ACTS, 6) == 0); + CHECK(tui_form_focus_clamp(99, nf, ACTS, 6) == 8); + + /* a heading row snaps forward (and wraps) to a selectable one */ + CHECK(tui_form_focus_clamp(3, nf, ACTS, 6) == 4); /* action 0 is head */ + CHECK(tui_form_focus_clamp(7, nf, ACTS, 6) == 8); /* action 4 is head */ + CHECK(tui_form_focus_clamp(0, 0, ACTS, 6) == 1); /* fields gone */ + static const struct tui_form_action WRAP[2] = { + { "Årsredovisning (K2)", 1, NULL }, + { "rubrik", -1, NULL }, + }; + CHECK(tui_form_focus_clamp(1, 0, WRAP, 2) == 0); /* wraps to the top */ + static const struct tui_form_action HEADS[2] = { + { "rubrik", -1, NULL }, + { "rubrik", -1, NULL }, + }; + CHECK(tui_form_focus_clamp(0, 0, HEADS, 2) == -1); + CHECK(tui_form_focus_clamp(-1, 0, HEADS, 2) == -1); + + /* every return writes the current row back; NULL stores nothing */ + int focus = 0; + CHECK(tui_form_focus_store(&focus, 4, TUI_FORM_BACK) == TUI_FORM_BACK); + CHECK(focus == 4); + CHECK(tui_form_focus_store(&focus, 5, TUI_FORM_REFRESH) == + TUI_FORM_REFRESH); + CHECK(focus == 5); + CHECK(tui_form_focus_store(&focus, 6, TUI_FORM_SUBMIT) == TUI_FORM_SUBMIT); + CHECK(focus == 6); + CHECK(tui_form_focus_store(&focus, 8, TUI_FORM_ACTION + 1) == + TUI_FORM_ACTION + 1); + CHECK(focus == 8); + CHECK(tui_form_focus_store(&focus, 1, 1) == 1 && focus == 1); + CHECK(tui_form_focus_store(&focus, -1, TUI_FORM_BACK) == TUI_FORM_BACK); + CHECK(focus == -1); + CHECK(tui_form_focus_store(NULL, 4, TUI_FORM_BACK) == TUI_FORM_BACK); + + /* two runs in a row, like the hub: walk to an action and choose it, the + next run starts on the same row */ + focus = tui_form_focus_clamp(0, nf, ACTS, 6); + CHECK(focus == 0); + focus = tui_form_act_key(focus, nf, ACTS, 6, '\t'); + focus = tui_form_act_key(focus, nf, ACTS, 6, '\t'); + focus = tui_form_act_key(focus, nf, ACTS, 6, '\t'); + CHECK(focus == 4); /* Årsredovisning (K2) */ + CHECK(tui_form_focus_store(&focus, focus, TUI_FORM_ACTION + 1) == + TUI_FORM_ACTION + 1); + CHECK(tui_form_focus_clamp(focus, nf, ACTS, 6) == 4); + CHECK(tui_form_focus_store(&focus, focus, TUI_FORM_BACK) == TUI_FORM_BACK); + CHECK(tui_form_focus_clamp(focus, nf, ACTS, 6) == 4); +} static char rt_cells[8][3][64]; static void rt_cb(void *ud, int row, int col, char **buf, size_t *cap) @@ -617,6 +678,7 @@ int main(void) test_nav_sections(); test_form_nav(); test_form_actions(); + test_form_focus(); test_rowtable(); test_rt_fields(); test_rt_focus(); -- cgit v1.3