summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-23 10:58:17 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-23 10:58:17 +0200
commit71a702f375750829c634b552217c9925d549828b (patch)
tree2b963da1d0110ed43920722ba4193ee9c79f2521
parent8ccb6d58d8e2f1c65dfd7ab5f0eb7d93f1f19def (diff)
downloadbokf-71a702f375750829c634b552217c9925d549828b.tar.gz
bokf-71a702f375750829c634b552217c9925d549828b.zip
tui: context menu on → / ^O; no F-keys, ^N or ^Enter
→ (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 <noreply@anthropic.com>
-rw-r--r--clients/bokftui.c12
-rw-r--r--clients/screens_attachments.c5
-rw-r--r--clients/screens_bank.c9
-rw-r--r--clients/screens_bokslut.c13
-rw-r--r--clients/screens_dashboard.c9
-rw-r--r--clients/screens_ib.c19
-rw-r--r--clients/screens_invoices.c125
-rw-r--r--clients/screens_payroll.c27
-rw-r--r--clients/screens_rules.c10
-rw-r--r--clients/screens_settings.c6
-rw-r--r--clients/screens_templates.c15
-rw-r--r--clients/screens_vouchers.c49
-rw-r--r--clients/tui.c522
-rw-r--r--clients/tui.h39
-rw-r--r--clients/ui.h4
-rw-r--r--docs/DECISIONS.md14
-rw-r--r--docs/PROTOCOL.md63
-rw-r--r--docs/STATE.md30
-rw-r--r--docs/TUI-GUIDELINES.md164
-rwxr-xr-xscripts/check-consistency.sh25
-rwxr-xr-xscripts/tui-golden.py134
-rw-r--r--tests/test_tui.c97
22 files changed, 967 insertions, 424 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[<u");
- 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)
? " <UTKAST>"
: "");
+ 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;
+ 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);
}
- 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: ");
+ 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_<group>.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
- <datum>`). Ctrl+N opens the form, Enter the detail. The form has the
+ <datum>`). `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 <nr> till <e-post>?". 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_<domain>[]` 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`, `<UTKAST>` 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 `<UTKAST>` directly after the row number in lists
(`3. <UTKAST> Namn`) and in the editor's frame title (`Kund <UTKAST>`).
-- **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, `<UTKAST>` 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, `<UTKAST>`, 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", "<UTKAST>"],
},
{
@@ -284,11 +285,11 @@ SCENARIOS = [
"expect": ["<UTKAST> 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", "<UTKAST>"],
},
{
@@ -311,7 +312,11 @@ SCENARIOS = [
"expect": ["Sparad kund", "<UTKAST>"],
},
{
- "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."],
},
{
@@ -408,32 +413,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",
"expect": ["Bolaget", "Företagsuppgifter", "Fakturauppgifter",
@@ -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"],
},
{
@@ -646,13 +629,49 @@ 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.
"name": "form-edit-backspace",
"screen": "customers",
"steps": [
{
- "keys": ["ctrln"],
+ "keys": ["n"],
"expect": ["Ny kund", "<UTKAST>"],
},
{
@@ -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);
+ /* 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, "→ = åtgärder") == 0);
- 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);
+ /* 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');
- tui_action_hint(NULL, 0, 1, buf, sizeof buf);
- CHECK(strcmp(buf, "F2 = fler") == 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)