From 71a702f375750829c634b552217c9925d549828b Mon Sep 17 00:00:00 2001 From: Anders Betts Date: Wed, 23 Sep 2026 10:58:17 +0200 Subject: tui: context menu on → / ^O; no F-keys, ^N or ^Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit → (where no caret uses it) and ^O (everywhere, also in table cells) open "Åtgärder": a box at the right edge with every action of the view and its key. Screens declare their actions with tui_set_actions(); choosing one feeds its key to the widget, so the menu and accelerators share one path. Function keys, Ctrl+N (now n), Ctrl+Enter and Ctrl+F are gone and no extended keyboard protocol is enabled, so the TUI works in a browser terminal; save/post/validate/preview/template/attach are menu actions. make check rejects F-keys, ^N and ^Enter in clients/. Co-Authored-By: Claude Opus 5.5 --- clients/bokftui.c | 12 +- clients/screens_attachments.c | 5 + clients/screens_bank.c | 9 +- clients/screens_bokslut.c | 13 +- clients/screens_dashboard.c | 9 + clients/screens_ib.c | 19 +- clients/screens_invoices.c | 125 +++++----- clients/screens_payroll.c | 27 ++- clients/screens_rules.c | 10 +- clients/screens_settings.c | 6 +- clients/screens_templates.c | 15 +- clients/screens_vouchers.c | 49 +++- clients/tui.c | 524 +++++++++++++++++++++++++++++++----------- clients/tui.h | 39 +++- clients/ui.h | 4 - docs/DECISIONS.md | 14 ++ docs/PROTOCOL.md | 63 ++--- docs/STATE.md | 30 ++- docs/TUI-GUIDELINES.md | 164 ++++++++----- scripts/check-consistency.sh | 25 +- scripts/tui-golden.py | 134 ++++++----- tests/test_tui.c | 99 ++++++-- 22 files changed, 969 insertions(+), 426 deletions(-) diff --git a/clients/bokftui.c b/clients/bokftui.c index 19a63cf..46b94cd 100644 --- a/clients/bokftui.c +++ b/clients/bokftui.c @@ -244,6 +244,10 @@ static int login_screen(struct app *a) ff[3].kind = TUI_F_ACTION; for (;;) { + static const struct tui_action acts[] = { + { "login", "Logga in", TUI_KEY_SUBMIT, 1, NULL }, + }; + tui_set_actions(acts, 1); int r = tui_form_run("bokf — inloggning", ff, 4, 1, &sel); if (r == TUI_FORM_BACK) { if (g_quit) { @@ -354,13 +358,6 @@ static void usage(FILE *f) " --version\n"); } -static void reset_modify_keys(void) -{ - putp("\033[4m"); - fflush(stdout); -} - int main(int argc, char **argv) { struct app app; @@ -419,7 +416,6 @@ int main(int argc, char **argv) tui_set_input(ui_getch); tui_set_screen(tui_frame, tui_hints); tui_set_quit(request_quit); - atexit(reset_modify_keys); curs_set(1); const char *env_session = getenv("BOKFD_SESSION"); diff --git a/clients/screens_attachments.c b/clients/screens_attachments.c index c72ee67..b2e9ad8 100644 --- a/clients/screens_attachments.c +++ b/clients/screens_attachments.c @@ -139,6 +139,11 @@ void inbox_screen(struct app *a) free(fn); } struct inboxctx ctx = { a, ids, n, cursor }; + static const struct tui_action iacts[] = { + { "inbox.upload", "Ladda upp fil…", 'a', 1, NULL }, + { "inbox.link", "Koppla till verifikat…", 'k', 1, NULL }, + }; + tui_set_actions(iacts, 2); int sel = tui_select_list_hook("Underlag (inkorg)", items, (int)n, cursor, 1, &cursor, 1, NULL, 0, inbox_key, &ctx); diff --git a/clients/screens_bank.c b/clients/screens_bank.c index 5131055..c5b101e 100644 --- a/clients/screens_bank.c +++ b/clients/screens_bank.c @@ -163,7 +163,7 @@ static void bank_match_tx(struct app *a, const struct bank_tx *t) items[i] = xstrdup(line); } items[t->nsuggestions] = xstrdup("Välj annat verifikat…"); - items[t->nsuggestions + 1] = xstrdup("Skapa nytt verifikat… (^N)"); + items[t->nsuggestions + 1] = xstrdup("Skapa nytt verifikat… (n)"); int sel = tui_select_list("Matcha transaktion", items, (int)nact, 0, 0, NULL, 0, NULL, 0); for (size_t i = 0; i < nact; i++) @@ -395,6 +395,13 @@ void bank_screen(struct app *a) } } struct bankctx ctx = { a, txs, n, start }; + static const struct tui_action bacts[] = { + { "bank.new_voucher", "Nytt verifikat från transaktionen", 'n', 1, + NULL }, + { "bank.unmatch", "Ta bort matchning", 'u', 1, NULL }, + { "bank.import", "Importera bankfil…", 'a', 1, NULL }, + }; + tui_set_actions(bacts, 3); int sel = tui_select_list_hook(title, lines, (int)rows, start, 1, &ctx.cursor, 1, NULL, 0, bank_key, &ctx); diff --git a/clients/screens_bokslut.c b/clients/screens_bokslut.c index a4776c9..2328c01 100644 --- a/clients/screens_bokslut.c +++ b/clients/screens_bokslut.c @@ -241,9 +241,16 @@ void bokslut_screen(struct app *a) ff[7].value = rate; ff[7].cap = sizeof rate; ff[7].kind = TUI_F_TEXT; - tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför F5 = visa" - " bokslutsplan F9 = bokför planen (frågar" - " först) Esc/q = tillbaka ^C = avsluta"); + static const struct tui_action bacts[] = { + { "bokslut.plan", "Visa bokslutsplan (torrkörning)", + TUI_KEY_REFRESH, 1, NULL }, + { "bokslut.post", "Bokför planen… (frågar först)", + TUI_KEY_SUBMIT, 1, NULL }, + }; + tui_set_actions(bacts, can_edit ? 2 : 1); + tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför" + " → = åtgärder (bokslutsplan, bokför)" + " Esc/q = tillbaka"); int r = tui_form_run_actions("Bokslut", ff, nf + 2, can_edit, acts, 5, NULL, &sel); if (r >= 0 && r < nf) { diff --git a/clients/screens_dashboard.c b/clients/screens_dashboard.c index 45f0aed..d34e960 100644 --- a/clients/screens_dashboard.c +++ b/clients/screens_dashboard.c @@ -90,6 +90,11 @@ static int64_t fy_new_form(struct app *a) ff[2].value = end; ff[2].cap = sizeof end; ff[2].kind = TUI_F_DATE; + static const struct tui_action acts[] = { + { "fy.create", "Skapa räkenskapsåret", TUI_KEY_SUBMIT, 1, NULL }, + { "fy.validate", "Validera", TUI_KEY_REFRESH, 1, NULL }, + }; + tui_set_actions(acts, 2); int r = tui_form_run("Nytt räkenskapsår", ff, 3, 1, &sel); if (r == TUI_FORM_BACK) return 0; @@ -364,6 +369,10 @@ void dashboard(struct app *a) for (;;) { if (g_reload) return; + static const struct tui_action acts[] = { + { "voucher.new", "Nytt verifikat", 'n', 1, NULL }, + }; + tui_set_actions(acts, 1); int sel = tui_menu("Bokf", MAIN_ITEMS, (int)(sizeof MAIN_ITEMS / sizeof MAIN_ITEMS[0]), 1, &last_sel); diff --git a/clients/screens_ib.c b/clients/screens_ib.c index 2d62297..34cac20 100644 --- a/clients/screens_ib.c +++ b/clients/screens_ib.c @@ -270,9 +270,14 @@ static int ib_form(struct app *a, char **old_acc, int64_t *old_amt, int nold) tui_rt_set_footer(&ib.rt, ib_footer); for (;;) { + static const struct tui_action acts[] = { + { "ib.save", "Spara", TUI_KEY_SUBMIT, 1, NULL }, + { "ib.validate", "Validera", TUI_KEY_REFRESH, 1, NULL }, + }; + tui_set_actions(acts, 2); int rr = tui_rt_run("Ingående balans", &ib.rt, - "Tab = byta fält F5 = validera ^X = rensa rad" - " F9 = spara Esc = avbryt"); + "Tab = byta fält ^O = åtgärder (spara," + " validera) ^X = rensa rad Esc = avbryt"); if (rr == -1) return 0; char msg[256]; @@ -301,7 +306,7 @@ struct ibpager { static int ib_view_key(void *ud, int ch) { struct ibpager *p = ud; - if (ch == 'e' || ch == 'E' || ch == TUI_KEY_CTRL_N) { + if (ch == 'e' || ch == 'E') { if (ib_form(p->a, p->accs, p->amts, p->n)) return TUI_HOOK_REFRESH; return TUI_HOOK_STAY; @@ -310,7 +315,7 @@ static int ib_view_key(void *ud, int ch) } /* The list is a read-only table with no selection: tui_pager shows the - columns and the sum as plain text, and the e/^N keys run the editor. */ + columns and the sum as plain text, and the e key runs the editor. */ void ib_screen(struct app *a) { for (;;) { @@ -341,8 +346,12 @@ void ib_screen(struct app *a) buf_line(&t, "\nSumma: %s\n", sumbuf); buf_append(&t, "\0", 1); struct ibpager p = { a, accs, amts, n }; + static const struct tui_action acts[] = { + { "ib.edit", "Redigera", 'e', 1, NULL }, + }; + tui_set_actions(acts, 1); int ret = tui_pager_hook("Ingående balans", (const char *)t.p, - "e/^N = redigera", 0, ib_view_key, &p); + "e = redigera", 0, ib_view_key, &p); buf_free(&t); for (int i = 0; i < n; i++) free(accs[i]); diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c index 712a8a8..46d2406 100644 --- a/clients/screens_invoices.c +++ b/clients/screens_invoices.c @@ -240,6 +240,7 @@ struct cform_ctx { int64_t id; const char *draft_id; int deleted; + struct tui_action acts[3]; char name[256]; char address[512]; char postal[32]; @@ -302,28 +303,23 @@ static void customer_load_json(const char *json, struct cform_ctx *c) yyjson_doc_free(doc); } +#define CFORM_DRAFT_DELETE TUI_KEY_ACTION(0) + static int cform_key(void *ud, int ch) { struct cform_ctx *c = ud; - if (ch != KEY_F(2)) + if (ch == TUI_KEY_MENU) { + c->acts[2].enabled = + drafts_have(kdrafts(), c->a->org, "customer", c->draft_id); return TUI_HOOK_NONE; - int have = drafts_have(kdrafts(), c->a->org, "customer", c->draft_id); - struct tui_action acts[] = { - { "customer.save", "Spara", 0, 1, NULL }, - { "customer.draft.delete", "Radera utkast", 0, have, "inget utkast" }, - }; - static int afocus; - int r = tui_action_menu("Åtgärder", acts, 2, &afocus); - if (r == 0) - return TUI_HOOK_SUBMIT; - if (r == 1) { - if (tui_confirm("Kund", "Radera utkastet?")) { - drafts_del(kdrafts(), c->a->org, "customer", c->draft_id); - c->deleted = 1; - return TUI_HOOK_BACK; - } - return TUI_HOOK_STAY; + } + if (ch != CFORM_DRAFT_DELETE) + return TUI_HOOK_NONE; + if (tui_confirm("Kund", "Radera utkastet?")) { + drafts_del(kdrafts(), c->a->org, "customer", c->draft_id); + c->deleted = 1; + return TUI_HOOK_BACK; } return TUI_HOOK_STAY; } @@ -404,13 +400,19 @@ static void customer_form(struct app *a, int64_t id, const char *draft_id) ff[9].kind = TUI_F_ACTION; static int sel = 0; - tui_form_hint_extra("F2 = åtgärder"); + c.acts[0] = (struct tui_action){ "customer.save", "Spara", + TUI_KEY_SUBMIT, 1, NULL }; + c.acts[1] = (struct tui_action){ "customer.validate", "Validera", + TUI_KEY_REFRESH, 1, NULL }; + c.acts[2] = (struct tui_action){ "customer.draft.delete", "Radera utkast", + CFORM_DRAFT_DELETE, 0, "inget utkast" }; for (;;) { char title[64]; snprintf(title, sizeof title, "%s%s", id > 0 ? "Kund" : "Ny kund", drafts_have(kdrafts(), a->org, "customer", draft_id) ? " " : ""); + tui_set_actions(c.acts, 3); int r = tui_form_run_hook(title, ff, 10, 1, cform_key, &c, &sel); if (c.deleted) break; @@ -474,6 +476,7 @@ struct clist { unsigned char *active; size_t nserver; size_t nrows; /* server rows + temp drafts; the add row is at nrows */ + struct tui_action act; /* 'd' for the current row (see clist_key) */ }; static void clist_clear(struct clist *l) @@ -547,45 +550,27 @@ static void clist_collect_temp(void *ud, const char *id, const char *fields) l->nrows++; } +/* The row action behind 'd' depends on the row: a draft is deleted, a + saved customer archived or reactivated. The menu shows the right one. */ static int clist_key(void *ud, int ch) { struct clist *l = ud; - static int afocus; - if (ch != KEY_F(2) || !l->cur) + if (ch != TUI_KEY_MENU || !l->cur) return TUI_HOOK_NONE; int i = *l->cur; - if (i < 0 || (size_t)i >= l->nrows) - return TUI_HOOK_STAY; - struct tui_action acts[] = { - { "customer.open", "Öppna", 0, 1, NULL }, - { "customer.draft.delete", "Radera utkast", 0, l->dkeys[i] != NULL, - "inget utkast" }, - { "customer.archive", l->active[i] ? "Arkivera" : "Återaktivera", 0, - l->ids[i] != 0, "gäller inte utkast" }, - }; - int r = tui_action_menu("Åtgärder", acts, 3, &afocus); - if (r == 0) { - char idbuf[32]; - const char *dk = l->dkeys[i]; - if (!dk) { - snprintf(idbuf, sizeof idbuf, "%lld", (long long)l->ids[i]); - dk = idbuf; - } - customer_form(l->a, l->ids[i], dk); - return TUI_HOOK_REFRESH; + struct tui_action *a = &l->act; + *a = (struct tui_action){ "customer.remove", "Arkivera", 'd', 1, NULL }; + if (i < 0 || (size_t)i >= l->nrows) { + a->enabled = 0; + a->reason = "välj en kund"; + } else if (l->dkeys[i] && l->ids[i] == 0) { + a->label = "Radera utkast"; + a->id = "customer.draft.delete"; + } else if (!l->active[i]) { + a->label = "Återaktivera"; } - if (r == 1) { - if (tui_confirm("Kund", "Radera utkastet?")) - drafts_del(kdrafts(), l->a->org, "customer", l->dkeys[i]); - return TUI_HOOK_REFRESH; - } - if (r == 2) { - customer_archive_toggle(l->a, l->ids[i], l->names[i], - l->active[i] != 0); - return TUI_HOOK_REFRESH; - } - return TUI_HOOK_STAY; + return TUI_HOOK_NONE; } void customers_screen(struct app *a) @@ -665,7 +650,7 @@ void customers_screen(struct app *a) free(resp); drafts_foreach(kdrafts(), a->org, "customer", clist_collect_temp, &l); - l.items[l.nrows] = xstrdup("+ Ny kund (^N)"); + l.items[l.nrows] = xstrdup("+ Ny kund (n)"); l.names[l.nrows] = xstrdup(""); l.ids[l.nrows] = 0; l.active[l.nrows] = 1; @@ -679,12 +664,13 @@ void customers_screen(struct app *a) } } cur = start; - tui_list_hint_extra("F2 = åtgärder"); + l.act = (struct tui_action){ "customer.remove", "Arkivera", 'd', 1, + NULL }; + tui_set_actions(&l.act, 1); int sel = tui_select_list_hook("Kunder", l.items, (int)l.nrows + 1, start, 1, &cur, 1, "arkivera/återaktivera", 0, clist_key, &l); - tui_list_hint_extra(NULL); if (cur >= 0 && (size_t)cur < l.nserver) a->customer_sel = l.ids[cur]; if (sel == -1) { @@ -697,7 +683,8 @@ void customers_screen(struct app *a) } if (sel == -6 && cur >= 0 && (size_t)cur < l.nrows) { if (l.ids[cur] == 0) { - tui_message("Kund", "Utkast raderas med F2."); + if (l.dkeys[cur] && tui_confirm("Kund", "Radera utkastet?")) + drafts_del(kdrafts(), a->org, "customer", l.dkeys[cur]); } else { customer_archive_toggle(a, l.ids[cur], l.names[cur], l.active[cur] != 0); @@ -1020,6 +1007,15 @@ static void invoices_detail(struct app *a, int64_t id) snprintf(ahint, sizeof ahint, "p = visa PDF s = skicka u = duplicera%s", ctx.paid_date[0] ? "" : " b = kvittera betalning"); + struct tui_action acts[] = { + { "invoice.pdf", "Visa PDF", 'p', 1, NULL }, + { "invoice.send", "Skicka…", 's', 1, NULL }, + { "invoice.duplicate", "Duplicera till nytt utkast", 'u', 1, + NULL }, + { "invoice.pay", "Kvittera betalning…", 'b', + ctx.paid_date[0] ? 0 : 1, "redan betald" }, + }; + tui_set_actions(acts, 4); int ret = tui_pager_hook("Faktura", (const char *)t.p, ahint, 0, inv_key, &ctx); free(cust); @@ -1457,10 +1453,15 @@ static int64_t invoices_form_run(struct iform *f) tui_rt_set_fields(&f->rt, ff, 7); tui_rt_normalize(&f->rt); const char *hint = - "Enter = välj/ändra Tab = byta fält F5 = förhandsvisa" - " F9 = utfärda Esc = avbryt"; + "Enter = välj/ändra Tab = byta fält" + " ^O = åtgärder (utfärda, förhandsvisa) Esc = avbryt"; + static const struct tui_action acts[] = { + { "invoice.issue", "Utfärda", TUI_KEY_SUBMIT, 1, NULL }, + { "invoice.preview", "Förhandsvisa PDF", TUI_KEY_REFRESH, 1, NULL }, + }; int64_t out = 0; for (;;) { + tui_set_actions(acts, 2); int rr = tui_rt_run("Ny faktura", &f->rt, hint); if (rr == -1) break; @@ -1600,10 +1601,12 @@ static int64_t invoices_new_from(struct app *a, int64_t id) return out; } +#define INVOICES_NEXT_NUMBER TUI_KEY_ACTION(0) + static int invoices_key(void *ud, int ch) { struct app *a = ud; - if (ch != 'n') + if (ch != INVOICES_NEXT_NUMBER) return TUI_HOOK_NONE; char *resp = client_rpc(&a->conn, "invoice.sequence_get", a->session, a->org, "{}"); @@ -1696,7 +1699,7 @@ void invoices_screen(struct app *a) free(paid); } free(resp); - items[n] = xstrdup("+ Ny faktura (^N)"); + items[n] = xstrdup("+ Ny faktura (n)"); } int start = 0; if (a->invoice_sel) { @@ -1707,6 +1710,12 @@ void invoices_screen(struct app *a) } } int cur = start; + static const struct tui_action lacts[] = { + { "invoice.new", "Ny faktura", 'n', 1, NULL }, + { "invoice.next_number", "Ändra nästa fakturanummer…", + INVOICES_NEXT_NUMBER, 1, NULL }, + }; + tui_set_actions(lacts, 2); int sel = tui_select_list_hook("Fakturor", items, (int)n + 1, start, 1, &cur, 1, NULL, 0, invoices_key, a); if (cur >= 0 && (size_t)cur < n) diff --git a/clients/screens_payroll.c b/clients/screens_payroll.c index 8a27ec8..ecec962 100644 --- a/clients/screens_payroll.c +++ b/clients/screens_payroll.c @@ -291,6 +291,11 @@ static void employee_form(struct app *a, int64_t id) ff[i].kind = TUI_F_TEXT; i++; const char *title = f.is_new ? "Ny anställd" : "Anställd"; + static const struct tui_action eacts[] = { + { "employee.save", "Spara", TUI_KEY_SUBMIT, 1, NULL }, + { "employee.validate", "Validera", TUI_KEY_REFRESH, 1, NULL }, + }; + tui_set_actions(eacts, 2); int r = tui_form_run(title, ff, i, 1, &sel); if (r == TUI_FORM_BACK) return; @@ -404,7 +409,7 @@ void employees_screen(struct app *a) start = i; break; } - lines[n] = xstrdup("+ Ny anställd (^N)"); + lines[n] = xstrdup("+ Ny anställd (n)"); int cur = (int)start; int sel = tui_select_list("Anställda", lines, (int)n + 1, cur, 1, &cur, 1, "arkivera/återaktivera", 0); @@ -597,8 +602,8 @@ void payroll_tax_tables_screen(struct app *a) acts[2].disabled_reason = reason; int r = tui_form_run_actions( "Skattetabeller", ff, 4, 0, acts, 3, - "upp/ned/Tab = flytta Enter = utför F5 = uppdatera" - " Esc/q = tillbaka ^C = avsluta", + "upp/ned/Tab = flytta Enter = utför → = åtgärder" + " Esc/q = tillbaka", &sel); if (r == TUI_FORM_ACTION + 1) tax_tables_fetch(a, current_year); @@ -1100,9 +1105,8 @@ static int64_t payroll_run_screen(struct app *a, int64_t id) struct tui_form_action acts[7] = { { status, -1, NULL }, { totals, -1, NULL }, - { "Förhandsvisa (F5)", 1, NULL }, - { "Bokför körning (F9)", post_reason ? 0 : 1, - post_reason }, + { "Förhandsvisa", 1, NULL }, + { "Bokför körning", post_reason ? 0 : 1, post_reason }, { "Lönebesked (PDF)", payslip_reason ? 0 : 1, payslip_reason }, { "AGI-underlag", agi_reason ? 0 : 1, agi_reason }, { "Betala skatt & avgifter", pay_reason ? 0 : 1, pay_reason }, @@ -1123,9 +1127,14 @@ static int64_t payroll_run_screen(struct app *a, int64_t id) else snprintf(title, sizeof title, "Ny lönekörning"); int editable = can_write && !posted; + struct tui_action macts[] = { + { "payroll.preview", "Förhandsvisa", TUI_KEY_REFRESH, 1, NULL }, + { "payroll.post", "Bokför körning…", TUI_KEY_SUBMIT, + post_reason ? 0 : 1, post_reason }, + }; + tui_set_actions(macts, 2); tui_form_hint("upp/ned/Tab = flytta Enter = ändra/utför" - " F5 = förhandsvisa F9 = bokför" - " Esc/q = tillbaka ^C = avsluta"); + " → = åtgärder Esc/q = tillbaka"); int ret = tui_form_run_actions(title, ff, 2, editable, acts, 7, NULL, &focus); if (ret == TUI_FORM_BACK) @@ -1207,7 +1216,7 @@ void payroll_screen(struct app *a) start = i; break; } - lines[n] = xstrdup("+ Ny lönekörning (^N)"); + lines[n] = xstrdup("+ Ny lönekörning (n)"); char title[160]; if (stale) snprintf(title, sizeof title, diff --git a/clients/screens_rules.c b/clients/screens_rules.c index 19f898d..4109f80 100644 --- a/clients/screens_rules.c +++ b/clients/screens_rules.c @@ -39,7 +39,8 @@ struct rule { int64_t sort_order; }; -/* F5 dry-runs; Ctrl+Enter creates/updates. Returns 1 when saved. */ +/* dry=1 validates only (the "Validera" action); otherwise creates/updates. + Returns 1 when saved. */ static int rule_save(struct app *a, int64_t id, const char *box, int mti, const char *pattern, int signi, const char *sort, int dry) { @@ -120,7 +121,12 @@ static int rule_form(struct app *a, const struct rule *r) ff[4].cap = sizeof sort; ff[4].kind = TUI_F_TEXT; const char *title = r ? "Redigera momsregel" : "Ny momsregel"; + static const struct tui_action acts[] = { + { "rule.save", "Spara", TUI_KEY_SUBMIT, 1, NULL }, + { "rule.validate", "Validera", TUI_KEY_REFRESH, 1, NULL }, + }; for (;;) { + tui_set_actions(acts, 2); int rr = tui_form_run(title, ff, 5, 1, &sel); if (rr == TUI_FORM_BACK) return 0; @@ -202,7 +208,7 @@ void rules_screen(struct app *a) (long long)rules[i].sort_order); items[i] = xstrdup(line); } - items[n] = xstrdup("+ Ny regel (^N)"); + items[n] = xstrdup("+ Ny regel (n)"); int s = tui_select_list("Momsregler", items, (int)n + 1, cursor, 1, &cursor, 1, n > 0 ? "ta bort" : NULL, 0); for (size_t i = 0; i <= n; i++) diff --git a/clients/screens_settings.c b/clients/screens_settings.c index 46ba201..f47a51b 100644 --- a/clients/screens_settings.c +++ b/clients/screens_settings.c @@ -95,7 +95,7 @@ static void board_screen(struct app *a) lines[i] = xstrdup(line); } if (owner) - lines[n] = xstrdup("+ Ny ledamot (^N)"); + lines[n] = xstrdup("+ Ny ledamot (n)"); int s = tui_select_list("Styrelseledamöter", lines, rows, cursor, 1, &cursor, owner ? 1 : 0, owner && n > 0 ? "ta bort" : NULL, 0); @@ -296,8 +296,8 @@ static void settings_form(struct app *a, const char *title, (long long)jint_val(seq, "result.next_number", 1)); free(seq); } - tui_form_hint("upp/ned/Home/End Enter = ändra F5 = uppdatera" - " Esc/q = tillbaka ^C = avsluta"); + tui_form_hint("upp/ned = flytta Enter = ändra → = åtgärder" + " Esc/q = tillbaka"); int r = tui_form_run_actions(title, ff, n, can_edit, acts, show_password_status ? 1 : 0, NULL, focus); diff --git a/clients/screens_templates.c b/clients/screens_templates.c index e55028d..430a126 100644 --- a/clients/screens_templates.c +++ b/clients/screens_templates.c @@ -68,7 +68,8 @@ static void tpl_footer(void *ud, char *buf, size_t n) " kredit."); } -/* F5 dry-runs; Ctrl+Enter creates/updates. Returns 1 when saved. */ +/* dry=1 validates only (the "Validera" action); otherwise creates/updates. + Returns 1 when saved. */ static int tpl_save(struct tplform *tf, int dry) { const char *problem = NULL; @@ -233,9 +234,15 @@ static int template_form(struct app *a, const char *load_name) ff[2].kind = TUI_F_TEXT; tui_rt_set_fields(&tf.rt, ff, 3); const char *title = load_name ? "Redigera mall" : "Ny mall"; - const char *hint = "Enter = ändra fält Tab = byta fält F5 = validera" - " ^X = rensa rad F9 = spara Esc = avbryt"; + const char *hint = "Tab = byta fält Enter = ändra fält" + " ^O = åtgärder (spara, validera) ^X = rensa rad" + " Esc = avbryt"; + static const struct tui_action acts[] = { + { "template.save", "Spara", TUI_KEY_SUBMIT, 1, NULL }, + { "template.validate", "Validera", TUI_KEY_REFRESH, 1, NULL }, + }; for (;;) { + tui_set_actions(acts, 2); int rr = tui_rt_run(title, &tf.rt, hint); if (rr == -1) return 0; @@ -309,7 +316,7 @@ void templates_screen(struct app *a) free(ds); } names[n] = xstrdup(""); - lines[n] = xstrdup("+ Ny mall (^N)"); + lines[n] = xstrdup("+ Ny mall (n)"); int s = tui_select_list("Mallar", lines, (int)n + 1, cursor, 1, &cursor, 1, n > 0 ? "arkivera" : NULL, 0); if (s == -1) { diff --git a/clients/screens_vouchers.c b/clients/screens_vouchers.c index 3c33990..454486e 100644 --- a/clients/screens_vouchers.c +++ b/clients/screens_vouchers.c @@ -46,7 +46,7 @@ static int vd_key(void *ud, int ch) ctx->nav = dir; return TUI_HOOK_BACK; } - if (ch == TUI_KEY_CTRL_N) { + if (ch == 'n') { int64_t nid = vouchers_new(a); if (nid > 0) { ctx->posted_id = nid; @@ -88,7 +88,7 @@ static int vd_key(void *ud, int ch) } return TUI_HOOK_BACK; } - if (ch == 6) { /* ^F: attach a file to this voucher */ + if (ch == 'a') { /* attach a file to this voucher */ char *path = file_browser(a, a->attachment_dir); if (path) { struct stat sb; @@ -323,9 +323,23 @@ static int voucher_detail(struct app *a, int64_t id, int64_t *out_new, ctx.can_next = pos >= 0 && pos + 1 < total; char hint[256], title[96]; snprintf(hint, sizeof hint, - "%s^N = nytt verifikat c = rätta ^F = bifoga%s", + "%sn = nytt c = rätta a = bifoga%s", pos >= 0 ? "upp/ned = föregående/nästa " : "", natts ? " f = underlag" : ""); + struct tui_action acts[] = { + { "voucher.prev", "Föregående verifikat", KEY_UP, + ctx.can_prev, "första i listan" }, + { "voucher.next", "Nästa verifikat", KEY_DOWN, ctx.can_next, + "sista i listan" }, + { "voucher.correct", "Rätta (ändringsverifikat)…", 'c', 1, NULL }, + { "voucher.attach", "Bifoga fil…", 'a', 1, NULL }, + { "voucher.files", "Underlag…", 'f', natts ? 1 : 0, + "inga underlag" }, + { "voucher.new", "Nytt verifikat", 'n', 1, NULL }, + }; + /* stepping needs a place in the list order */ + tui_set_actions(pos >= 0 ? acts : acts + 2, + pos >= 0 ? 6 : 4); if (pos >= 0) snprintf(title, sizeof title, "Verifikat %s%lld (%d av %d)", series ? series : "", (long long)number, pos + 1, total); @@ -836,14 +850,25 @@ static int64_t vform_post(struct vform *vf, int dry) return 0; } +#define VFORM_TEMPLATE TUI_KEY_ACTION(0) +#define VFORM_ATTACH TUI_KEY_ACTION(1) + +static const struct tui_action VFORM_ACTS[] = { + { "voucher.post", "Bokför", TUI_KEY_SUBMIT, 1, NULL }, + { "voucher.validate", "Validera (torrkörning)", TUI_KEY_REFRESH, 1, + NULL }, + { "voucher.template", "Hämta mall…", VFORM_TEMPLATE, 1, NULL }, + { "voucher.attach", "Bifoga fil…", VFORM_ATTACH, 1, NULL }, +}; + static int vform_key(void *ud, int ch) { struct vform *vf = ud; - if (ch == KEY_F(4)) { + if (ch == VFORM_TEMPLATE) { vform_template(vf); return TUI_HOOK_STAY; } - if (ch == 6) { + if (ch == VFORM_ATTACH) { vform_attach(vf); return TUI_HOOK_STAY; } @@ -929,10 +954,11 @@ int64_t vouchers_new_prefill(struct app *a, const struct voucher_prefill *p) ff[2].kind = TUI_F_TEXT; tui_rt_set_fields(&vf.rt, ff, 3); tui_rt_normalize(&vf.rt); - const char *hint = "Enter = ändra fält Tab = byta fält F4 = mall" - " ^F = bifoga fil F5 = validera ^X = rensa rad" - " F9 = bokför Esc = avbryt"; + const char *hint = "Tab = byta fält Enter = ändra fält" + " ^O = åtgärder (bokför, validera, mall, bifoga)" + " ^X = rensa rad Esc = avbryt"; for (;;) { + tui_set_actions(VFORM_ACTS, 4); int rr = tui_rt_run("Nytt verifikat", &vf.rt, hint); if (rr == -1) return 0; @@ -1050,7 +1076,7 @@ static char **vlist_lines(const struct vlist_item *v, size_t n) v[i].desc ? v[i].desc : ""); items[i] = xstrdup(line); } - items[n] = xstrdup("+ Nytt verifikat (^N)"); + items[n] = xstrdup("+ Nytt verifikat (n)"); return items; } @@ -1098,6 +1124,11 @@ void vouchers_screen(struct app *a) vlist_sort_label(a->voucher_sort)); struct vlctx lctx = { a, 0 }; int cur = start; + struct tui_action lacts[] = { + { "voucher.new", "Nytt verifikat", 'n', 1, NULL }, + { "voucher.sort", "Byt sortering", 's', 1, NULL }, + }; + tui_set_actions(lacts, 2); tui_list_hint_extra("s = sortera"); int sel = tui_select_list_hook(title, items, (int)n + 1, start, 1, &cur, 1, NULL, 0, vlist_key, &lctx); diff --git a/clients/tui.c b/clients/tui.c index 43a3d27..bad019b 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -45,6 +45,11 @@ static int in_key(void) return g_in(); } +static void acts_take(const struct tui_action **acts, int *n); +static int ctx_menu(const struct tui_action *scr, int ns, + const struct tui_action *own, int no, int anchor_y); +static int ctx_key(int ch, int arrow_ok); + /* ------------------------------------------------------------------ */ /* theme: semantic styles, colour pairs and attribute fallback */ /* ------------------------------------------------------------------ */ @@ -165,14 +170,9 @@ int tui_sticky_split(char **lines, int n, char **head, int *nhead) void tui_keys_setup(void) { - /* ^Enter has no control code; enable xterm modifyOtherKeys level 2 (level - 1 leaves control+Enter alone) and the Kitty keyboard protocol, then bind - the sequences they produce. Terminals with neither keep F9 working. */ - putp("\033[>4;2m"); - putp("\033[>1u"); - fflush(stdout); - define_key("\033[27;5;13~", TUI_KEY_CTRL_ENTER); - define_key("\033[13;5u", TUI_KEY_CTRL_ENTER); + /* No extended keyboard protocol is enabled: every key the TUI uses has a + legacy encoding, which a browser terminal sends too. The CSI forms + below are still bound in case a terminal sends them anyway. */ define_key("\033[27;2;9~", KEY_BTAB); define_key("\033[9;2u", KEY_BTAB); define_key("\033[27u", 27); @@ -187,6 +187,8 @@ void tui_keys_setup(void) define_key("\033[102;5u", 6); define_key("\033[27;5;110~", 14); define_key("\033[110;5u", 14); + define_key("\033[27;5;111~", TUI_KEY_CTRL_O); + define_key("\033[111;5u", TUI_KEY_CTRL_O); define_key("\033[27;5;114~", 18); define_key("\033[114;5u", 18); define_key("\033[27;5;117~", 21); @@ -1065,9 +1067,9 @@ int tui_nav_key(struct tui_list_nav *v, int ch, int allow_new, v->sel = nav_snap(v, v->sel, 1); if (v->n <= 0) { /* an empty list is never a dead end: refresh, add and back work */ - if (ch == KEY_F(5)) + if (ch == 'r' || ch == TUI_KEY_REFRESH) return -2; - if (ch == TUI_KEY_CTRL_N && allow_new) + if (ch == 'n' && allow_new) return -4; if (ch == 27 || ch == 'q') return -1; @@ -1154,9 +1156,9 @@ int tui_nav_key(struct tui_list_nav *v, int ch, int allow_new, v->goto_active = 1; } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) return v->sel; - else if (ch == KEY_F(5)) + else if (ch == 'r' || ch == TUI_KEY_REFRESH) return -2; - else if (ch == TUI_KEY_CTRL_N && allow_new) + else if (ch == 'n' && allow_new) return -4; else if ((ch == 'd' || ch == 'D') && allow_remove) return -6; @@ -1265,25 +1267,54 @@ int tui_select_list_hook(const char *title, char **items, int n, int start, v.numw = tui_num_width(n); v.items = (const char *const *)items; snprintf(v.gotolabel, sizeof v.gotolabel, "Gå till rad: "); + const struct tui_action *sa; + int sn; + acts_take(&sa, &sn); + int pending = 0; for (;;) { if (cursor) *cursor = v.sel; list_view_sync(&v); - char hint[320], dbuf[64] = ""; - if (archive_action) - snprintf(dbuf, sizeof dbuf, " d = %s", archive_action); + char hint[320]; snprintf(hint, sizeof hint, - "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, - allow_toggle ? " ^A = öppna/stäng" : ""); + "Enter = öppna → = åtgärder%s g = gå till/sök" + " Esc/q = tillbaka", + allow_new ? " n = ny" : ""); if (g_list_hint_extra && *g_list_hint_extra) { size_t hl = strlen(hint); snprintf(hint + hl, sizeof hint - hl, " %s", g_list_hint_extra); } - list_draw(title, items, n, &v, hint); - int ch = in_key(); + if (!pending) + list_draw(title, items, n, &v, hint); + int ch = pending ? pending : in_key(); + pending = 0; + if (!v.goto_active && ctx_key(ch, 1)) { + if (key) + key(ud, TUI_KEY_MENU); + struct tui_action own[8]; + int no = 0; + if (n > 0) + own[no++] = (struct tui_action){ "list.open", "Öppna", '\n', + 1, NULL }; + if (allow_new) + own[no++] = (struct tui_action){ "list.new", "Ny", 'n', 1, + NULL }; + if (archive_action && n > 0) + own[no++] = (struct tui_action){ "list.remove", archive_action, + 'd', 1, NULL }; + if (allow_toggle && n > 0) + own[no++] = (struct tui_action){ "list.toggle", + "Stäng/öppna", 1, 1, NULL }; + own[no++] = (struct tui_action){ "list.goto", "Gå till rad / sök", + 'g', 1, NULL }; + if (allow_refresh) + own[no++] = (struct tui_action){ "list.refresh", "Uppdatera", + 'r', 1, NULL }; + own[no++] = (struct tui_action){ "list.back", "Tillbaka", 27, 1, + NULL }; + pending = ctx_menu(sa, sn, own, no, 2 + v.sel - v.top); + continue; + } int in_goto = v.goto_active; int r = tui_nav_key(&v, ch, allow_new, archive_action != NULL, allow_toggle, 0); @@ -1331,10 +1362,25 @@ int tui_menu(const char *title, const char *const *items, int n, v.view = (LINES - 4) / 2; v.items = items; snprintf(v.gotolabel, sizeof v.gotolabel, "Gå till nummer: "); + const struct tui_action *sa; + int sn; + acts_take(&sa, &sn); + int pending = 0; for (;;) { if (cursor) *cursor = v.sel; list_view_sync(&v); + if (pending) { + int r = tui_nav_key(&v, pending, allow_new, 0, 0, 1); + pending = 0; + if (r != -3) { + if (cursor) + *cursor = v.sel; + free(flags); + return r; + } + continue; + } if (g_frame) g_frame(title); int num = 0; @@ -1362,13 +1408,28 @@ int tui_menu(const char *title, const char *const *items, int n, nav_goto_draw(&v); if (g_hints) g_hints(allow_new - ? "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/sök, PgUp/PgDn," - " Home/End, Enter Esc/q = tillbaka ^C = avsluta"); + ? "Enter = öppna 1-9 = snabbval → = åtgärder" + " n = ny g = gå till/sök Esc/q = tillbaka" + : "Enter = öppna 1-9 = snabbval → = åtgärder" + " g = gå till/sök Esc/q = tillbaka"); refresh(); - int r = tui_nav_key(&v, in_key(), allow_new, 0, 0, 1); + int ch = in_key(); + if (!v.goto_active && ctx_key(ch, 1)) { + struct tui_action own[4]; + int no = 0; + own[no++] = (struct tui_action){ "menu.open", "Öppna", '\n', 1, + NULL }; + if (allow_new) + own[no++] = (struct tui_action){ "menu.new", "Ny", 'n', 1, + NULL }; + own[no++] = (struct tui_action){ "menu.goto", "Gå till / sök", + 'g', 1, NULL }; + own[no++] = (struct tui_action){ "menu.back", "Tillbaka", 27, 1, + NULL }; + pending = ctx_menu(sa, sn, own, no, 3 + (v.sel - v.top) * 2); + continue; + } + int r = tui_nav_key(&v, ch, allow_new, 0, 0, 1); if (r != -3) { if (cursor) *cursor = v.sel; @@ -1500,6 +1561,9 @@ int tui_pager_hook(const char *title, const char *text, int vbody = view - nhead; if (vbody < 1) vbody = 1; + const struct tui_action *sa; + int sn; + acts_take(&sa, &sn); for (;;) { tui_frame(title); int row = 0; @@ -1514,20 +1578,36 @@ int tui_pager_hook(const char *title, const char *text, char hint[512]; const char *scroll = (flags & TUI_PAGER_NO_ARROWS) ? "PgUp/PgDn/Home/End rullar" - : "piltangenter/PgUp/PgDn rullar"; + : "upp/ned/PgUp/PgDn rullar"; if (extra_hint && *extra_hint) snprintf(hint, sizeof hint, - "%s F5 = uppdatera %s q = tillbaka", scroll, + "%s → = åtgärder %s q = tillbaka", scroll, extra_hint); else - snprintf(hint, sizeof hint, "%s F5 = uppdatera q = tillbaka", + snprintf(hint, sizeof hint, "%s → = åtgärder q = tillbaka", scroll); tui_hints(hint); refresh(); int ch = in_key(); + if (ctx_key(ch, 1)) { + if (key) + key(ud, TUI_KEY_MENU); + struct tui_action own[4]; + int no = 0; + own[no++] = (struct tui_action){ "pager.refresh", "Uppdatera", 'r', + 1, NULL }; + if (flags & TUI_PAGER_SAVE) + own[no++] = (struct tui_action){ "pager.save", "Spara", 's', 1, + NULL }; + own[no++] = (struct tui_action){ "pager.back", "Tillbaka", 27, 1, + NULL }; + ch = ctx_menu(sa, sn, own, no, 2); + if (!ch) + continue; + } if (ch == 'q' || ch == 27) break; - if (ch == KEY_F(5)) { + if (ch == 'r' || ch == TUI_KEY_REFRESH) { ret = 1; break; } @@ -1597,9 +1677,9 @@ int tui_form_next(int sel, int n, int ch) int p = sel - TUI_FORM_PAGE; return p < 0 ? 0 : p; } - if (ch == KEY_F(5)) + if (ch == TUI_KEY_REFRESH) return TUI_FORM_REFRESH; - if (ch == TUI_KEY_CTRL_ENTER || ch == KEY_F(9)) + if (ch == TUI_KEY_SUBMIT) return TUI_FORM_SUBMIT; if (ch == 27 || ch == 'q') return TUI_FORM_BACK; @@ -1734,22 +1814,24 @@ void tui_form_act_label(const struct tui_form_action *a, char *buf, size_t n) } } -static void action_key_name(int key, char *buf, size_t n) +void tui_key_name(int key, char *buf, size_t n) { - if (key >= KEY_F(1) && key <= KEY_F(12)) - snprintf(buf, n, "F%d", key - KEY_F(0)); - else if (key == TUI_KEY_CTRL_N) - snprintf(buf, n, "^N"); - else if (key == TUI_KEY_CTRL_X) - snprintf(buf, n, "^X"); - else if (key == 27) + if (key == 27) snprintf(buf, n, "Esc"); else if (key == '\n' || key == '\r' || key == KEY_ENTER) snprintf(buf, n, "Enter"); + else if (key == ' ') + snprintf(buf, n, "mellanslag"); + else if (key == KEY_UP) + snprintf(buf, n, "↑"); + else if (key == KEY_DOWN) + snprintf(buf, n, "↓"); else if (key > 0 && key < 27) snprintf(buf, n, "^%c", 'A' + key - 1); + else if (key > 32 && key < 127) + snprintf(buf, n, "%c", key); else - snprintf(buf, n, "?"); + buf[0] = '\0'; /* virtual: menu only */ } void tui_action_label(const struct tui_action *a, char *buf, size_t n) @@ -1778,9 +1860,11 @@ void tui_action_hint(const struct tui_action *acts, int n, int max, char *buf, char key[16], item[200]; size_t len; - if (acts[i].enabled != 1 || !acts[i].key) + if (acts[i].enabled != 1) + continue; + tui_key_name(acts[i].key, key, sizeof key); + if (!key[0]) continue; - action_key_name(acts[i].key, key, sizeof key); snprintf(item, sizeof item, "%s%s = %s", shown ? " " : "", key, acts[i].label ? acts[i].label : ""); len = strlen(item); @@ -1790,110 +1874,224 @@ void tui_action_hint(const struct tui_action *acts, int n, int max, char *buf, o += len; shown++; } - const char *more = "F2 = fler"; + const char *more = "→ = åtgärder"; size_t ml = strlen(more); if (o + ml + (o ? 3 : 0) + 1 <= cap) snprintf(buf + o, cap - o, "%s%s", o ? " " : "", more); } -int tui_action_menu(const char *title, const struct tui_action *acts, int n, - int *focus) +int tui_ctx_merge(const struct tui_action *scr, int ns, + const struct tui_action *own, int no, + struct tui_action *out, int cap) { - struct tui_list_nav v; - unsigned char *ok; - char **items; - int ret = -1; + int n = 0; + for (int i = 0; scr && i < ns && n < cap; i++) + out[n++] = scr[i]; + int sep = n > 0; + for (int i = 0; own && i < no && n < cap; i++) { + int taken = 0; + for (int j = 0; scr && j < ns; j++) + if (own[i].key && scr[j].key == own[i].key) + taken = 1; + if (taken) + continue; + if (sep && n + 1 < cap) { + memset(&out[n], 0, sizeof out[n]); + out[n].label = ""; + out[n].enabled = -1; + n++; + } + sep = 0; + out[n++] = own[i]; + } + return n; +} + +static int menu_step(const struct tui_action *acts, int n, int from, int dir) +{ + for (int k = 1; k <= n; k++) { + int i = ((from + k * dir) % n + n) % n; + if (acts[i].enabled != -1) + return i; + } + return from; +} +int tui_action_menu_at(const char *title, const struct tui_action *acts, + int n, int anchor_y, int *focus) +{ if (!acts || n <= 0) return -1; - memset(&v, 0, sizeof v); - v.n = n; - ok = xcalloc((size_t)n, 1); - items = xcalloc((size_t)n, sizeof(char *)); + int lw = 0, kw = 0; for (int i = 0; i < n; i++) { - char line[192]; - - tui_action_label(&acts[i], line, sizeof line); - items[i] = xstrdup(line); - ok[i] = acts[i].enabled != -1; - } - v.selectable = ok; - v.sel = focus && *focus >= 0 && *focus < n ? *focus : 0; - if (!ok[v.sel]) - v.sel = nav_snap(&v, v.sel, 1); - v.numw = tui_num_width(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: "); + char lab[256], key[16]; + tui_action_label(&acts[i], lab, sizeof lab); + tui_key_name(acts[i].key, key, sizeof key); + if (tui_disp_width(lab) > lw) + lw = tui_disp_width(lab); + if (tui_disp_width(key) > kw) + kw = tui_disp_width(key); + } + int w = lw + (kw ? kw + 3 : 0) + 4; + if (w < tui_disp_width(title) + 6) + w = tui_disp_width(title) + 6; + if (w < 24) + w = 24; + if (w > COLS - 4) + w = COLS - 4; + int h = n + 2; + if (h > LINES - 2) + h = LINES - 2; + int view = h - 2; + int x = COLS - w - 2; + if (x < 1) + x = 1; + int y = anchor_y < 0 ? (LINES - h) / 2 : anchor_y - 1; + if (y + h > LINES - 1) + y = LINES - 1 - h; + if (y < 1) + y = 1; + int sel = focus && *focus >= 0 && *focus < n ? *focus : 0; + if (acts[sel].enabled == -1) + sel = menu_step(acts, n, sel, 1); + if (acts[sel].enabled == -1) + return -1; /* only headings */ + refresh(); + WINDOW *win = newwin(h, w, y, x); + int top = 0, ret = -1, prev = curs_set(0); for (;;) { - int num = 0; - - if (v.sel < 0 || v.sel >= n) { - v.sel = nav_snap(&v, 0, 1); - if (v.sel < 0) - break; - } - if (focus) - *focus = v.sel; - list_view_sync(&v); - if (g_frame) - g_frame(title); - for (int i = 0; i < v.top && i < n; i++) - if (ok[i]) - num++; - for (int idx = v.top; idx < n && idx < v.top + v.view; idx++) { - char line[832]; - if (!ok[idx]) { - snprintf(line, sizeof line, "%*s %s", v.numw, "", - items[idx]); - attron(tui_style_attrs(TUI_DIM, g_colours, 0)); - mvaddnstr(3 + (idx - v.top), 4, line, COLS - 6); - attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); + if (sel < top) + top = sel; + if (sel >= top + view) + top = sel - view + 1; + werase(win); + wattrset(win, tui_style_attrs(TUI_TITLE, g_colours, 0)); + box(win, 0, 0); + mvwaddnstr(win, 0, 2, title, w - 4); + wattrset(win, A_NORMAL); + for (int r = 0; r < view && top + r < n; r++) { + const struct tui_action *a = &acts[top + r]; + if (a->enabled == -1 && !(a->label && *a->label)) { + wattrset(win, tui_style_attrs(TUI_RULE, g_colours, 0)); + mvwhline(win, 1 + r, 1, ACS_HLINE, w - 2); + wattrset(win, A_NORMAL); continue; } - num++; - snprintf(line, sizeof line, "%*d. %s", v.numw, num, items[idx]); - if (acts[idx].enabled != 1) - attron(tui_style_attrs(TUI_DIM, g_colours, 0)); - if (idx == v.sel) - attron(A_REVERSE); - mvaddnstr(3 + (idx - v.top), 4, line, COLS - 6); - if (idx == v.sel) - attroff(A_REVERSE); - if (acts[idx].enabled != 1) - attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); + char lab[256], key[16], line[512]; + tui_action_label(a, lab, sizeof lab); + tui_key_name(a->key, key, sizeof key); + tui_pad_field(lab, sizeof lab, w - 4 - (kw ? kw + 1 : 0)); + snprintf(line, sizeof line, " %s%s%*s ", lab, kw ? " " : "", kw, + ""); + tui_pad_field(line, sizeof line, w - 2); + int attr = a->enabled == 1 ? (int)A_NORMAL + : a->enabled == 0 ? tui_style_attrs(TUI_DIM, g_colours, 0) + : tui_style_attrs(TUI_HEADING, + g_colours, 0); + if (top + r == sel) + attr |= A_REVERSE; + wattrset(win, attr); + mvwaddstr(win, 1 + r, 1, line); + /* the key, right-aligned in its column */ + if (key[0]) { + int kx = w - 2 - tui_disp_width(key); + wattrset(win, (top + r == sel ? A_REVERSE : A_NORMAL) | + tui_style_attrs(TUI_ACCENT, g_colours, 0)); + mvwaddstr(win, 1 + r, kx, key); + } + wattrset(win, A_NORMAL); } - nav_goto_draw(&v); if (g_hints) - g_hints("upp/ned, 1-9 = hoppa, g = gå till/sök, PgUp/PgDn, Home/End," - " Enter = utför Esc/q = avbryt"); + g_hints("upp/ned = välj Enter = utför Esc/vänster = stäng"); refresh(); + touchwin(win); + wrefresh(win); int ch = in_key(); - if (!v.goto_active && (ch == '\n' || ch == '\r' || ch == KEY_ENTER)) { - if (!ok[v.sel]) - continue; - if (acts[v.sel].enabled != 1) { - tui_message(title, "%s", - acts[v.sel].reason && *acts[v.sel].reason - ? acts[v.sel].reason - : "Otillgänglig."); + if (ch == KEY_UP || ch == KEY_BTAB) { + sel = menu_step(acts, n, sel, -1); + } else if (ch == KEY_DOWN || ch == '\t') { + sel = menu_step(acts, n, sel, 1); + } else if (ch == KEY_HOME) { + sel = menu_step(acts, n, n - 1, 1); + } else if (ch == KEY_END) { + sel = menu_step(acts, n, 0, -1); + } else if (ch == 27 || ch == KEY_LEFT || ch == 'q' || + ch == TUI_KEY_CTRL_O) { + break; + } else { + int pick = -1; + if (ch == '\n' || ch == '\r' || ch == KEY_ENTER || ch == KEY_RIGHT) + pick = sel; + else + for (int i = 0; i < n; i++) + if (acts[i].enabled != -1 && acts[i].key == ch) + pick = i; + if (pick < 0) continue; + sel = pick; + if (acts[pick].enabled == 1) { + ret = pick; + break; } - ret = v.sel; - break; + tui_message(title, "%s", + acts[pick].reason && *acts[pick].reason + ? acts[pick].reason + : "Otillgänglig."); } - if (tui_nav_key(&v, ch, 0, 0, 0, 0) == -1) - break; } - for (int i = 0; i < n; i++) - free(items[i]); - free(items); - free(ok); + if (focus) + *focus = sel; + curs_set(prev == ERR ? 0 : prev); + delwin(win); + touchwin(stdscr); + refresh(); return ret; } +int tui_action_menu(const char *title, const struct tui_action *acts, int n, + int *focus) +{ + return tui_action_menu_at(title, acts, n, -1, focus); +} + +/* Screen actions registered for the next widget run. */ +static const struct tui_action *g_acts; +static int g_nacts; + +void tui_set_actions(const struct tui_action *acts, int n) +{ + g_acts = acts; + g_nacts = acts && n > 0 ? n : 0; +} + +static void acts_take(const struct tui_action **acts, int *n) +{ + *acts = g_acts; + *n = g_nacts; + g_acts = NULL; + g_nacts = 0; +} + +#define CTX_MAX 64 + +/* Opens the context menu over screen + own actions; returns the key of the + chosen action (fed back to the widget) or 0. */ +static int ctx_menu(const struct tui_action *scr, int ns, + const struct tui_action *own, int no, int anchor_y) +{ + struct tui_action all[CTX_MAX]; + int n = tui_ctx_merge(scr, ns, own, no, all, CTX_MAX); + int i = tui_action_menu_at("Åtgärder", all, n, anchor_y, NULL); + return i >= 0 ? all[i].key : 0; +} + +/* Whether ch opens the context menu here: ^O always, → where no caret + uses it. */ +static int ctx_key(int ch, int arrow_ok) +{ + return ch == TUI_KEY_CTRL_O || (arrow_ok && ch == KEY_RIGHT); +} + /* Form layout: labels at column 2, values at FORM_VALUE_X, first row at FORM_Y. Shared by tui_form_run* and the header fields of tui_rt. */ #define FORM_Y 5 @@ -2023,6 +2221,10 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, { int sel = tui_form_focus_clamp(focus ? *focus : -1, nf, acts, na); int top = 0; + const struct tui_action *sa; + int sn; + acts_take(&sa, &sn); + int pending = 0; if (na < 0) na = 0; for (;;) { @@ -2071,18 +2273,15 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, h = na > 0 ? (can_edit ? "upp/ned/Tab = flytta Enter = ändra/utför" - " F5 = uppdatera F9 = spara" - " Esc/q = tillbaka ^C = avsluta" + " → = åtgärder Esc/q = tillbaka" : "upp/ned/Tab = flytta Enter = utför" - " F5 = uppdatera Esc/q = tillbaka" - " ^C = avsluta" + " → = åtgärder Esc/q = tillbaka" " (endast behöriga kan ändra)") : (can_edit - ? "upp/ned/Home/End Enter = ändra" - " F5 = uppdatera F9 = spara" - " Esc/q = tillbaka ^C = avsluta" - : "upp/ned/Home/End F5 = uppdatera" - " Esc/q = tillbaka ^C = avsluta" + ? "upp/ned = flytta Enter = ändra" + " → = åtgärder Esc/q = tillbaka" + : "upp/ned = flytta → = åtgärder" + " Esc/q = tillbaka" " (endast behöriga kan ändra)"); char hbuf[640]; if (g_form_hint_extra && *g_form_hint_extra && @@ -2092,7 +2291,28 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, tui_hints(h); g_form_hint = NULL; refresh(); - int ch = in_key(); + int ch = pending ? pending : in_key(); + pending = 0; + if (ctx_key(ch, 1)) { + if (key) + key(ud, TUI_KEY_MENU); + struct tui_action own[5]; + int no = 0; + if (sel >= 0 && sel < nf) + own[no++] = (struct tui_action){ + "form.edit", f[sel].kind == TUI_F_ACTION ? "Öppna" : "Ändra", + '\n', can_edit ? 1 : 0, "endast behöriga kan ändra" }; + else if (sel >= nf) + own[no++] = (struct tui_action){ "form.run", "Utför", '\n', 1, + NULL }; + own[no++] = (struct tui_action){ "form.refresh", "Uppdatera", + TUI_KEY_REFRESH, 1, NULL }; + own[no++] = (struct tui_action){ "form.back", "Tillbaka", 27, 1, + NULL }; + int line = sel < 0 ? 0 : sel < nf ? sel : sel + 1; + pending = ctx_menu(sa, sn, own, no, FORM_Y + line - top); + continue; + } int nxt = na > 0 ? tui_form_act_key(sel, nf, acts, na, ch) : tui_form_next(sel, nf, ch); if (nxt >= 0) { @@ -2410,6 +2630,10 @@ int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) int top = 0; int prev_cursor = curs_set(0); int ret; + const struct tui_action *sa; + int sn; + acts_take(&sa, &sn); + int pending = 0; for (;;) { if (g_frame) g_frame(title); @@ -2501,17 +2725,41 @@ int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) if (caret_y >= 0) move(caret_y, caret_x); refresh(); - int ch = in_key(); + int ch = pending ? pending : in_key(); + pending = 0; + /* ^O everywhere; → only on a header field, in a cell it moves the + caret */ + if (ctx_key(ch, t->focus >= 0)) { + if (t->key) + t->key(t->ud, TUI_KEY_MENU); + struct tui_action own[5]; + int no = 0; + if (t->focus >= 0) + own[no++] = (struct tui_action){ "rt.edit", "Ändra fält", '\n', + 1, NULL }; + else + own[no++] = (struct tui_action){ "rt.clear", "Rensa raden", + TUI_KEY_CTRL_X, 1, NULL }; + own[no++] = (struct tui_action){ "rt.refresh", "Validera", + TUI_KEY_REFRESH, 1, NULL }; + own[no++] = (struct tui_action){ "rt.submit", "Spara", + TUI_KEY_SUBMIT, 1, NULL }; + own[no++] = (struct tui_action){ "rt.back", "Avbryt", 27, 1, + NULL }; + int ay = caret_y >= 0 ? caret_y : FORM_Y + t->focus; + pending = ctx_menu(sa, sn, own, no, ay); + continue; + } /* no 'q' here: every printable key is text in a cell or field */ if (ch == 27) { ret = -1; break; } - if (ch == KEY_F(5)) { + if (ch == TUI_KEY_REFRESH) { ret = -2; break; } - if (ch == TUI_KEY_CTRL_ENTER || ch == KEY_F(9)) { + if (ch == TUI_KEY_SUBMIT) { ret = -3; break; } diff --git a/clients/tui.h b/clients/tui.h index 4ae0da6..58b3ff5 100644 --- a/clients/tui.h +++ b/clients/tui.h @@ -12,8 +12,15 @@ /* Universal keys that have no control code or need a stable name. */ #define TUI_KEY_CTRL_N 0x0e +#define TUI_KEY_CTRL_O 0x0f /* context menu, also while typing in a cell */ #define TUI_KEY_CTRL_X 0x18 #define TUI_KEY_CTRL_ENTER (KEY_MAX + 1) +/* Virtual keys: they have no physical key and only come from the context + menu (docs/TUI-GUIDELINES.md "Keys and the web"). No widget uses F-keys. */ +#define TUI_KEY_REFRESH (KEY_MAX + 2) /* uppdatera / validera / förhandsvisa */ +#define TUI_KEY_SUBMIT (KEY_MAX + 3) /* spara / bokför / utfärda */ +#define TUI_KEY_MENU (KEY_MAX + 4) /* to the key hook: the menu opens */ +#define TUI_KEY_ACTION(n) (KEY_MAX + 16 + (n)) /* menu-only screen action */ /* Semantic styles: the widget layer maps them to colour pairs when the terminal supports colour and NO_COLOR is unset, otherwise to plain @@ -230,14 +237,36 @@ struct tui_action { /* "label", or "label (reason)" when the action is disabled. */ void tui_action_label(const struct tui_action *a, char *buf, size_t n); -/* Builds "F9 = spara F2 = fler", showing at most max keyed actions. */ +/* Builds "a = bifoga n = ny → = åtgärder", showing at most max actions + whose key is a visible key (virtual keys are menu-only). */ void tui_action_hint(const struct tui_action *acts, int n, int max, char *buf, size_t cap); -/* Modal action list: arrows/Tab/Home/End/PgUp/PgDn/1-9/g navigate, Enter - runs (a disabled action shows its reason), Esc cancels. Returns the - chosen index or -1. *focus remembers the row. */ +/* Display name of an action key: "Enter", "Esc", "^X", "n", "↑"; "" for a + virtual key (menu only). */ +void tui_key_name(int key, char *buf, size_t n); +/* Context menu ("Åtgärder"): a box at the right edge next to row anchor_y + (-1: centred) listing label and key per action. Up/down/Home/End move, + Enter or the action's own key runs it (a disabled action shows its + reason), Esc/←/q/^O close. Returns the chosen index or -1; *focus (may + be NULL) remembers the row. */ +int tui_action_menu_at(const char *title, const struct tui_action *acts, + int n, int anchor_y, int *focus); int tui_action_menu(const char *title, const struct tui_action *acts, int n, int *focus); +/* The screen's actions for the next widget run (lists, menus, pager, + forms, row table); the widget takes them at entry, so they never leak + into a widget opened from a hook. → (or ^O) opens the context menu with + these first, then the widget's own; choosing one feeds its key to the + widget as if it was typed, so accelerators and the menu share one path. + The key hook gets TUI_KEY_MENU right before the menu opens, so a screen + can update enabled/reason for the current row in its array. */ +void tui_set_actions(const struct tui_action *acts, int n); +/* Screen actions first, then own actions whose key is not taken, with a + separator row (enabled -1, empty label) between the groups. Returns the + count written to out. Pure. */ +int tui_ctx_merge(const struct tui_action *scr, int ns, + const struct tui_action *own, int no, + struct tui_action *out, int cap); /* --- action list in a form (tui_form_run_actions) --- */ /* enabled: 1 = selectable and runs; 0 = dimmed but selectable (Enter shows @@ -269,7 +298,7 @@ int tui_form_act_step(int focus, int nf, const struct tui_form_action *acts, int tui_form_act_page(int focus, int nf, const struct tui_form_action *acts, int na, int dir); /* Same ring for one key, plus Home/End/PgUp/PgDn and the form return keys - (F5/^Enter/F9/Esc/q). Returns the new focus, a TUI_FORM_* code or + (TUI_KEY_REFRESH/TUI_KEY_SUBMIT from the menu, Esc/q). Returns the new focus, a TUI_FORM_* code or TUI_NAV_NONE. */ int tui_form_act_key(int sel, int nf, const struct tui_form_action *acts, int na, int ch); diff --git a/clients/ui.h b/clients/ui.h index 884adbf..0fb533b 100644 --- a/clients/ui.h +++ b/clients/ui.h @@ -12,12 +12,8 @@ /* Shared UI state and helpers for the bokftui screens (screens_.c). The scene registry and main() live in bokftui.c. */ -/* universal "new/add" hotkey: ^N (0x0e), the de-facto Linux convention */ -#define KEY_CTRL_N 0x0e /* ^X clears the focused row in row editors */ #define KEY_CTRL_X 0x18 -/* ^Enter saves/posts the form; sent via xterm modifyOtherKeys */ -#define KEY_CTRL_ENTER (KEY_MAX + 1) /* Pager markup: headings, dimmed derived lines and full-width rules. */ #define MK_HEAD "\001" #define MK_DIM "\002" diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5499610..c18f49b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -261,6 +261,20 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21). row, the `F2` menu, delete from list and editor); the other screens follow. +29. **Context menu and web-safe keys (2026-09-23)**: `→` opens a context + menu ("Åtgärder") where no caret uses the arrow, `^O` everywhere + (also in table cells); it is a box at the right edge listing every + action with its key, so it doubles as the hotkey list. Choosing an + action feeds its key to the widget, so the menu and the accelerator + share one path. All function keys are removed (F2 menu, F4 mall, F5 + refresh/validate/preview, F7, F9 save/post) and so are `Ctrl+N` (now + `n`), `Ctrl+Enter` and `Ctrl+F`; the extended keyboard protocols are no + longer enabled. Reason: the TUI is to be served in a browser terminal + for users outside the home network (the owner and Petter first, others + later), and browsers own the F-keys and `Ctrl+N`/`T`/`W`. Supersedes + the key parts of #11 and #28; `make check` rejects F-keys, `^N` and + `^Enter` in `clients/`. + ## Completed work formerly listed under "Pending decisions" - Attachments are complete: download (voucher detail `f`, Underlag `Enter`, diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 9ab9620..709a475 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -954,9 +954,10 @@ commands. Implemented screens (0.1.0-dev): `voucher.list` calls) and `s` cycles its sort order (number or date, ascending or descending, remembered in `tui.conf`); in the detail view up/down step to the previous/next voucher in that order. -- **Nytt verifikat** — row editor with live balance display, F5 dry-run - validation and F9 posting; one `client_ref` per form makes retries safe. - F4 applies a konteringsmall (prompts for template and `x`). +- **Nytt verifikat** — row editor with live balance display; the context + menu (`^O`) offers Bokför, Validera (dry run), Hämta mall… (prompts for + template and `x`) and Bifoga fil…; one `client_ref` per form makes + retries safe. - **Ingående balans** — the effective opening balances of the selected fiscal year (carry-forward plus any `IB` vouchers, as the reports compute them); enter accounts with signed amounts (positive debit, negative @@ -967,7 +968,8 @@ commands. Implemented screens (0.1.0-dev): as `YYYY-MM-DD - YYYY-MM-DD` (plus label and open/closed) so broken fiscal years are visible. All screens then work in the selected year. - **Mallar** — list, create and edit templates in the same form style as - vouchers (Tab, dynamic rows, F7 clear row, F5 validate, F9 save); archive + vouchers (Tab, dynamic rows, `^X` clear row, Validera and Spara in the + context menu); archive keeps the template but hides it from the list. - **Underlag** — inbox of unlinked attachments; `a` uploads a file and Enter opens **Granska** (text in a pager, other files in the desktop @@ -976,47 +978,49 @@ commands. Implemented screens (0.1.0-dev): - **Bankavstämning** — imported bank transactions (`bank.import`) matched against vouchers on the bank account, with suggestions; Enter matches the suggested voucher (or picks another), `u` unmatches, `a` imports a SEB CSV. - `Ctrl+N` (or the last row `Skapa nytt verifikat…` in the match list) opens + `n` (or the last row `Skapa nytt verifikat…` in the match list) opens **Nytt verifikat** prefilled with the transaction's date, text, signed amount and bank account; posting it auto-matches the new voucher and returns to the list. A failed auto-match keeps the posted voucher and shows the server error. - **Fakturor** — invoice list (`invoice.list`, newest first) with number, date, customer, total and status (`utfärdad`/`krediterad`/`betald - `). Ctrl+N opens the form, Enter the detail. The form has the + `). `n` opens the form, Enter the detail. The form has the customer picker, invoice/due (due defaults from the customer's payment days) and delivery dates, er/var referens and rows (beskrivning, antal, enhet, à-pris, moms, anm); a row with only beskrivning is a free-text line - (`text` rows, no amount). `F5` previews the real PDF (`invoice.preview`, - nothing stored, no number consumed), `F9` issues (`invoice.issue`) and + (`text` rows, no amount). The context menu's Förhandsvisa PDF shows the + real PDF (`invoice.preview`, nothing stored, no number consumed), Utfärda + issues (`invoice.issue`) and then asks "Skicka faktura till ?". The detail shows header and rows and offers `p = visa PDF` (`invoice.pdf`), `s = skicka` (`invoice.send`), `u = duplicera` (a new draft with the same rows and today's dates) and, on unpaid invoices, `b = kvittera betalning`: a prefilled payment voucher (debit `bank_account`, credit `invoice_receivable_account`) is opened in the ordinary voucher form and, - once posted, linked with `invoice.pay`. In the list, `n` sets the next - invoice number (`invoice.sequence_get`/`sequence_set`, owner-only). + once posted, linked with `invoice.pay`. In the list, the context menu's + Ändra nästa fakturanummer… sets the next invoice number + (`invoice.sequence_get`/`sequence_set`, owner-only). - **Kunder** — the customer register (name, address, postal code, city, VAT - number, e-mail, your reference, payment days, notes). Ctrl+N creates, - Enter edits (F5 validates with a dry run, Ctrl+Enter saves), `d` - archives/reactivates. + number, e-mail, your reference, payment days, notes). `n` creates, Enter + edits (Spara row or menu; Validera is a dry run), `d` archives/ + reactivates a customer or deletes a draft. - **Lönekörningar** — the monthly payroll runs (`payroll.run_list`, newest - period first) with status (`bokförd`/`betald`), gross and net. Ctrl+N + period first) with status (`bokförd`/`betald`), gross and net. `n` starts a new run (period YYYY-MM and pay date, defaulting into the selected fiscal year) when an active employee with a monthly salary exists, Enter opens an existing run. The run screen shows the status and - totals, `F5` previews (`payroll.run_preview` for a new run, the stored - lines for a posted one), `Ctrl+Enter` posts after confirmation + totals, Förhandsvisa (`payroll.run_preview` for a new run, the stored + lines for a posted one) and Bokför körning… posts after confirmation (`payroll.run_post`, showing the voucher), and the manual steps are the action rows **Lönebesked** (PDF via `payroll.payslip`), **AGI-underlag** (`payroll.agi`, owner only) and **Betala skatt & avgifter** (`payroll.pay_tax`); a disabled step names its reason. The list title warns when the current year's tax tables are missing. - **Anställda** — the employee register (name, masked personnummer, salary, - tax table/column, e-mail). Ctrl+N creates, Enter edits (an empty - personnummer keeps the stored one), `d` archives/reactivates; F5 - validates with a dry run and Ctrl+Enter saves. + tax table/column, e-mail). `n` creates, Enter edits (an empty + personnummer keeps the stored one), `d` archives/reactivates; Validera + (dry run) and Spara are in the context menu. - **Skattetabeller** (via **System**) — stored tax table years, the current year's status, fetch time and source. Owners fetch Skatteverket's official monthly table (`payroll.tax_tables_fetch`) or import a file offline @@ -1028,9 +1032,9 @@ commands. Implemented screens (0.1.0-dev): Report tables keep their column-header row pinned while the body scrolls. The draft asks for the board's proposed dividend, kept per fiscal year with `fiscal_year.update`. -- **Bokslut** — periodiseringsfond and tax rate fields; F5 shows the posting - plan as a `bokslut.post` dry run, `^Enter` (or F9) asks for confirmation - and posts the plan. +- **Bokslut** — periodiseringsfond and tax rate fields; Visa bokslutsplan + shows the posting plan as a `bokslut.post` dry run, Bokför planen… asks + for confirmation and posts the plan. - **Information om året** — the per-year årsredovisning details (material events, AGM and payment dates, proposed dividend, employees, other notes), edited per field; a new fiscal year inherits the stable fields. The @@ -1051,12 +1055,15 @@ commands. Implemented screens (0.1.0-dev): - **Revision** (via **System**) — chain verification and behandlingshistorik. -**Ctrl+N is the universal "add" key**: it starts a new verifikat from the -dashboard, the voucher list and the voucher detail view; a new mall from the -Mallar menu; a new customer or employee from their registers; a new payroll -run from Lönekörningar; a new fiscal year from the year picker; and maps to -the editor in Ingående balans and to file upload in Underlag. F5 is the -universal refresh. Hints show the keys per screen. +**`→` (or `^O`) opens the context menu** ("Åtgärder") with every action of +the view and the key that does the same; `^O` also works while typing in a +table cell. **`n` is the universal "add" key**: a new verifikat from the +dashboard, the voucher list and the voucher detail view; a new mall, +customer, employee, payroll run, rule or board member from their lists; a +new fiscal year from the year picker; file upload in Underlag. `r` is the +universal refresh in lists and views. The TUI uses no function keys and no +browser-reserved control keys (`Ctrl+N`/`T`/`W`), so it runs unchanged in a +browser terminal; see TUI-GUIDELINES.md "Keys and the web". **Ctrl+R reloads the client in place** (for hot-reloading after a rebuild): it re-execs the installed binary with `--org`, `--fy` and `--screen NAME` diff --git a/docs/STATE.md b/docs/STATE.md index 4dcbb35..77ef9a4 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -14,6 +14,18 @@ unit tests and the docs consistency check. ## Resume here (2026-09-23) +- **Context menu and web-safe keys (2026-09-23, branch + `eff/context-menu`)**: `→` (or `^O`, also in table cells) opens + "Åtgärder", a box at the right edge with every action of the view and + its key; every screen declares its actions (`tui_set_actions`). All + F-keys, `Ctrl+N` (now `n`), `Ctrl+Enter` and `Ctrl+F` are gone, and the + extended keyboard protocols are no longer enabled, so the TUI can run in + a browser terminal; `make check` rejects F-keys/`^N`/`^Enter` in + `clients/`. Save/post/validate/preview/template/attach in forms are menu + actions. Decision #29; spec in TUI-GUIDELINES.md "Actions and the context + menu" and "Keys and the web". Next for the web: an upload/download path + through the browser instead of the local file browser, and per-user + isolation of the TUI processes. - **Byt lösenord (2026-09-23, deployed in `v0.1.68`)**: new command `user.set_password` (own password, password session only, current password required and rate limited like logins, ≥ 10 characters, other sessions closed, audited without secrets) and a @@ -254,8 +266,8 @@ Original entries for the struck items are in `docs/DECISIONS.md`. Drive the TUI over a pty with `scripts/tui-sandbox.sh -- ./build/bokftui --socket /tmp/opencode/bokf-local/sock --org 1 --fy 1 ...` plus a small driver that feeds keys and an ANSI renderer (recreate if gone; arrows are - `ESC O B/A`, Tab `\t`, `^X` `\x18`, `^Enter` `ESC[27;5;13~`, F5 - `ESC[15~`). Never test against the live daemon. + `ESC O B/A`, Tab `\t`, `^X` `\x18`, `^O` `\x0f` for the context + menu). Never test against the live daemon. - Demo: db `~/bokf-demo/bokfd.db`, socket `~/bokf-demo/bokfd.sock`, @@ -271,18 +283,20 @@ Original entries for the struck items are in `docs/DECISIONS.md`. (schema v1, old binary) — recreate or migrate it with the current build if it is still wanted. - TUI smoke tests: drive over a pty with `script -qec`; function-key escape - sequences are timing-sensitive there (not an app bug). Arrows arrive as - application-mode sequences (`ESC O B` for Down), not `ESC [ B`, because - curses enables the keypad. `Ctrl+N/C/F` are single bytes and reliable. + sequences are timing-sensitive there (not an app bug; the TUI no longer + uses them). Arrows arrive as application-mode sequences (`ESC O B` for + Down), not `ESC [ B`, because curses enables the keypad. `^O`/`^X`/`^C` + are single bytes and reliable. Always wrap the run in `scripts/tui-sandbox.sh -- ./build/bokftui ...`: it isolates `XDG_CONFIG_HOME`/`XDG_CACHE_HOME` so a test can never overwrite the real `~/.config/bokf/tui.conf` or `~/.cache/bokf/tui.log`. ## Known caveats -- Saving with Ctrl+Enter needs a terminal that speaks xterm - `modifyOtherKeys` level 2 or the Kitty keyboard protocol; gnome-terminal/VTE - sends neither, so the hints advertise `F9`, which works everywhere. +- The TUI enables no extended keyboard protocol (no xterm + `modifyOtherKeys`, no Kitty protocol) and uses no function keys; saving + and posting are context-menu actions, which work in every terminal and + in a browser terminal. - Developer tooling: the `g_cmd_[]` tables in `src/cmd_*.c` carry declarative argument schemas (`CMD_ARGS`); `describe` emits them and the diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md index 28b7f89..650f026 100644 --- a/docs/TUI-GUIDELINES.md +++ b/docs/TUI-GUIDELINES.md @@ -7,7 +7,9 @@ implementations. Inspired by Midnight Commander, htop, mutt and calcurse. The **Interaction model** section is the agreed target (settled 2026-09-22, `DECISIONS.md` #28). The **Kunder** pilot is implemented: drafts in `$XDG_CACHE_HOME/bokf/drafts.json`, `` marking, the explicit `Spara` -row, the `F2` action menu and draft deletion work there. Every other screen +row and draft deletion work there. The context menu (`→`/`^O`, below) and +the web-safe keys are implemented in every screen (2026-09-23, +`DECISIONS.md` #29). Every other screen still follows the sections below; the interaction model wins where they conflict as each screen moves over. @@ -38,16 +40,17 @@ saves a whole form, never deletes and never posts. | Class | Draft | Commit event | `Esc`/`q` | Examples | |---|---|---|---|---| -| **Register** | in memory + local draft file | explicit `Spara` (the save action row, `F9`) after validation | back; the draft stays | customers, employees, templates, momsregler, org data | -| **Document** | the form is the draft | explicit `Posta` (the save action row, `F9`); immutable once written | back; confirm only when dirty | verifikat, fakturor, löneruns, bokslut | +| **Register** | in memory + local draft file | explicit `Spara` (the save action row or the context menu) after validation | back; the draft stays | customers, employees, templates, momsregler, org data | +| **Document** | the form is the draft | explicit `Posta` (the save action row or the context menu); immutable once written | back; confirm only when dirty | verifikat, fakturor, löneruns, bokslut | | **Settings** | an edit buffer until `Spara` | explicit `Spara` | back | faktura/SMTP/serie-inställningar | - Every savable form ends with a visible commit action row (`Spara`, or - `Posta` for documents); `Enter` on the focused row commits (it is an item - with an action), `F9` is the accelerator. -- `Ctrl+Enter` is **dropped** as a commit key: gnome-terminal/VTE cannot - send it. The save action row and `F9` are the only commit gestures, which - makes every terminal behave the same. + `Posta` for documents), or — in a row table, where rows follow — offers it + first in the context menu (`^O`, `Enter`). `Enter` on the focused row + commits (it is an item with an action). +- There is no commit hotkey: `Ctrl+Enter` cannot be sent by gnome-terminal/ + VTE and `F9` is dropped with the other function keys (see "Keys and the + web"). The action row and the menu work in every terminal and browser. - Commit is blocked while the data is invalid: the first invalid field is focused and its error shown. The draft is untouched. A server error at commit keeps the draft as well; only a successful command removes it. @@ -64,8 +67,8 @@ saves a whole form, never deletes and never posts. and shows up in its list immediately. - Drafts are marked `` directly after the row number in lists (`3. Namn`) and in the editor's frame title (`Kund `). -- **Delete draft** is an action both on the list row (via `F2`) and inside - the editor, with a confirmation. It removes the memory and file draft and +- **Delete draft** is an action both on the list row (`d`, also in the + context menu) and inside the editor (context menu), with a confirmation. It removes the memory and file draft and never touches the backend. A successful commit removes the draft too. - Drafts are client-local and never synced; another client sees the last committed value. @@ -76,7 +79,7 @@ saves a whole form, never deletes and never posts. Bitwarden session file; decide whether such fields are excluded from drafts. -### Actions and the `F2` menu +### Actions and the context menu Screens declare actions, never keys: @@ -84,41 +87,79 @@ Screens declare actions, never keys: struct tui_action { const char *id; /* stable, e.g. "customer.archive" */ const char *label; /* Swedish UI text */ - int key; /* accelerator; 0 = menu only */ + int key; /* accelerator, or a virtual key (menu only) */ int enabled; /* 1 runnable, 0 dimmed with a reason, -1 heading */ const char *reason; /* why a disabled action is dim */ }; ``` -- One ordered action list per context drives everything: `F2` opens the - `Åtgärder` menu, the same list dispatches the accelerator keys and builds - the footer hint. A key can no longer exist outside the registry. -- The menu is sectioned: **Aktuell rad** (item actions), **Skärmen** (save, - delete draft, attach, …), **Globalt** (`F5` uppdatera, `Ctrl+R` ladda om, - …). Destructive actions are last and still ask for confirmation. -- `Enter` in the menu runs the highlighted action; disabled actions are dim - with their reason (as in `tui_form_action` today); `Esc` closes. The - actions that complex forms hide behind hotkeys today live here unchanged. -- The footer shows at most the two or three most important contextual - actions plus `F2 = fler`. Universal navigation keys (`Tab`, arrows, - `PgUp`/`PgDn`, `Home`/`End`) are not repeated there. -- The session's key decisions: letter accelerators stay, `F9` is the only - commit key (`Ctrl+Enter` is dropped) and `F2` only — no `§` binding (it is - not reliably encodable across terminals). +- **`→` opens the context menu** ("Åtgärder") wherever no caret uses the + arrow: lists, menus, pagers (report/detail views), forms and a row + table's header fields. **`^O` opens it everywhere**, also while typing in + a table cell, where `→` moves the caret. The menu is a box at the right + edge next to the selected row; each line shows the label and, right- + aligned, the key that does the same thing — the menu doubles as the + hotkey list. Up/down/Home/End move, `Enter` (or the action's own key) + runs, `Esc`/`←`/`q`/`^O` close. A disabled action is dim and `Enter` + shows its reason; a separator line divides the groups. +- The screen registers its actions with `tui_set_actions(acts, n)` right + before the widget call; the widget takes them at entry, so they never leak + into a widget opened from a hook. The menu lists the screen's actions + first, then the widget's own (Öppna, Ny, Gå till/sök, Uppdatera, + Tillbaka; in forms Ändra/Utför; in row tables Ändra fält/Rensa raden, + Validera, Spara, Avbryt), skipping own actions whose key the screen took + (`tui_ctx_merge`). +- **Choosing an action is the same as pressing its key**: the widget gets + the key as if it was typed, so the accelerator and the menu share one + code path in the screen's key hook. An action without a physical key uses + a virtual key: `TUI_KEY_SUBMIT` (spara/bokför/utfärda — was F9), + `TUI_KEY_REFRESH` (uppdatera/validera/förhandsvisa — was F5) or + `TUI_KEY_ACTION(n)` for the screen's own menu-only actions (mall, bifoga + in the voucher form, nästa fakturanummer). +- Right before the menu opens the key hook gets `TUI_KEY_MENU`, so a screen + can set `enabled`/`label`/`reason` for the current row in its own array + (the Kunder list shows "Radera utkast" for a draft row and "Arkivera" or + "Återaktivera" otherwise, all on `d`). +- Footers show `→ = åtgärder` (`^O` in row tables) and at most the few + letter accelerators that matter on the screen. Universal navigation keys + are not repeated there. + +### Keys and the web + +The TUI is meant to run in a browser terminal too (xterm.js/ttyd). A +browser keeps some keys for itself, so the TUI only uses keys every +terminal and browser delivers (checked by `make check`): + +- **No function keys.** F1/F3/F5/F6/F7/F10/F11/F12 are browser keys and F2, + F4, F9 need `Fn` on many laptops. Their old jobs are context-menu actions + (F2 → `→`, F5 → "Uppdatera"/"Validera"/"Förhandsvisa" with `r` in lists + and pagers, F9 → "Spara"/"Bokför"/"Utfärda", F4 → "Hämta mall…"). +- **No `Ctrl+N`, `Ctrl+T`, `Ctrl+W`, `Ctrl+Tab`**: Chrome never lets a page + have them. "Ny" is `n` in lists, menus and the voucher detail. +- **No `Ctrl+Enter`** and no extended keyboard protocol: the TUI no longer + enables xterm `modifyOtherKeys` or the Kitty protocol, so every key comes + in its legacy encoding. +- **Allowed**: arrows, `Enter`, `Esc`, `Tab`/`Shift-Tab`, PgUp/PgDn, + Home/End, letters and digits, and the control keys a page can take + (`^O` menu, `^X` clear row, `^A`/`^E`/`^U` in fields, `^C` quit, `^R` + reload — `^R` is a developer convenience and never the only way). +- Every action is reachable with arrows + `Enter` through the menu; a + letter accelerator is a shortcut, never the only path. ### Implementation status -1. Done: `struct tui_action`, `tui_action_menu()` and `tui_action_hint()` in - `clients/tui.[ch]`, unit-tested in `tests/test_tui.c`. -2. Partly done: the Kunder list builds its `F2` actions in a key hook and - appends `F2 = åtgärder` to the footer via `tui_list_hint_extra()`; other - lists have no actions yet, and `tui_rt` still uses its own key branches. +1. Done: `struct tui_action`, the context menu (`tui_action_menu_at`, + `tui_set_actions`, `tui_ctx_merge`, `tui_key_name`) and `→`/`^O` in all + widgets, unit-tested in `tests/test_tui.c`; pty scenario `context-menu`. +2. Done: every screen declares its actions; no F-keys, `^N` or `^Enter` + remain (`scripts/check-consistency.sh` fails on `KEY_F(`, `^N` and + `CTRL_ENTER` in `clients/`). 3. Done for Kunder: `clients/drafts.[ch]` (JSON store, atomic 0600 write, temporary ids, `` marking, delete action), unit-tested. -4. Done for Kunder: draft/`Spara` model with the `Spara` action row, - `F2` menu and draft deletion from both the list and the editor. The - other register screens and the settings forms (explicit `Spara`, no - per-field autosave) are next. +4. Done for Kunder: draft/`Spara` model with the `Spara` action row and + draft deletion from both the list and the editor. The other register + screens and the settings forms (explicit `Spara`, no per-field autosave) + are next. 5. Done for Kunder: pty scenarios `customer-draft` and `customer-draft-save` cover create, edit, ``, delete and save. @@ -143,8 +184,10 @@ there. | Key | Meaning | |---|---| -| `Ctrl+N` | Add: new verifikat (dashboard, voucher list/detail), new mall (Mallar), new fiscal year (year picker), open editor (IB), upload file (Underlag) | -| `F5` | Refresh the view | +| `→` | Open the context menu ("Åtgärder") for the selected row/view: every action with its key. Not in a table cell, where `→` moves the caret | +| `^O` | Open the context menu anywhere, also while typing in a table cell | +| `n` | Ny: new verifikat (dashboard, voucher list/detail), new mall, kund, faktura, anställd, lönekörning, regel, ledamot, new voucher from a bank transaction, upload (Underlag) | +| `r` | Uppdatera: reload the list or view | | `Esc` / `q` | Back one level. At the dashboard it does nothing — Esc never exits the app. In a row table (`tui_rt_run`) and while a field is being edited `q` is text; only `Esc` goes back there | | `Ctrl+C` | Quit the application (closes the session). The only key that exits | | `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) | @@ -153,21 +196,22 @@ there. | `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) | +| `a` | Add/upload: upload (Underlag), import a bank file (Bankavstämning), attach a file (voucher detail) | | `c` | Correct (voucher detail) | | `d` | Delete/arkivera the selected row (only where the action exists; asks for confirmation) | | `s` | Verifikat list: cycle the sort order — nummer stigande/fallande, datum stigande/fallande (ties by series and number). The title shows the current order; it is saved as `voucher_sort` in `tui.conf` | -| up/down (voucher detail) | Previous/next voucher in the list's current order; the title shows `(n av N)`. The detail scrolls with PgUp/PgDn/Home/End (`TUI_PAGER_NO_ARROWS`). A voucher posted from the detail (`^N`) is shown without stepping until the list is reopened | +| up/down (voucher detail) | Previous/next voucher in the list's current order; the title shows `(n av N)`. The detail scrolls with PgUp/PgDn/Home/End (`TUI_PAGER_NO_ARROWS`). A voucher posted from the detail (`n`) is shown without stepping until the list is reopened | | `f` | Voucher detail: list the voucher's underlag — Enter opens Granska (text in a pager, PDFs/images in the desktop viewer) or Ladda ned…, `d` removes the link (asks first). Underlag: Enter does the same | -| `u` / `b` | Faktura detail: `u` duplicates the invoice into a new draft (same rows, dates reset to today), `b` (unpaid invoices) prefills and posts the payment voucher, then marks the invoice paid | -| `Ctrl+F` | Attach a file via the file browser (voucher form and voucher detail) | +| `p` / `s` / `u` / `b` | Faktura detail: `p` shows the PDF, `s` sends it, `u` duplicates the invoice into a new draft (same rows, dates reset to today), `b` (unpaid invoices) prefills and posts the payment voucher, then marks the invoice paid | +| `u` | Bankavstämning: remove the highlighted transaction's match | | `k` | Underlag: link the highlighted attachment to a voucher picked from a list | | `Ctrl+X` | Clear the current row — only inside row editors (never "new") | -| `Ctrl+Enter` | Save/post the current form. Needs xterm `modifyOtherKeys` level 2 or the Kitty keyboard protocol (xterm, kitty, foot, WezTerm); gnome-terminal/VTE sends neither, so the hints advertise `F9`, which works everywhere. The interaction model drops `Ctrl+Enter` entirely — don't add it to new views | -Every screen prints its keys in the footer via `hints()`. If a key exists, the -footer shows it; if the footer shows it, the key works. Control keys are -written compactly as `^N`, `^A`, `^C`, `^R` to save width. +Saving, posting, validating, previewing, templates and attaching in forms +have no hotkey: they are context-menu actions (see "Keys and the web"). +Every key a screen handles is listed in its context menu; the footer shows +`→ = åtgärder` and the most important letters. Control keys are written +compactly as `^O`, `^X`, `^C`, `^R` to save width. ## Lists (`select_list`, `menu`) @@ -175,7 +219,7 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. - Verifikation ids are shown concatenated as `series+number` (`V-8`, `A8`), using the org's `series_voucher` (Bolaget → Verifikationsserier) for new vouchers. -- The last row may be an action (e.g. `+ Nytt verifikat (Ctrl+N)`); selecting it +- The last row may be an action (e.g. `+ Nytt verifikat (n)`); selecting it runs the action instead of opening a detail view. - Selection memory: lists remember the selected row by identity (voucher id), not index, across detail round-trips, refreshes and screen re-entry. @@ -200,7 +244,7 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. 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 + snaps forward and wraps — and on every return (`Esc`, Uppdatera, Spara, 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. @@ -234,7 +278,7 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. the dashes themselves: type `20260315` and the field shows `2026-03-15`. Backspace deletes a digit (with its separator), ←/→/Home/End move by digit, and the caret renders like in any other field. Validation (`util_parse_iso_date`) - happens on F5/`^Enter`. + happens on Validera/Spara. - Derived values (account names, balances) are dim and non-editable. - Row tables: always exactly one empty trailing row; entering data appends a new empty row; an empty row followed by another empty row collapses. @@ -259,7 +303,7 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. does nothing. The hardware cursor only shows in a table cell. `q` is text here — only `Esc` leaves the table. Moving to another cell (arrows, `Enter`, `Tab`) resets the caret to the end of the new cell. A focused - cell scrolls horizontally like a form field. `F5`/`Ctrl+Enter`/`Esc` + cell scrolls horizontally like a form field. `^O`/`Esc` work the same from both. - A form may carry an action list under its fields (`tui_form_run_actions`): fields first, then action rows, one focus ring (`Tab`/`Shift-Tab`/ @@ -276,17 +320,20 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. resultat` voucher that `bokslut.post` creates; a closed year counts as posted) and the actions Årsredovisning (K2), Inkomstdeklaration (INK2/SRU), Bokslutsplan (torrkörning) and Bokför bokslut — the last - dimmed with a reason when the year is closed or already posted. `F5` - shows the plan, `Ctrl+Enter` posts it. -- Validation: `F5` validates without writing and reports exactly what is wrong + dimmed with a reason when the year is closed or already posted. The + context menu's "Visa bokslutsplan" shows the plan, "Bokför planen…" posts + it. +- Validation: the "Validera" action (`TUI_KEY_REFRESH`) validates without + writing and reports exactly what is wrong (field, row number). Server `dry_run` is used where available. -- Saving: `Ctrl+Enter` writes, shows a confirmation message, and returns to +- Saving: the "Spara"/"Bokför" action (`TUI_KEY_SUBMIT`) writes, shows a + confirmation message, and returns to the previous view. `Esc` cancels without saving. - Screen-specific keys (template picker, attach, link, download) are handled in a key hook, never with a local loop. The hook runs for keys the widget itself does not handle and returns `TUI_HOOK_*`: `STAY` (consumed, redraw), - `BACK`, `REFRESH` or `SUBMIT` (the widget returns as on `Esc`/`F5`/ - `Ctrl+Enter`). Hooks exist on `tui_form_run_hook`, `tui_rt_set_key`, + `BACK`, `REFRESH` or `SUBMIT` (the widget returns as on `Esc` or the + Uppdatera/Spara actions). Hooks exist on `tui_form_run_hook`, `tui_rt_set_key`, `tui_pager_hook` and `tui_select_list_hook`. - File browser (`file_browser`): starts in the directory where the last attachment was picked (remembered in `tui.conf`), falling back to `$HOME` @@ -404,8 +451,9 @@ only place that touches ncurses. Rules: one-off questions use the prompts (`tui_prompt_into`, `tui_date_prompt_into`, `tui_amount_prompt_into`, `tui_choice_prompt`). All of them share one field editor. -5. Support `F5` if the data can change elsewhere. +5. Support refresh (`r` / "Uppdatera") if the data can change elsewhere. 6. Update `PROTOCOL.md` §8 and this file if you add a new key or interaction. 7. Declare the screen's actions in one `tui_action` list (once the - interaction model is implemented); dispatch, the `F2` menu and the footer + interaction model is implemented); dispatch, the context menu and the + footer hint all read that list, so a key cannot exist without a visible action. diff --git a/scripts/check-consistency.sh b/scripts/check-consistency.sh index 3ca9b0c..d8054e2 100755 --- a/scripts/check-consistency.sh +++ b/scripts/check-consistency.sh @@ -6,7 +6,10 @@ # "Command", and vice versa; # 2. every error code passed to fail()/failf()/add_error() in src/*.c is # documented in PROTOCOL.md section 5.3, and vice versa (documented but -# unused codes are warnings only, some are reserved). +# unused codes are warnings only, some are reserved); +# 3. the TUI uses no key a browser terminal cannot deliver: no function +# keys (KEY_F), no Ctrl+N and no Ctrl+Enter (docs/TUI-GUIDELINES.md +# "Keys and the web"). # # Run from the repo root: sh scripts/check-consistency.sh # Exits 0 when there is no drift, 1 when drift is found, 2 on parse errors. @@ -19,6 +22,8 @@ # PROTOCOL_MD protocol document, default $REPO_ROOT/docs/PROTOCOL.md # SRC_GLOB shell glob of sources scanned for error codes, # default $REPO_ROOT/src/*.c +# CLIENTS_GLOB shell glob of TUI sources scanned for keys, +# default $REPO_ROOT/clients/*.c # # Commands deliberately kept out of the PROTOCOL.md tables. Prefer documenting # the command in a table over adding it here. Keep sorted, space separated. @@ -188,6 +193,24 @@ else echo "(none)" fi +section "keys: F-keys, Ctrl+N or Ctrl+Enter used in the TUI" +CLIENTS_GLOB=${CLIENTS_GLOB:-"$REPO_ROOT/clients/*.c"} +: > "$tmp/bad_keys" +for f in $CLIENTS_GLOB; do + [ -f "$f" ] || continue + grep -nE 'KEY_F\(|TUI_KEY_CTRL_N|KEY_CTRL_N|CTRL_ENTER' "$f" | + sed "s|^|$f:|" >> "$tmp/bad_keys" || true +done +if [ -s "$tmp/bad_keys" ]; then + while IFS= read -r line; do + echo "key: not web-safe: $line" + problems=$((problems + 1)) + done < "$tmp/bad_keys" + status=1 +else + echo "(none)" +fi + section "warnings (not failures)" if [ -s "$tmp/code_unused" ]; then while IFS= read -r code; do diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py index b6aaada..bce00eb 100755 --- a/scripts/tui-golden.py +++ b/scripts/tui-golden.py @@ -57,12 +57,8 @@ KEYS = { "pgup": "\x1b[5~", "pgdn": "\x1b[6~", "ctrlc": "\x03", - "ctrln": "\x0e", - "ctrlenter": "\x1b[27;5;13~", - "ctrlenter-kitty": "\x1b[13;5u", - "f5": "\x1b[15~", - "f9": "\x1b[20~", - "f2": "\x1bOQ", + "ctrlo": "\x0f", # context menu, also inside a table cell + "f5": "\x1b[15~", # only to prove that F-keys do nothing "backspace": "\x7f", "tab": "\t", } @@ -120,7 +116,7 @@ SCENARIOS = [ }, { "keys": ["enter"], - "expect": ["piltangenter/PgUp/PgDn rullar", "kvitto"], + "expect": ["upp/ned/PgUp/PgDn rullar", "kvitto"], }, { "keys": ["esc"], @@ -140,7 +136,7 @@ SCENARIOS = [ }, { "keys": ["enter"], - "expect": ["piltangenter/PgUp/PgDn rullar", "kvitto"], + "expect": ["upp/ned/PgUp/PgDn rullar", "kvitto"], }, { "keys": ["esc"], @@ -184,7 +180,7 @@ SCENARIOS = [ "expect": ["Bankavstämning", "GOLDEN INSÄTTNING", "CDON"], "steps": [ { - "keys": ["ctrln"], + "keys": ["n"], "expect": ["Nytt verifikat", "GOLDEN INSÄTTNING", "123,00", "1930"], }, @@ -224,14 +220,19 @@ SCENARIOS = [ "expect": ["Bankavstämning", "CDON", "1 omatchade"], "steps": [ { - "keys": ["ctrln"], + "keys": ["n"], "expect": ["Nytt verifikat", "CDON", "24,09", "1930"], }, { "keys": ["\t\t\t\t\t\t\t", "3001", "\t", "24,09"], }, { - "keys": ["ctrlenter"], + "keys": ["ctrlo"], + "expect": ["Åtgärder", "Bokför", "Validera (torrkörning)", + "Hämta mall…"], + }, + { + "keys": ["enter"], "expect": ["Bokfört"], }, { @@ -243,7 +244,7 @@ SCENARIOS = [ "expect": ["→ A2", "0 omatchade"], }, { - "keys": ["ctrln"], + "keys": ["n"], "expect": ["Transaktionen är redan matchad."], }, { @@ -268,7 +269,7 @@ SCENARIOS = [ "expect": ["Kunder", "Testkund AB"], "steps": [ { - "keys": ["ctrln"], + "keys": ["n"], "expect": ["Ny kund", ""], }, { @@ -284,11 +285,11 @@ SCENARIOS = [ "expect": [" Utkastkund"], }, { - "keys": ["f2"], - "expect": ["Åtgärder", "Öppna", "Radera utkast"], + "keys": ["right"], + "expect": ["Åtgärder", "Radera utkast", "Öppna", "Enter"], }, { - "keys": ["down", "enter"], + "keys": ["enter"], "expect": ["Radera utkastet?"], }, { @@ -303,7 +304,7 @@ SCENARIOS = [ "expect": ["Kunder", "Testkund AB"], "steps": [ { - "keys": ["ctrln"], + "keys": ["n"], "expect": ["Ny kund", ""], }, { @@ -311,7 +312,11 @@ SCENARIOS = [ "expect": ["Sparad kund", ""], }, { - "keys": ["f9"], + "keys": ["right"], + "expect": ["Åtgärder", "Spara", "Validera"], + }, + { + "keys": ["enter"], "expect": ["Sparat."], }, { @@ -325,7 +330,7 @@ SCENARIOS = [ "screen": "invoices", "steps": [ { - "keys": ["ctrln"], + "keys": ["n"], "expect": ["Ny faktura", "Kund"], }, ], @@ -363,7 +368,7 @@ SCENARIOS = [ "Betalning faktura"], }, { - "keys": ["f9"], + "keys": ["ctrlo", "enter"], "expect": ["Bokfört"], }, { @@ -382,11 +387,11 @@ SCENARIOS = [ "expect": ["Anställda", "Testanställd", "Ny anställd"], }, { - "name": "employee-new-f9", + "name": "employee-new-save", "screen": "employees", "steps": [ { - "keys": ["ctrln"], + "keys": ["n"], "expect": ["Ny anställd", "Personnummer"], }, { @@ -398,7 +403,7 @@ SCENARIOS = [ "expect": ["19900101-1234"], }, { - "keys": ["f9"], + "keys": ["right", "enter"], "expect": ["Anställd skapad."], }, { @@ -407,32 +412,6 @@ SCENARIOS = [ }, ], }, - { - "name": "employee-new-kitty", - "screen": "employees", - "steps": [ - { - "keys": ["ctrln"], - "expect": ["Ny anställd", "Personnummer"], - }, - { - "keys": ["enter", "Kitty Person", "enter"], - "expect": ["Kitty Person"], - }, - { - "keys": ["down", "enter", "19900202-5678", "enter"], - "expect": ["19900202-5678"], - }, - { - "keys": ["ctrlenter-kitty"], - "expect": ["Anställd skapad."], - }, - { - "keys": ["enter"], - "expect": ["Anställda", "Kitty Person"], - }, - ], - }, { "name": "company", "screen": "company", @@ -515,11 +494,15 @@ SCENARIOS = [ "screen": "payroll", "steps": [ { - "keys": ["ctrln"], + "keys": ["n"], "expect": ["Ny lönekörning", "Period", "Bokför körning"], }, { - "keys": ["f5"], + "keys": ["right"], + "expect": ["Åtgärder", "Förhandsvisa", "Bokför körning…"], + }, + { + "keys": ["enter"], "expect": ["Testanställd", "30 000,00", "Avgifter"], }, { @@ -527,7 +510,7 @@ SCENARIOS = [ "expect": ["Ny lönekörning", "Bokför körning"], }, { - "keys": ["ctrlenter"], + "keys": ["right", "down", "enter"], "expect": ["Bokför lönekörningen"], }, { @@ -645,6 +628,42 @@ SCENARIOS = [ }, ], }, + { + # → opens the context menu with the screen's actions first and the + # key of each action; choosing one is the same as pressing its key. + # F-keys do nothing (browsers own them). + "name": "context-menu", + "screen": "vouchers", + "expect": ["sorterade på nummer, stigande"], + "steps": [ + { + "keys": ["right"], + "expect": ["Åtgärder", "Nytt verifikat", "Byt sortering", + "Gå till rad / sök", "Tillbaka"], + }, + { + "keys": ["esc"], + "expect": ["Enter = öppna → = åtgärder"], + }, + { + "keys": ["right", "down", "enter"], + "expect": ["sorterade på nummer, fallande"], + }, + { + "keys": ["home", "enter", "right"], + "expect": ["Rätta (ändringsverifikat)…", "Bifoga fil…", + "Nästa verifikat"], + }, + { + "keys": ["left"], + "expect": ["n = nytt c = rätta a = bifoga"], + }, + { + "keys": ["esc", "f5", "s", "s", "s"], + "expect": ["sorterade på nummer, stigande"], + }, + ], + }, { # Backspace in a freshly opened field edits the value; the next # key must not replace everything. @@ -652,7 +671,7 @@ SCENARIOS = [ "screen": "customers", "steps": [ { - "keys": ["ctrln"], + "keys": ["n"], "expect": ["Ny kund", ""], }, { @@ -672,7 +691,7 @@ SCENARIOS = [ "screen": "vouchers", "steps": [ { - "keys": ["ctrln"], + "keys": ["n"], "expect": ["Nytt verifikat", "Datum", "Konto"], }, { @@ -1355,10 +1374,9 @@ def main(argv): report_failure(name, missing, "", before) failures += 1 continue - if (b"\x1b[>4;2m" not in app.raw or - b"\x1b[>1u" not in app.raw): - report_failure(name, ["keyboard protocol enable " - "sequence"], "", before) + if (b"\x1b[>4;2m" in app.raw or b"\x1b[>1u" in app.raw): + report_failure(name, ["no extended keyboard protocol " + "(legacy keys only)"], "", before) failures += 1 continue current = before diff --git a/tests/test_tui.c b/tests/test_tui.c index d01a546..c0c5f0d 100644 --- a/tests/test_tui.c +++ b/tests/test_tui.c @@ -329,9 +329,13 @@ static void test_nav(void) nav_init(&v, 0); CHECK(tui_nav_key(&v, KEY_DOWN, 0, 0, 0, 0) == TUI_NAV_NONE); - CHECK(tui_nav_key(&v, KEY_F(5), 0, 0, 0, 0) == -2); - CHECK(tui_nav_key(&v, TUI_KEY_CTRL_N, 1, 0, 0, 0) == -4); - CHECK(tui_nav_key(&v, TUI_KEY_CTRL_N, 0, 0, 0, 0) == TUI_NAV_NONE); + CHECK(tui_nav_key(&v, 'r', 0, 0, 0, 0) == -2); + CHECK(tui_nav_key(&v, TUI_KEY_REFRESH, 0, 0, 0, 0) == -2); + CHECK(tui_nav_key(&v, 'n', 1, 0, 0, 0) == -4); + CHECK(tui_nav_key(&v, 'n', 0, 0, 0, 0) == TUI_NAV_NONE); + /* no F-keys and no ^N: browsers own them */ + CHECK(tui_nav_key(&v, KEY_F(5), 0, 0, 0, 0) == TUI_NAV_NONE); + CHECK(tui_nav_key(&v, TUI_KEY_CTRL_N, 1, 0, 0, 0) == TUI_NAV_NONE); CHECK(tui_nav_key(&v, 'q', 0, 0, 0, 0) == -1); CHECK(tui_nav_key(&v, 27, 0, 0, 0, 0) == -1); @@ -350,9 +354,13 @@ static void test_nav(void) CHECK(tui_nav_key(&v, '9', 0, 0, 0, 0) == TUI_NAV_NONE); /* out of range */ CHECK(tui_nav_key(&v, '\n', 0, 0, 0, 0) == v.sel); - CHECK(tui_nav_key(&v, KEY_F(5), 0, 0, 0, 0) == -2); - CHECK(tui_nav_key(&v, TUI_KEY_CTRL_N, 1, 0, 0, 0) == -4); - CHECK(tui_nav_key(&v, TUI_KEY_CTRL_N, 0, 0, 0, 0) == TUI_NAV_NONE); + CHECK(tui_nav_key(&v, 'r', 0, 0, 0, 0) == -2); + CHECK(tui_nav_key(&v, TUI_KEY_REFRESH, 0, 0, 0, 0) == -2); + CHECK(tui_nav_key(&v, 'n', 1, 0, 0, 0) == -4); + CHECK(tui_nav_key(&v, 'n', 0, 0, 0, 0) == TUI_NAV_NONE); + /* no F-keys and no ^N: browsers own them */ + CHECK(tui_nav_key(&v, KEY_F(5), 0, 0, 0, 0) == TUI_NAV_NONE); + CHECK(tui_nav_key(&v, TUI_KEY_CTRL_N, 1, 0, 0, 0) == TUI_NAV_NONE); CHECK(tui_nav_key(&v, 'd', 0, 1, 0, 0) == -6); CHECK(tui_nav_key(&v, 'd', 0, 0, 0, 0) == TUI_NAV_NONE); CHECK(tui_nav_key(&v, 1, 0, 0, 1, 0) == -7); /* ^A */ @@ -590,9 +598,11 @@ static void test_form_nav(void) CHECK(tui_form_next(0, 3, KEY_UP) == TUI_NAV_NONE); CHECK(tui_form_next(0, 3, KEY_DOWN) == 1); CHECK(tui_form_next(2, 3, KEY_DOWN) == TUI_NAV_NONE); - CHECK(tui_form_next(1, 3, KEY_F(5)) == TUI_FORM_REFRESH); - CHECK(tui_form_next(1, 3, TUI_KEY_CTRL_ENTER) == TUI_FORM_SUBMIT); - CHECK(tui_form_next(1, 3, KEY_F(9)) == TUI_FORM_SUBMIT); + CHECK(tui_form_next(1, 3, TUI_KEY_REFRESH) == TUI_FORM_REFRESH); + CHECK(tui_form_next(1, 3, TUI_KEY_SUBMIT) == TUI_FORM_SUBMIT); + CHECK(tui_form_next(1, 3, KEY_F(5)) == TUI_NAV_NONE); + CHECK(tui_form_next(1, 3, KEY_F(9)) == TUI_NAV_NONE); + CHECK(tui_form_next(1, 3, TUI_KEY_CTRL_ENTER) == TUI_NAV_NONE); CHECK(tui_form_next(1, 3, 27) == TUI_FORM_BACK); CHECK(tui_form_next(1, 3, 'q') == TUI_FORM_BACK); CHECK(tui_form_next(1, 3, 'x') == TUI_NAV_NONE); @@ -662,8 +672,9 @@ static void test_form_actions(void) CHECK(tui_form_act_page(4, nf, ACTS, 6, 1) == 8); CHECK(tui_form_act_page(4, nf, ACTS, 6, -1) == 0); CHECK(tui_form_act_page(0, 0, NULL, 0, 1) == -1); - CHECK(tui_form_act_key(0, nf, ACTS, 6, KEY_F(5)) == TUI_FORM_REFRESH); - CHECK(tui_form_act_key(5, nf, ACTS, 6, TUI_KEY_CTRL_ENTER) == + CHECK(tui_form_act_key(0, nf, ACTS, 6, TUI_KEY_REFRESH) == + TUI_FORM_REFRESH); + CHECK(tui_form_act_key(5, nf, ACTS, 6, TUI_KEY_SUBMIT) == TUI_FORM_SUBMIT); CHECK(tui_form_act_key(5, nf, ACTS, 6, 27) == TUI_FORM_BACK); CHECK(tui_form_act_key(5, nf, ACTS, 6, 'x') == TUI_NAV_NONE); @@ -936,10 +947,11 @@ static void test_actions(void) { char buf[256]; struct tui_action acts[] = { - { "save", "Spara", KEY_F(9), 1, NULL }, + { "save", "Spara", TUI_KEY_SUBMIT, 1, NULL }, { "del", "Radera utkast", 0, 0, "inget utkast" }, { "sec", "Sektion", 0, -1, NULL }, - { "arch", "Arkivera", KEY_F(2), 1, NULL }, + { "arch", "Arkivera", 'd', 1, NULL }, + { "att", "Bifoga fil", 'a', 1, NULL }, }; tui_action_label(&acts[0], buf, sizeof buf); @@ -947,18 +959,57 @@ static void test_actions(void) tui_action_label(&acts[1], buf, sizeof buf); CHECK(strcmp(buf, "Radera utkast (inget utkast)") == 0); - tui_action_hint(acts, 4, 1, buf, sizeof buf); - CHECK(strstr(buf, "F9 = Spara") != NULL); - CHECK(strstr(buf, "F2 = fler") != NULL); - CHECK(strstr(buf, "Arkivera") == NULL); - - tui_action_hint(acts, 4, 0, buf, sizeof buf); - CHECK(strstr(buf, "F9 = Spara") != NULL); - CHECK(strstr(buf, "F2 = Arkivera F2 = fler") != NULL); - CHECK(strstr(buf, "Radera utkast") == NULL); - + /* virtual keys are menu-only: not in the footer */ + tui_action_hint(acts, 5, 1, buf, sizeof buf); + CHECK(strcmp(buf, "d = Arkivera → = åtgärder") == 0); + tui_action_hint(acts, 5, 0, buf, sizeof buf); + CHECK(strcmp(buf, "d = Arkivera a = Bifoga fil → = åtgärder") == 0); tui_action_hint(NULL, 0, 1, buf, sizeof buf); - CHECK(strcmp(buf, "F2 = fler") == 0); + CHECK(strcmp(buf, "→ = åtgärder") == 0); + + /* key names: web-safe keys only, virtual keys have none */ + tui_key_name('\n', buf, sizeof buf); + CHECK(strcmp(buf, "Enter") == 0); + tui_key_name(27, buf, sizeof buf); + CHECK(strcmp(buf, "Esc") == 0); + tui_key_name(TUI_KEY_CTRL_X, buf, sizeof buf); + CHECK(strcmp(buf, "^X") == 0); + tui_key_name('n', buf, sizeof buf); + CHECK(strcmp(buf, "n") == 0); + tui_key_name(KEY_UP, buf, sizeof buf); + CHECK(strcmp(buf, "↑") == 0); + tui_key_name(TUI_KEY_SUBMIT, buf, sizeof buf); + CHECK(buf[0] == '\0'); + tui_key_name(TUI_KEY_ACTION(3), buf, sizeof buf); + CHECK(buf[0] == '\0'); + + /* the context menu: screen actions, a separator, then the widget's own + actions whose key the screen did not take */ + struct tui_action scr[] = { + { "v.post", "Bokför", TUI_KEY_SUBMIT, 1, NULL }, + { "v.tpl", "Hämta mall…", TUI_KEY_ACTION(0), 1, NULL }, + }; + struct tui_action own[] = { + { "rt.refresh", "Validera", TUI_KEY_REFRESH, 1, NULL }, + { "rt.submit", "Spara", TUI_KEY_SUBMIT, 1, NULL }, + { "rt.back", "Avbryt", 27, 1, NULL }, + }; + struct tui_action out[8]; + int n = tui_ctx_merge(scr, 2, own, 3, out, 8); + CHECK(n == 5); + CHECK(strcmp(out[0].label, "Bokför") == 0); + CHECK(strcmp(out[1].label, "Hämta mall…") == 0); + CHECK(out[2].enabled == -1 && out[2].label[0] == '\0'); + CHECK(strcmp(out[3].label, "Validera") == 0); + CHECK(strcmp(out[4].label, "Avbryt") == 0); /* Spara: key taken */ + /* no screen actions: no separator */ + n = tui_ctx_merge(NULL, 0, own, 3, out, 8); + CHECK(n == 3 && strcmp(out[0].label, "Validera") == 0); + /* capacity is respected */ + n = tui_ctx_merge(scr, 2, own, 3, out, 3); /* no room: no separator */ + CHECK(n == 3 && strcmp(out[2].label, "Validera") == 0); + n = tui_ctx_merge(scr, 2, own, 3, out, 4); + CHECK(n == 4 && strcmp(out[3].label, "Validera") == 0); } static void test_drafts(void) -- cgit v1.3