aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnders Betts <anders.betts@gmail.com>2026-09-22 12:43:52 +0200
committerAnders Betts <anders.betts@gmail.com>2026-09-22 12:43:52 +0200
commit07f5b140922d13519ad7a0d1dad67a9b403be9f1 (patch)
treeb6dde2ed6adb90c6423a5e585b693b908637e5ee
parent760cb25bd220718e37d8b95016f30cdf8ef6acdc (diff)
downloadbokf-07f5b140922d13519ad7a0d1dad67a9b403be9f1.tar.gz
bokf-07f5b140922d13519ad7a0d1dad67a9b403be9f1.zip
tui: kundutkast, <UTKAST>, explicit Spara och F2-åtgärder
-rw-r--r--Makefile5
-rw-r--r--clients/drafts.c249
-rw-r--r--clients/drafts.h49
-rw-r--r--clients/screens_invoices.c465
-rw-r--r--clients/tui.c189
-rw-r--r--clients/tui.h23
-rw-r--r--docs/DECISIONS.md5
-rw-r--r--docs/STATE.md27
-rw-r--r--docs/TUI-GUIDELINES.md35
-rwxr-xr-xscripts/tui-golden.py59
-rw-r--r--tests/test_tui.c95
11 files changed, 1077 insertions, 124 deletions
diff --git a/Makefile b/Makefile
index 6b246a3..d27a270 100644
--- a/Makefile
+++ b/Makefile
@@ -58,7 +58,8 @@ TUI_SCREEN_OBJ = $(patsubst %.c,$(BUILD)/%.o,$(TUI_SCREENS))
TUI_SCREEN_DEP = $(TUI_SCREEN_OBJ:.o=.d)
$(BUILD)/bokftui: $(BUILD)/clients/bokftui.o $(BUILD)/clients/ui.o \
- $(TUI_SCREEN_OBJ) $(BUILD)/clients/tui.o \
+ $(BUILD)/clients/drafts.o $(TUI_SCREEN_OBJ) \
+ $(BUILD)/clients/tui.o \
$(BUILD)/clients/client.o \
$(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/src/formula.o \
$(BUILD)/vendor/yyjson.o $(BUILD)/vendor/sha256.o
@@ -69,6 +70,7 @@ $(BUILD)/test_core: $(BUILD)/tests/test_core.o $(BUILD)/clients/client.o \
$(CC) $(CFLAGS) -o $@ $^ -lm $(SSL_LIBS)
$(BUILD)/test_tui: $(BUILD)/tests/test_tui.o $(BUILD)/clients/tui.o \
+ $(BUILD)/clients/drafts.o \
$(BUILD)/src/util.o $(BUILD)/src/log.o $(BUILD)/vendor/yyjson.o \
$(BUILD)/vendor/sha256.o
$(CC) $(CFLAGS) -o $@ $^ -lm -lncursesw
@@ -156,6 +158,7 @@ $(BUILD)/tests/%.o: tests/%.c
$(BUILD)/clients/bokfctl.d $(BUILD)/clients/bokftui.d \
$(BUILD)/clients/ui.d $(TUI_SCREEN_DEP) \
$(BUILD)/clients/tui.d $(BUILD)/clients/client.d \
+ $(BUILD)/clients/drafts.d \
$(BUILD)/tests/test_core.d $(BUILD)/tests/test_tui.d \
$(BUILD)/tests/pdf_check.d $(BUILD)/tests/invoice_check.d \
$(BUILD)/tests/smtp_check.d \
diff --git a/clients/drafts.c b/clients/drafts.c
new file mode 100644
index 0000000..f71e85c
--- /dev/null
+++ b/clients/drafts.c
@@ -0,0 +1,249 @@
+#include "drafts.h"
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "util.h"
+#include "yyjson.h"
+
+static void mkdirs_for(const char *path)
+{
+ char tmp[640];
+
+ if (snprintf(tmp, sizeof tmp, "%s", path) >= (int)sizeof tmp)
+ return;
+ for (char *p = tmp + 1; *p; p++) {
+ if (*p != '/')
+ continue;
+ *p = '\0';
+ mkdir(tmp, 0700);
+ *p = '/';
+ }
+}
+
+static void cache_path(char *buf, size_t n)
+{
+ const char *cache = getenv("XDG_CACHE_HOME");
+
+ if (cache && *cache) {
+ snprintf(buf, n, "%s/bokf/drafts.json", cache);
+ return;
+ }
+ const char *home = getenv("HOME");
+ snprintf(buf, n, "%s/.cache/bokf/drafts.json",
+ home && *home ? home : ".");
+}
+
+static void rec_clear(struct draft *r)
+{
+ free(r->entity);
+ free(r->id);
+ free(r->fields);
+}
+
+void drafts_free(struct drafts *d)
+{
+ if (!d)
+ return;
+ for (size_t i = 0; i < d->n; i++)
+ rec_clear(&d->v[i]);
+ free(d->v);
+ d->v = NULL;
+ d->n = d->cap = 0;
+}
+
+static struct draft *rec_find(const struct drafts *d, int64_t org,
+ const char *entity, const char *id)
+{
+ for (size_t i = 0; i < d->n; i++) {
+ struct draft *r = &d->v[i];
+
+ if (r->org == org && strcmp(r->entity, entity) == 0 &&
+ strcmp(r->id, id) == 0)
+ return r;
+ }
+ return NULL;
+}
+
+static void rec_put(struct drafts *d, int64_t org, const char *entity,
+ const char *id, const char *fields)
+{
+ struct draft *r = rec_find(d, org, entity, id);
+
+ if (!r) {
+ if (d->n == d->cap) {
+ d->cap = d->cap ? d->cap * 2 : 8;
+ d->v = xrealloc(d->v, d->cap * sizeof *d->v);
+ }
+ r = &d->v[d->n++];
+ memset(r, 0, sizeof *r);
+ r->org = org;
+ r->entity = xstrdup(entity);
+ r->id = xstrdup(id);
+ r->fields = xstrdup(fields && *fields ? fields : "{}");
+ return;
+ }
+ free(r->fields);
+ r->fields = xstrdup(fields && *fields ? fields : "{}");
+}
+
+static void drafts_write(const struct drafts *d)
+{
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *root = yyjson_mut_obj(doc);
+
+ yyjson_mut_doc_set_root(doc, root);
+ yyjson_mut_obj_add_int(doc, root, "version", 1);
+ yyjson_mut_val *arr = yyjson_mut_arr(doc);
+ for (size_t i = 0; i < d->n; i++) {
+ const struct draft *r = &d->v[i];
+ yyjson_mut_val *o = yyjson_mut_arr_add_obj(doc, arr);
+ yyjson_mut_obj_add_int(doc, o, "org", r->org);
+ yyjson_mut_obj_add_strcpy(doc, o, "entity", r->entity);
+ yyjson_mut_obj_add_strcpy(doc, o, "id", r->id);
+ yyjson_mut_val *f = yyjson_mut_rawcpy(doc, r->fields);
+ yyjson_mut_obj_add_val(doc, o, "fields", f ? f : yyjson_mut_obj(doc));
+ }
+ yyjson_mut_obj_add_val(doc, root, "drafts", arr);
+ char *json = yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, NULL);
+ yyjson_mut_doc_free(doc);
+ if (!json)
+ return;
+
+ mkdirs_for(d->path);
+ char tmp[640];
+ if (snprintf(tmp, sizeof tmp, "%s.tmp", d->path) >= (int)sizeof tmp) {
+ free(json);
+ return;
+ }
+ int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600);
+ if (fd >= 0) {
+ FILE *f = fdopen(fd, "w");
+ if (f) {
+ fputs(json, f);
+ fclose(f);
+ rename(tmp, d->path);
+ } else {
+ close(fd);
+ }
+ }
+ free(json);
+}
+
+static void drafts_load(struct drafts *d)
+{
+ FILE *f = fopen(d->path, "rb");
+ if (!f)
+ return;
+ struct buf b;
+ char tmp[8192];
+ size_t k;
+ buf_init(&b);
+ while ((k = fread(tmp, 1, sizeof tmp, f)) > 0)
+ buf_append(&b, tmp, k);
+ fclose(f);
+ yyjson_doc *doc = yyjson_read((const char *)b.p, b.len, 0);
+ buf_free(&b);
+ if (!doc)
+ return;
+ yyjson_val *arr = yyjson_obj_get(yyjson_doc_get_root(doc), "drafts");
+ if (yyjson_is_arr(arr)) {
+ size_t idx, max;
+ yyjson_val *it;
+ yyjson_arr_foreach(arr, idx, max, it) {
+ yyjson_val *org = yyjson_obj_get(it, "org");
+ yyjson_val *entity = yyjson_obj_get(it, "entity");
+ yyjson_val *id = yyjson_obj_get(it, "id");
+ yyjson_val *fields = yyjson_obj_get(it, "fields");
+ if (!yyjson_is_int(org) || !yyjson_is_str(entity) ||
+ !yyjson_is_str(id) || !yyjson_is_obj(fields))
+ continue;
+ char *ft = yyjson_val_write(fields, 0, NULL);
+ rec_put(d, yyjson_get_sint(org), yyjson_get_str(entity),
+ yyjson_get_str(id), ft ? ft : "{}");
+ free(ft);
+ }
+ }
+ yyjson_doc_free(doc);
+}
+
+void drafts_init_path(struct drafts *d, const char *path)
+{
+ memset(d, 0, sizeof *d);
+ snprintf(d->path, sizeof d->path, "%s", path);
+ drafts_load(d);
+}
+
+void drafts_init(struct drafts *d)
+{
+ char path[600];
+ cache_path(path, sizeof path);
+ drafts_init_path(d, path);
+}
+
+const char *drafts_get(const struct drafts *d, int64_t org,
+ const char *entity, const char *id)
+{
+ const struct draft *r = rec_find(d, org, entity, id);
+ return r ? r->fields : NULL;
+}
+
+int drafts_have(const struct drafts *d, int64_t org, const char *entity,
+ const char *id)
+{
+ return rec_find(d, org, entity, id) != NULL;
+}
+
+void drafts_put(struct drafts *d, int64_t org, const char *entity,
+ const char *id, const char *fields)
+{
+ rec_put(d, org, entity, id, fields);
+ drafts_write(d);
+}
+
+void drafts_del(struct drafts *d, int64_t org, const char *entity,
+ const char *id)
+{
+ struct draft *r = rec_find(d, org, entity, id);
+
+ if (!r)
+ return;
+ size_t i = (size_t)(r - d->v);
+ rec_clear(r);
+ memmove(&d->v[i], &d->v[i + 1], (d->n - i - 1) * sizeof *d->v);
+ d->n--;
+ drafts_write(d);
+}
+
+size_t drafts_count(const struct drafts *d, int64_t org, const char *entity)
+{
+ size_t n = 0;
+
+ for (size_t i = 0; i < d->n; i++)
+ if (d->v[i].org == org && strcmp(d->v[i].entity, entity) == 0)
+ n++;
+ return n;
+}
+
+void drafts_foreach(const struct drafts *d, int64_t org, const char *entity,
+ void (*cb)(void *ud, const char *id, const char *fields),
+ void *ud)
+{
+ for (size_t i = 0; i < d->n; i++)
+ if (d->v[i].org == org && strcmp(d->v[i].entity, entity) == 0)
+ cb(ud, d->v[i].id, d->v[i].fields);
+}
+
+char *drafts_new_id(void)
+{
+ static unsigned seq;
+ char buf[64];
+
+ snprintf(buf, sizeof buf, "ny-%lld-%u", (long long)time(NULL), ++seq);
+ return xstrdup(buf);
+}
diff --git a/clients/drafts.h b/clients/drafts.h
new file mode 100644
index 0000000..2cc67db
--- /dev/null
+++ b/clients/drafts.h
@@ -0,0 +1,49 @@
+#ifndef BOKF_DRAFTS_H
+#define BOKF_DRAFTS_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+/* Client-local edit drafts: every non-committed edit is mirrored to
+ $XDG_CACHE_HOME/bokf/drafts.json (mode 0600, atomic replace) so a reload,
+ a crash or Ctrl+C never loses work. A record is keyed by
+ (org, entity, id); a new entity uses a temporary id from
+ drafts_new_id(). See docs/TUI-GUIDELINES.md "Interaction model". */
+
+struct draft {
+ int64_t org;
+ char *entity;
+ char *id;
+ char *fields; /* JSON object text */
+};
+
+struct drafts {
+ struct draft *v;
+ size_t n, cap;
+ char path[600];
+};
+
+void drafts_init(struct drafts *d);
+/* Explicit path, for tests. */
+void drafts_init_path(struct drafts *d, const char *path);
+void drafts_free(struct drafts *d);
+
+/* The draft's fields JSON, or NULL; the store owns the string. */
+const char *drafts_get(const struct drafts *d, int64_t org,
+ const char *entity, const char *id);
+int drafts_have(const struct drafts *d, int64_t org, const char *entity,
+ const char *id);
+/* Inserts or replaces the draft and persists the file. */
+void drafts_put(struct drafts *d, int64_t org, const char *entity,
+ const char *id, const char *fields);
+void drafts_del(struct drafts *d, int64_t org, const char *entity,
+ const char *id);
+size_t drafts_count(const struct drafts *d, int64_t org, const char *entity);
+void drafts_foreach(const struct drafts *d, int64_t org, const char *entity,
+ void (*cb)(void *ud, const char *id, const char *fields),
+ void *ud);
+
+/* "ny-<time>-<seq>", unique within the process. Caller frees. */
+char *drafts_new_id(void);
+
+#endif
diff --git a/clients/screens_invoices.c b/clients/screens_invoices.c
index 352c184..712a8a8 100644
--- a/clients/screens_invoices.c
+++ b/clients/screens_invoices.c
@@ -14,6 +14,7 @@
#include <unistd.h>
#include "client.h"
+#include "drafts.h"
#include "tui.h"
#include "formula.h"
#include "util.h"
@@ -219,12 +220,128 @@ static int customer_save(struct app *a, int64_t id, const char *name,
return ok;
}
-static void customer_form(struct app *a, int64_t id)
+/* Drafts for the customer register: new entities exist only as drafts
+ until Spara writes them (docs/TUI-GUIDELINES.md "Interaction model"). */
+
+static struct drafts g_drafts;
+static int g_drafts_ready;
+
+static struct drafts *kdrafts(void)
+{
+ if (!g_drafts_ready) {
+ drafts_init(&g_drafts);
+ g_drafts_ready = 1;
+ }
+ return &g_drafts;
+}
+
+struct cform_ctx {
+ struct app *a;
+ int64_t id;
+ const char *draft_id;
+ int deleted;
+ char name[256];
+ char address[512];
+ char postal[32];
+ char city[128];
+ char vat[64];
+ char email[256];
+ char your_ref[128];
+ char payment[24];
+ char notes[2048];
+};
+
+static char *customer_fields_json(const struct cform_ctx *c)
+{
+ yyjson_mut_doc *d = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *o = yyjson_mut_obj(d);
+
+ yyjson_mut_doc_set_root(d, o);
+ yyjson_mut_obj_add_strcpy(d, o, "name", c->name);
+ yyjson_mut_obj_add_strcpy(d, o, "address", c->address);
+ yyjson_mut_obj_add_strcpy(d, o, "postal_code", c->postal);
+ yyjson_mut_obj_add_strcpy(d, o, "city", c->city);
+ yyjson_mut_obj_add_strcpy(d, o, "vat_nr", c->vat);
+ yyjson_mut_obj_add_strcpy(d, o, "email", c->email);
+ yyjson_mut_obj_add_strcpy(d, o, "your_ref", c->your_ref);
+ yyjson_mut_obj_add_int(d, o, "payment_days",
+ strtoll(c->payment, NULL, 10));
+ yyjson_mut_obj_add_strcpy(d, o, "notes", c->notes);
+ char *s = yyjson_mut_write(d, 0, NULL);
+ yyjson_mut_doc_free(d);
+ return s;
+}
+
+static void customer_load_json(const char *json, struct cform_ctx *c)
{
- char name[256] = "", address[512] = "", postal[32] = "", city[128] = "";
- char vat[64] = "", email[256] = "", your_ref[128] = "";
- char payment[16] = "30", notes[2048] = "";
- if (id > 0) {
+ yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
+
+ if (!doc)
+ return;
+ yyjson_val *o = yyjson_doc_get_root(doc);
+ const struct {
+ const char *key;
+ char *dst;
+ size_t cap;
+ } fields[] = {
+ { "name", c->name, sizeof c->name },
+ { "address", c->address, sizeof c->address },
+ { "postal_code", c->postal, sizeof c->postal },
+ { "city", c->city, sizeof c->city },
+ { "vat_nr", c->vat, sizeof c->vat },
+ { "email", c->email, sizeof c->email },
+ { "your_ref", c->your_ref, sizeof c->your_ref },
+ { "notes", c->notes, sizeof c->notes },
+ };
+ for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++)
+ snprintf(fields[i].dst, fields[i].cap, "%s",
+ vstr(o, fields[i].key));
+ int64_t days = vint(o, "payment_days");
+ if (days > 0)
+ snprintf(c->payment, sizeof c->payment, "%lld", (long long)days);
+ yyjson_doc_free(doc);
+}
+
+static int cform_key(void *ud, int ch)
+{
+ struct cform_ctx *c = ud;
+
+ if (ch != KEY_F(2))
+ 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;
+ }
+ return TUI_HOOK_STAY;
+}
+
+static void customer_form(struct app *a, int64_t id, const char *draft_id)
+{
+ struct cform_ctx c;
+
+ memset(&c, 0, sizeof c);
+ c.a = a;
+ c.id = id;
+ c.draft_id = draft_id;
+ snprintf(c.payment, sizeof c.payment, "30");
+
+ const char *fields = drafts_get(kdrafts(), a->org, "customer", draft_id);
+ if (fields) {
+ customer_load_json(fields, &c);
+ } else if (id > 0) {
char args[64];
snprintf(args, sizeof args, "{\"id\":%lld}", (long long)id);
char *resp =
@@ -234,43 +351,46 @@ static void customer_form(struct app *a, int64_t id)
free(resp);
return;
}
- struct {
+ const struct {
const char *key;
char *dst;
size_t cap;
- } fields[] = {
- { "name", name, sizeof name },
- { "address", address, sizeof address },
- { "postal_code", postal, sizeof postal },
- { "city", city, sizeof city },
- { "vat_nr", vat, sizeof vat },
- { "email", email, sizeof email },
- { "your_ref", your_ref, sizeof your_ref },
- { "notes", notes, sizeof notes },
+ } f[] = {
+ { "name", c.name, sizeof c.name },
+ { "address", c.address, sizeof c.address },
+ { "postal_code", c.postal, sizeof c.postal },
+ { "city", c.city, sizeof c.city },
+ { "vat_nr", c.vat, sizeof c.vat },
+ { "email", c.email, sizeof c.email },
+ { "your_ref", c.your_ref, sizeof c.your_ref },
+ { "notes", c.notes, sizeof c.notes },
};
- for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) {
- char path[64], *v;
- snprintf(path, sizeof path, "result.%s", fields[i].key);
+ for (size_t i = 0; i < sizeof f / sizeof f[0]; i++) {
+ char path[64];
+ char *v;
+ snprintf(path, sizeof path, "result.%s", f[i].key);
v = jstr_dup(resp, path);
if (v) {
- snprintf(fields[i].dst, fields[i].cap, "%s", v);
+ snprintf(f[i].dst, f[i].cap, "%s", v);
free(v);
}
}
- snprintf(payment, sizeof payment, "%lld",
+ snprintf(c.payment, sizeof c.payment, "%lld",
(long long)jint_val(resp, "result.payment_days", 30));
free(resp);
}
+
const char *labels[] = { "Namn", "Adress",
"Postnummer", "Ort",
"Momsreg.nr", "E-post",
"Er referens", "Betalningsdagar",
"Anteckningar" };
- char *vals[] = { name, address, postal, city, vat,
- email, your_ref, payment, notes };
- size_t caps[] = { sizeof name, sizeof address, sizeof postal,
- sizeof city, sizeof vat, sizeof email,
- sizeof your_ref, sizeof payment, sizeof notes };
+ char *vals[] = { c.name, c.address, c.postal, c.city,
+ c.vat, c.email, c.your_ref, c.payment,
+ c.notes };
+ size_t caps[] = { sizeof c.name, sizeof c.address, sizeof c.postal,
+ sizeof c.city, sizeof c.vat, sizeof c.email,
+ sizeof c.your_ref, sizeof c.payment, sizeof c.notes };
struct tui_form_field ff[10];
memset(ff, 0, sizeof ff);
for (int i = 0; i < 9; i++) {
@@ -282,22 +402,47 @@ static void customer_form(struct app *a, int64_t id)
ff[9].label = "";
ff[9].value = "Spara";
ff[9].kind = TUI_F_ACTION;
+
static int sel = 0;
+ tui_form_hint_extra("F2 = åtgärder");
for (;;) {
- int r = tui_form_run(id > 0 ? "Kund" : "Ny kund", ff, 10, 1, &sel);
+ char title[64];
+ snprintf(title, sizeof title, "%s%s", id > 0 ? "Kund" : "Ny kund",
+ drafts_have(kdrafts(), a->org, "customer", draft_id)
+ ? " <UTKAST>"
+ : "");
+ int r = tui_form_run_hook(title, ff, 10, 1, cform_key, &c, &sel);
+ if (c.deleted)
+ break;
+ if (r >= 0) {
+ char *json = customer_fields_json(&c);
+ if (json) {
+ drafts_put(kdrafts(), a->org, "customer", draft_id, json);
+ free(json);
+ }
+ }
if (r == TUI_FORM_BACK)
- return;
+ break;
if (r == TUI_FORM_REFRESH) {
- customer_save(a, id, name, address, postal, city, vat, email,
- your_ref, payment, notes, 1);
+ customer_save(a, id, c.name, c.address, c.postal, c.city, c.vat,
+ c.email, c.your_ref, c.payment, c.notes, 1);
continue;
}
if (r == TUI_FORM_SUBMIT || r == 9) {
- if (customer_save(a, id, name, address, postal, city, vat, email,
- your_ref, payment, notes, 0))
- return;
+ if (customer_save(a, id, c.name, c.address, c.postal, c.city,
+ c.vat, c.email, c.your_ref, c.payment, c.notes,
+ 0)) {
+ drafts_del(kdrafts(), a->org, "customer", draft_id);
+ break;
+ }
+ char *json = customer_fields_json(&c);
+ if (json) {
+ drafts_put(kdrafts(), a->org, "customer", draft_id, json);
+ free(json);
+ }
}
}
+ tui_form_hint_extra(NULL);
}
static void customer_archive_toggle(struct app *a, int64_t id, const char *name,
@@ -319,59 +464,170 @@ static void customer_archive_toggle(struct app *a, int64_t id, const char *name,
free(r);
}
+struct clist {
+ struct app *a;
+ int *cur;
+ char **items;
+ char **names;
+ char **dkeys;
+ int64_t *ids;
+ unsigned char *active;
+ size_t nserver;
+ size_t nrows; /* server rows + temp drafts; the add row is at nrows */
+};
+
+static void clist_clear(struct clist *l)
+{
+ if (l->items) {
+ for (size_t i = 0; i <= l->nrows; i++) {
+ free(l->items[i]);
+ free(l->names[i]);
+ free(l->dkeys[i]);
+ }
+ }
+ free(l->items);
+ free(l->names);
+ free(l->dkeys);
+ free(l->ids);
+ free(l->active);
+ l->items = NULL;
+ l->names = NULL;
+ l->dkeys = NULL;
+ l->ids = NULL;
+ l->active = NULL;
+ l->nserver = l->nrows = 0;
+}
+
+static int clist_is_server_id(const struct clist *l, const char *id)
+{
+ long long v = strtoll(id, NULL, 10);
+
+ if (v <= 0)
+ return 0;
+ for (size_t i = 0; i < l->nserver; i++)
+ if (l->ids[i] == v)
+ return 1;
+ return 0;
+}
+
+static void clist_collect_temp(void *ud, const char *id, const char *fields)
+{
+ struct clist *l = ud;
+ char nbuf[256], line[768];
+ const char *name = "(namnlös)";
+ yyjson_doc *doc;
+
+ if (clist_is_server_id(l, id))
+ return;
+ doc = yyjson_read(fields, strlen(fields), 0);
+ if (doc) {
+ yyjson_val *o = yyjson_doc_get_root(doc);
+ const char *n = vstr(o, "name");
+ if (*n)
+ name = n;
+ size_t i = l->nrows;
+ l->names[i] = xstrdup(name);
+ snprintf(nbuf, sizeof nbuf, "<UTKAST> %s", name);
+ snprintf(line, sizeof line, "%s", nbuf);
+ l->items[i] = xstrdup(line);
+ l->dkeys[i] = xstrdup(id);
+ l->ids[i] = 0;
+ l->active[i] = 1;
+ l->nrows++;
+ yyjson_doc_free(doc);
+ return;
+ }
+ size_t i = l->nrows;
+ l->names[i] = xstrdup(name);
+ snprintf(line, sizeof line, "<UTKAST> %s", name);
+ l->items[i] = xstrdup(line);
+ l->dkeys[i] = xstrdup(id);
+ l->ids[i] = 0;
+ l->active[i] = 1;
+ l->nrows++;
+}
+
+static int clist_key(void *ud, int ch)
+{
+ struct clist *l = ud;
+ static int afocus;
+
+ if (ch != KEY_F(2) || !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;
+ }
+ 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;
+}
+
void customers_screen(struct app *a)
{
- char **items = NULL;
- char **names = NULL;
- int64_t *ids = NULL;
- unsigned char *active = NULL;
- size_t n = 0;
+ struct clist l;
+ memset(&l, 0, sizeof l);
+ l.a = a;
+ int cur = 0;
+ l.cur = &cur;
int fetch = 1;
for (;;) {
if (g_quit) {
- for (size_t i = 0; i < n; i++) {
- free(items[i]);
- free(names[i]);
- }
- free(items);
- free(names);
- free(ids);
- free(active);
+ clist_clear(&l);
return;
}
if (fetch) {
- for (size_t i = 0; i < n; i++) {
- free(items[i]);
- free(names[i]);
- }
- free(items);
- free(names);
- free(ids);
- free(active);
- items = NULL;
- names = NULL;
- ids = NULL;
- active = NULL;
- n = 0;
+ clist_clear(&l);
fetch = 0;
char *resp = client_rpc(&a->conn, "customer.list", a->session,
a->org, "{}");
if (!resp || !client_ok(resp)) {
show_error("Kunder", resp);
free(resp);
+ clist_clear(&l);
return;
}
- n = jarr_size(resp, "result.items");
- items = xcalloc(n + 1, sizeof(char *));
- names = xcalloc(n + 1, sizeof(char *));
- ids = xcalloc(n ? n : 1, sizeof(int64_t));
- active = xcalloc(n + 1, 1);
+ size_t n = jarr_size(resp, "result.items");
+ size_t ndraft = drafts_count(kdrafts(), a->org, "customer");
+ size_t cap = n + ndraft + 2;
+ l.items = xcalloc(cap, sizeof(char *));
+ l.names = xcalloc(cap, sizeof(char *));
+ l.dkeys = xcalloc(cap, sizeof(char *));
+ l.ids = xcalloc(cap, sizeof(int64_t));
+ l.active = xcalloc(cap, 1);
+ l.nserver = n;
+ l.nrows = n;
for (size_t i = 0; i < n; i++) {
- char path[64], line[768];
+ char path[64], line[768], idbuf[32];
snprintf(path, sizeof path, "result.items.%zu.id", i);
- ids[i] = jint_val(resp, path, 0);
+ l.ids[i] = jint_val(resp, path, 0);
snprintf(path, sizeof path, "result.items.%zu.active", i);
- active[i] = (unsigned char)jbool_val(resp, path, 1);
+ l.active[i] = (unsigned char)jbool_val(resp, path, 1);
snprintf(path, sizeof path, "result.items.%zu.name", i);
char *name = jstr_dup(resp, path);
snprintf(path, sizeof path, "result.items.%zu.postal_code", i);
@@ -390,60 +646,81 @@ void customers_screen(struct app *a)
tui_pad_field(pbuf, sizeof pbuf, 30);
snprintf(vbuf, sizeof vbuf, "%s", vat ? vat : "");
tui_pad_field(vbuf, sizeof vbuf, 18);
- snprintf(line, sizeof line, "%s %s %s %3lld dagar %s", nbuf,
- pbuf, vbuf, (long long)days,
- active[i] ? "" : "(arkiverad)");
- items[i] = xstrdup(line);
- names[i] = xstrdup(name ? name : "");
+ snprintf(idbuf, sizeof idbuf, "%lld", (long long)l.ids[i]);
+ int isdraft =
+ drafts_have(kdrafts(), a->org, "customer", idbuf);
+ if (isdraft)
+ l.dkeys[i] = xstrdup(idbuf);
+ snprintf(line, sizeof line, "%s%s %s %s %3lld dagar %s",
+ isdraft ? "<UTKAST> " : "", nbuf, pbuf, vbuf,
+ (long long)days,
+ l.active[i] ? "" : "(arkiverad)");
+ l.items[i] = xstrdup(line);
+ l.names[i] = xstrdup(name ? name : "");
free(name);
free(postal);
free(city);
free(vat);
}
free(resp);
- items[n] = xstrdup("+ Ny kund (^N)");
+ drafts_foreach(kdrafts(), a->org, "customer", clist_collect_temp,
+ &l);
+ l.items[l.nrows] = xstrdup("+ Ny kund (^N)");
+ l.names[l.nrows] = xstrdup("");
+ l.ids[l.nrows] = 0;
+ l.active[l.nrows] = 1;
}
int start = 0;
if (a->customer_sel) {
- for (size_t i = 0; i < n; i++)
- if (ids[i] == a->customer_sel) {
+ for (size_t i = 0; i < l.nserver; i++)
+ if (l.ids[i] == a->customer_sel) {
start = (int)i;
break;
}
}
- int cur = start;
- int sel = tui_select_list("Kunder", items, (int)n + 1, start, 1, &cur,
- 1, "arkivera/återaktivera", 0);
- if (cur >= 0 && (size_t)cur < n)
- a->customer_sel = ids[cur];
+ cur = start;
+ tui_list_hint_extra("F2 = åtgärder");
+ 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) {
- for (size_t i = 0; i < n; i++) {
- free(items[i]);
- free(names[i]);
- }
- free(items);
- free(names);
- free(ids);
- free(active);
+ clist_clear(&l);
return;
}
if (sel == -2) {
fetch = 1;
continue;
}
- if (sel == -6 && cur >= 0 && (size_t)cur < n) {
- customer_archive_toggle(a, ids[cur], names[cur],
- active[cur] != 0);
+ if (sel == -6 && cur >= 0 && (size_t)cur < l.nrows) {
+ if (l.ids[cur] == 0) {
+ tui_message("Kund", "Utkast raderas med F2.");
+ } else {
+ customer_archive_toggle(a, l.ids[cur], l.names[cur],
+ l.active[cur] != 0);
+ }
fetch = 1;
continue;
}
- if (sel == -4 || (sel >= 0 && (size_t)sel == n)) {
- customer_form(a, 0);
+ if (sel == -4 || (cur >= 0 && (size_t)cur == l.nrows)) {
+ char *tid = drafts_new_id();
+ drafts_put(kdrafts(), a->org, "customer", tid, "{}");
+ customer_form(a, 0, tid);
+ free(tid);
fetch = 1;
continue;
}
- if (sel >= 0 && (size_t)sel < n) {
- customer_form(a, ids[sel]);
+ if (sel >= 0 && (size_t)sel < l.nrows) {
+ char idbuf[32];
+ const char *dk = l.dkeys[sel];
+ if (!dk) {
+ snprintf(idbuf, sizeof idbuf, "%lld", (long long)l.ids[sel]);
+ dk = idbuf;
+ }
+ customer_form(a, l.ids[sel], dk);
fetch = 1;
continue;
}
diff --git a/clients/tui.c b/clients/tui.c
index db5599d..13da593 100644
--- a/clients/tui.c
+++ b/clients/tui.c
@@ -17,6 +17,12 @@ static int (*g_in)(void) = getch;
static void (*g_frame)(const char *title);
static void (*g_hints)(const char *hints);
static void (*g_quit)(void);
+static const char *g_list_hint_extra;
+
+void tui_list_hint_extra(const char *extra)
+{
+ g_list_hint_extra = extra;
+}
void tui_set_input(int (*fn)(void))
{
@@ -1082,6 +1088,10 @@ int tui_select_list_hook(const char *title, char **items, int n, int start,
allow_refresh ? " F5 = uppdatera" : "",
allow_new ? " ^N = ny" : "", dbuf,
allow_toggle ? " ^A = öppna/stäng" : "");
+ 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();
int r = tui_nav_key(&v, ch, allow_new, archive_action != NULL,
@@ -1190,6 +1200,7 @@ int tui_menu(const char *title, const char *const *items, int n,
/* ------------------------------------------------------------------ */
static const char *g_form_hint;
+static const char *g_form_hint_extra;
static char g_status[512];
static char g_right[160];
@@ -1198,6 +1209,11 @@ void tui_form_hint(const char *hint)
g_form_hint = hint;
}
+void tui_form_hint_extra(const char *extra)
+{
+ g_form_hint_extra = extra;
+}
+
void tui_set_status(const char *status, const char *right)
{
snprintf(g_status, sizeof g_status, "%s", status ? status : "");
@@ -1531,6 +1547,174 @@ 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)
+{
+ 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)
+ snprintf(buf, n, "Esc");
+ else if (key == '\n' || key == '\r' || key == KEY_ENTER)
+ snprintf(buf, n, "Enter");
+ else if (key > 0 && key < 27)
+ snprintf(buf, n, "^%c", 'A' + key - 1);
+ else
+ snprintf(buf, n, "?");
+}
+
+void tui_action_label(const struct tui_action *a, char *buf, size_t n)
+{
+ const char *label = a && a->label ? a->label : "";
+
+ if (a && a->enabled == 0) {
+ const char *reason =
+ a->reason && *a->reason ? a->reason : "otillgänglig";
+ snprintf(buf, n, "%s (%s)", label, reason);
+ } else {
+ snprintf(buf, n, "%s", label);
+ }
+}
+
+void tui_action_hint(const struct tui_action *acts, int n, int max, char *buf,
+ size_t cap)
+{
+ size_t o = 0;
+ int shown = 0;
+
+ if (!buf || cap == 0)
+ return;
+ buf[0] = '\0';
+ for (int i = 0; i < n && (max <= 0 || shown < max); i++) {
+ char key[16], item[200];
+ size_t len;
+
+ if (acts[i].enabled != 1 || !acts[i].key)
+ 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);
+ if (o + len + 1 > cap)
+ break;
+ memcpy(buf + o, item, len + 1);
+ o += len;
+ shown++;
+ }
+ const char *more = "F2 = fler";
+ 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)
+{
+ struct tui_list_nav v;
+ unsigned char *ok;
+ char **items;
+ int ret = -1;
+
+ 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 *));
+ for (int i = 0; i < n; i++) {
+ char line[192];
+
+ tui_action_label(&acts[i], line, sizeof line);
+ items[i] = xstrdup(line);
+ ok[i] = acts[i].enabled != -1;
+ }
+ v.selectable = ok;
+ v.sel = focus && *focus >= 0 && *focus < n ? *focus : 0;
+ if (!ok[v.sel])
+ v.sel = nav_snap(&v, v.sel, 1);
+ v.numw = tui_num_width(n);
+ if (v.numw < 2)
+ v.numw = 2;
+ v.view = LINES - 4;
+ snprintf(v.gotolabel, sizeof v.gotolabel, "Gå till nummer: ");
+ 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));
+ 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));
+ }
+ if (v.goto_active) {
+ move(LINES - 2, 2);
+ tui_style(TUI_ACCENT);
+ printw("%s%s", v.gotolabel, v.gotobuf);
+ tui_style_reset();
+ clrtoeol();
+ } else {
+ move(LINES - 2, 2);
+ clrtoeol();
+ }
+ if (g_hints)
+ g_hints("upp/ned, 1-9 = hoppa, g = gå till, PgUp/PgDn, Home/End,"
+ " Enter = utför Esc/q = avbryt");
+ refresh();
+ int ch = in_key();
+ if (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.");
+ continue;
+ }
+ ret = v.sel;
+ break;
+ }
+ 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);
+ return ret;
+}
+
/* Action rows sit under the fields, one blank line below them. */
static void form_draw_actions(const struct tui_form_action *acts, int na,
int nf, int sel)
@@ -1628,6 +1812,11 @@ static int form_run(const char *title, struct tui_form_field *f, int nf,
: "upp/ned/Home/End F5 = uppdatera"
" Esc/q = tillbaka ^C = avsluta"
" (endast behöriga kan ändra)");
+ char hbuf[640];
+ if (g_form_hint_extra && *g_form_hint_extra &&
+ snprintf(hbuf, sizeof hbuf, "%s %s", h, g_form_hint_extra) <
+ (int)sizeof hbuf)
+ h = hbuf;
tui_hints(h);
g_form_hint = NULL;
refresh();
diff --git a/clients/tui.h b/clients/tui.h
index 6df27fb..1785a32 100644
--- a/clients/tui.h
+++ b/clients/tui.h
@@ -191,10 +191,33 @@ int tui_form_next(int sel, int n, int ch);
int tui_form_run(const char *title, struct tui_form_field *f, int n,
int can_edit, int *focus);
void tui_form_hint(const char *hint); /* overrides the footer for the next run */
+/* Extra text appended to the form/list footer until changed (NULL clears). */
+void tui_form_hint_extra(const char *extra);
+void tui_list_hint_extra(const char *extra);
int tui_form_run_hook(const char *title, struct tui_form_field *f, int n,
int can_edit, int (*key)(void *ud, int ch), void *ud,
int *focus);
+/* --- action registry (docs/TUI-GUIDELINES.md "Interaction model") --- */
+struct tui_action {
+ const char *id; /* stable, e.g. "customer.archive" */
+ const char *label; /* Swedish UI text */
+ int key; /* accelerator; 0 = menu only */
+ int enabled; /* 1 runnable, 0 dim + reason, -1 heading */
+ const char *reason; /* why a disabled action is dim */
+};
+
+/* "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. */
+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. */
+int tui_action_menu(const char *title, const struct tui_action *acts, int n,
+ int *focus);
+
/* --- action list in a form (tui_form_run_actions) --- */
/* enabled: 1 = selectable and runs; 0 = dimmed but selectable (Enter shows
disabled_reason in a message); -1 = non-selectable heading/status row,
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index 1522842..a0c613b 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -256,7 +256,10 @@ kept verbatim from the STATE.md they were pruned from (2026-09-21).
error keeps the draft. Screens declare actions in one ordered
`struct tui_action` list that drives the `F2` menu, the accelerator keys
and the footer hints; `F2` only, no `§`; `Ctrl+Enter` is dropped as a
- commit key (`F9` and the save action row remain). Pilots on Kunder.
+ commit key (`F9` and the save action row remain). **Kunder pilot
+ implemented 2026-09-22** (`clients/drafts.[ch]`, `<UTKAST>`, the `Spara`
+ row, the `F2` menu, delete from list and editor); the other screens
+ follow.
## Completed work formerly listed under "Pending decisions"
diff --git a/docs/STATE.md b/docs/STATE.md
index 3077b12..b034a9a 100644
--- a/docs/STATE.md
+++ b/docs/STATE.md
@@ -54,13 +54,17 @@ unit tests and the docs consistency check.
employer name there too (it previously used the Comfortaa wordmark). The
invoice's `FAKTURA` outline is unchanged; the `MAKANDRA AB` outlines in
`src/wordmark.h` are now unused at runtime.
-- **TUI interaction model (2026-09-22, design, not implemented)**: the UX
- session settled two focus modes, three entity lifecycles, explicit
- `Spara` in every savable form and `<UTKAST>` drafts persisted to
- `$XDG_CACHE_HOME/bokf/drafts.json` (deletable from both the list and the
- editor), plus one `tui_action` registry per screen behind `F2` (no `§`).
- Spec in `TUI-GUIDELINES.md` "Interaction model"; decisions in
- `DECISIONS.md` #28. Rollout is backlog item 17.
+- **TUI interaction model (2026-09-22)**: the UX session settled two focus
+ modes, three entity lifecycles, explicit `Spara` in every savable form and
+ `<UTKAST>` drafts persisted to `$XDG_CACHE_HOME/bokf/drafts.json`
+ (deletable from both the list and the editor), plus one `tui_action`
+ registry per screen behind `F2` (no `§`). The **Kunder pilot is
+ implemented** (`clients/drafts.[ch]`, `tui_action_menu()`,
+ `tui_action_hint()`, `<UTKAST>`, `Spara` row, F2 menu, delete draft) with
+ unit tests and the pty scenarios `customer-draft`/`customer-draft-save`;
+ the other screens are unchanged. Spec in `TUI-GUIDELINES.md` "Interaction
+ model"; decisions in `DECISIONS.md` #28. Rollout continues in backlog
+ item 17.
- **Mail configuration**: Makandra AB (org 2) has **no** `smtp_*` settings
in bokf, so `invoice.send` there is `SMTP_NOT_CONFIGURED` (fine if
invoices are sent elsewhere — set them up when wanted). Mock AB (org 1)
@@ -130,10 +134,11 @@ None open. Completed items that used to be listed here are archived in
(`tests/core_<domain>.c`) so `--only` stops cascading; pilot with one
domain.
17. Interaction-model rollout (spec: `TUI-GUIDELINES.md` "Interaction
- model", decisions #28): `struct tui_action` + `F2` menu in the widget
- layer, `clients/drafts.[ch]`, the Kunder pilot, then the other
- registers; settings get `Spara`; pty scenarios for draft
- create/save/delete/reload.
+ model", decisions #28): **Kunder done** 2026-09-22 (widget layer
+ `tui_action`/`F2`, `clients/drafts.[ch]`, drafts/`<UTKAST>`/`Spara`,
+ pty scenarios). Remaining: the other register screens, explicit
+ `Spara` in the settings forms (replacing per-field autosave), and
+ `tui_rt` action menus.
Original entries for the struck items are in `docs/DECISIONS.md`.
diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md
index fbd3b75..1c201aa 100644
--- a/docs/TUI-GUIDELINES.md
+++ b/docs/TUI-GUIDELINES.md
@@ -5,9 +5,10 @@ copy the behaviour of the voucher list / voucher form; they are the reference
implementations. Inspired by Midnight Commander, htop, mutt and calcurse.
The **Interaction model** section is the agreed target (settled 2026-09-22,
-`DECISIONS.md` #28) and has not been implemented yet. The sections after it
-describe today's widget behaviour and stay authoritative until the widget
-layer and the screens are migrated; the interaction model wins where they
+`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
+still follows the sections below; the interaction model wins where they
conflict as each screen moves over.
## Interaction model (target, 2026-09-22)
@@ -105,21 +106,21 @@ struct tui_action {
commit key (`Ctrl+Enter` is dropped) and `F2` only — no `§` binding (it is
not reliably encodable across terminals).
-### Deltas to implement
+### Implementation status
-1. `struct tui_action` + `tui_action_menu()` in `clients/tui.[ch]`
- (`tui_form_action` is generalized); the pure ordering/dimming/hint logic
- is unit-tested in `tests/test_tui.c`.
-2. Action lists on `tui_select_list` and `tui_rt`, replacing the per-screen
- key branches; hints and dispatch read the same list.
-3. `clients/drafts.[ch]`: JSON store, atomic write, dirty tracking,
- temporary ids, `<UTKAST>` marking and the delete action.
-4. Registers get the draft/`Spara` model first — pilot on **Kunder** — then
- the other register screens; settings drop their per-field autosave and
- get a `Spara` row.
-5. pty scenarios: a new empty entity is a visible `<UTKAST>`; fill + `Spara`
- commits and clears it; delete from the list and from the editor; a draft
- survives `Ctrl+R`.
+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.
+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.
+5. Done for Kunder: pty scenarios `customer-draft` and
+ `customer-draft-save` cover create, edit, `<UTKAST>`, delete and save.
## Session start
diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py
index b339f2d..4fee4df 100755
--- a/scripts/tui-golden.py
+++ b/scripts/tui-golden.py
@@ -60,6 +60,7 @@ KEYS = {
"ctrlenter-kitty": "\x1b[13;5u",
"f5": "\x1b[15~",
"f9": "\x1b[20~",
+ "f2": "\x1bOQ",
}
# {org_name} {org_nr} {fy_label} {fy_start} {fy_end} are substituted at run
@@ -258,6 +259,64 @@ SCENARIOS = [
"expect": ["Kunder", "Testkund AB"],
},
{
+ "name": "customer-draft",
+ "screen": "customers",
+ "expect": ["Kunder", "Testkund AB"],
+ "steps": [
+ {
+ "keys": ["ctrln"],
+ "expect": ["Ny kund", "<UTKAST>"],
+ },
+ {
+ "keys": ["enter", "Utkastkund", "enter"],
+ "expect": ["Ny kund", "<UTKAST>", "Utkastkund"],
+ },
+ {
+ "keys": ["esc"],
+ "expect": ["Kunder", "<UTKAST> Utkastkund"],
+ },
+ {
+ "keys": ["end", "up"],
+ "expect": ["<UTKAST> Utkastkund"],
+ },
+ {
+ "keys": ["f2"],
+ "expect": ["Åtgärder", "Öppna", "Radera utkast"],
+ },
+ {
+ "keys": ["down", "enter"],
+ "expect": ["Radera utkastet?"],
+ },
+ {
+ "keys": ["enter"],
+ "expect": ["Kunder", "Testkund AB"],
+ },
+ ],
+ },
+ {
+ "name": "customer-draft-save",
+ "screen": "customers",
+ "expect": ["Kunder", "Testkund AB"],
+ "steps": [
+ {
+ "keys": ["ctrln"],
+ "expect": ["Ny kund", "<UTKAST>"],
+ },
+ {
+ "keys": ["enter", "Sparad kund", "enter"],
+ "expect": ["Sparad kund", "<UTKAST>"],
+ },
+ {
+ "keys": ["f9"],
+ "expect": ["Sparat."],
+ },
+ {
+ "keys": ["enter"],
+ "expect": ["Kunder", "Sparad kund"],
+ },
+ ],
+ },
+ {
"name": "invoice-form",
"screen": "invoices",
"steps": [
diff --git a/tests/test_tui.c b/tests/test_tui.c
index 84e610e..aa062fc 100644
--- a/tests/test_tui.c
+++ b/tests/test_tui.c
@@ -1,10 +1,13 @@
/* Unit tests for the TUI widget layer (clients/tui.c). Only the pure parts
are exercised here; drawing is smoke-tested over a pty. */
#include <stdio.h>
+#include <stdlib.h>
#include <string.h>
+#include <unistd.h>
#include <ncursesw/ncurses.h>
+#include "drafts.h"
#include "tui.h"
#include "util.h"
@@ -712,6 +715,96 @@ static void test_rt_footer(void)
CHECK(f[0] == '\0');
}
+static void test_actions(void)
+{
+ char buf[256];
+ struct tui_action acts[] = {
+ { "save", "Spara", KEY_F(9), 1, NULL },
+ { "del", "Radera utkast", 0, 0, "inget utkast" },
+ { "sec", "Sektion", 0, -1, NULL },
+ { "arch", "Arkivera", KEY_F(2), 1, NULL },
+ };
+
+ tui_action_label(&acts[0], buf, sizeof buf);
+ CHECK(strcmp(buf, "Spara") == 0);
+ tui_action_label(&acts[1], buf, sizeof buf);
+ CHECK(strcmp(buf, "Radera utkast (inget utkast)") == 0);
+
+ tui_action_hint(acts, 4, 1, buf, sizeof buf);
+ CHECK(strstr(buf, "F9 = Spara") != NULL);
+ CHECK(strstr(buf, "F2 = fler") != NULL);
+ CHECK(strstr(buf, "Arkivera") == NULL);
+
+ tui_action_hint(acts, 4, 0, buf, sizeof buf);
+ CHECK(strstr(buf, "F9 = Spara") != NULL);
+ CHECK(strstr(buf, "F2 = Arkivera F2 = fler") != NULL);
+ CHECK(strstr(buf, "Radera utkast") == NULL);
+
+ tui_action_hint(NULL, 0, 1, buf, sizeof buf);
+ CHECK(strcmp(buf, "F2 = fler") == 0);
+}
+
+static void test_drafts(void)
+{
+ char path[128];
+ struct drafts d, r;
+
+ snprintf(path, sizeof path, "/tmp/bokf-drafts-test-%d.json",
+ (int)getpid());
+ unlink(path);
+ drafts_init_path(&d, path);
+ CHECK(drafts_count(&d, 1, "customer") == 0);
+ CHECK(drafts_get(&d, 1, "customer", "ny-1") == NULL);
+
+ drafts_put(&d, 1, "customer", "ny-1", "{\"name\":\"A\"}");
+ CHECK(drafts_have(&d, 1, "customer", "ny-1"));
+ CHECK(drafts_count(&d, 1, "customer") == 1);
+ CHECK(strcmp(drafts_get(&d, 1, "customer", "ny-1"),
+ "{\"name\":\"A\"}") == 0);
+ drafts_put(&d, 1, "customer", "ny-1", "{\"name\":\"B\"}");
+ CHECK(strcmp(drafts_get(&d, 1, "customer", "ny-1"),
+ "{\"name\":\"B\"}") == 0);
+ drafts_put(&d, 2, "customer", "7", "{\"name\":\"C\"}");
+ drafts_put(&d, 1, "employee", "3", "{\"name\":\"D\"}");
+ CHECK(drafts_count(&d, 1, "customer") == 1);
+ CHECK(drafts_count(&d, 2, "customer") == 1);
+
+ drafts_init_path(&r, path);
+ CHECK(drafts_count(&r, 1, "customer") == 1);
+ CHECK(strcmp(drafts_get(&r, 1, "customer", "ny-1"),
+ "{\"name\":\"B\"}") == 0);
+ CHECK(strcmp(drafts_get(&r, 2, "customer", "7"),
+ "{\"name\":\"C\"}") == 0);
+ CHECK(strcmp(drafts_get(&r, 1, "employee", "3"),
+ "{\"name\":\"D\"}") == 0);
+ drafts_del(&r, 1, "customer", "ny-1");
+ CHECK(drafts_count(&r, 1, "customer") == 0);
+ drafts_free(&r);
+
+ drafts_init_path(&r, path);
+ CHECK(drafts_count(&r, 1, "customer") == 0);
+ CHECK(drafts_count(&r, 2, "customer") == 1);
+ drafts_free(&r);
+ drafts_free(&d);
+ unlink(path);
+
+ FILE *f = fopen(path, "w");
+ if (f) {
+ fputs("inte json", f);
+ fclose(f);
+ }
+ drafts_init_path(&r, path);
+ CHECK(drafts_count(&r, 1, "customer") == 0);
+ drafts_free(&r);
+ unlink(path);
+
+ char *a = drafts_new_id();
+ char *b = drafts_new_id();
+ CHECK(a && b && strncmp(a, "ny-", 3) == 0 && strcmp(a, b) != 0);
+ free(a);
+ free(b);
+}
+
int main(void)
{
test_disp_width();
@@ -733,6 +826,8 @@ int main(void)
test_rt_fields();
test_rt_focus();
test_rt_footer();
+ test_actions();
+ test_drafts();
printf("test_tui: %d checks, %d failures\n", checks, failures);
return failures ? 1 : 0;
}