diff options
| author | Anders Betts <anders.betts@gmail.com> | 2026-09-23 08:56:49 +0200 |
|---|---|---|
| committer | Anders Betts <anders.betts@gmail.com> | 2026-09-23 08:56:49 +0200 |
| commit | 2ca284619125af0d5c12e9210ff1bfc8f9122739 (patch) | |
| tree | a304096c2b58492d345e71f205571b913b555a23 | |
| parent | 278d89825dcb25c4cc55891b84b96b6ca9b84354 (diff) | |
| download | bokf-2ca284619125af0d5c12e9210ff1bfc8f9122739.tar.gz bokf-2ca284619125af0d5c12e9210ff1bfc8f9122739.zip | |
tui: one shared field editor, in-place form editing, value-only focus
Forms edit fields in place in the value column instead of a bottom
prompt, highlight only the focused value and scroll when taller than the
screen; long text scrolls horizontally instead of wrapping. Fixes: typing
after Backspace/Del no longer wipes the field, 'q' is text in row tables,
the caret resets between cells, dialogs decode arrow keys, full buffers
never take half a UTF-8 character, an invalid amount stays editable.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | clients/tui.c | 933 | ||||
| -rw-r--r-- | clients/tui.h | 12 | ||||
| -rw-r--r-- | docs/STATE.md | 11 | ||||
| -rw-r--r-- | docs/TUI-GUIDELINES.md | 56 | ||||
| -rwxr-xr-x | scripts/tui-golden.py | 46 | ||||
| -rw-r--r-- | tests/test_tui.c | 127 |
6 files changed, 753 insertions, 432 deletions
diff --git a/clients/tui.c b/clients/tui.c index 13da593..211df40 100644 --- a/clients/tui.c +++ b/clients/tui.c @@ -261,9 +261,40 @@ void tui_le_clear(struct tui_ledit *e) e->buf[0] = '\0'; } +static size_t utf8_seq_len(unsigned char c) +{ + if ((c & 0xE0) == 0xC0) + return 2; + if ((c & 0xF0) == 0xE0) + return 3; + if ((c & 0xF8) == 0xF0) + return 4; + return 1; +} + +/* Keys arrive one byte at a time: a lead byte is only accepted when its + whole sequence fits, and a continuation byte only completes an open + sequence, so a full buffer never ends in half a character. */ +static int le_accepts(const struct tui_ledit *e, unsigned char ch) +{ + if ((ch & 0xC0) != 0x80) + return e->len + utf8_seq_len(ch) < e->cap; + size_t start = e->pos, cont = 0; + while (start > 0 && cont < 3 && + ((unsigned char)e->buf[start - 1] & 0xC0) == 0x80) { + start--; + cont++; + } + if (start == 0) + return 0; + unsigned char lead = (unsigned char)e->buf[start - 1]; + return (lead & 0xC0) == 0xC0 && cont + 1 < utf8_seq_len(lead) && + e->len + 1 < e->cap; +} + static void le_insert(struct tui_ledit *e, unsigned char ch) { - if (e->len + 1 >= e->cap) + if (!le_accepts(e, ch)) return; memmove(e->buf + e->pos + 1, e->buf + e->pos, e->len - e->pos + 1); e->buf[e->pos] = (char)ch; @@ -358,17 +389,9 @@ int tui_date_field_edit(char *buf, size_t cap, size_t *pos, int ch, ci = seen; } - if (*fresh) { - int editing = ch >= '0' && ch <= '9'; - int moving = ch == KEY_LEFT || ch == KEY_RIGHT || ch == KEY_HOME || - ch == KEY_END || ch == 1 || ch == 5; - if (editing || moving) { - if (editing) { - n = 0; - ci = 0; - } - *fresh = 0; /* movement keeps the date and moves the caret */ - } + if (*fresh && ch >= '0' && ch <= '9') { + n = 0; + ci = 0; } if (ch >= '0' && ch <= '9') { @@ -412,10 +435,10 @@ int tui_date_field_edit(char *buf, size_t cap, size_t *pos, int ch, } else if (ch == 21) { n = 0; ci = 0; - *fresh = 0; } else { return 0; /* not a date editing key */ } + *fresh = 0; /* any edit or caret move ends the replace-on-type state */ /* rebuild "YYYY-MM-DD" (partial while typing) */ char out[16]; @@ -445,50 +468,147 @@ int tui_field_edit(char *buf, size_t cap, size_t *pos, int ch, int *fresh) tui_le_init(&e, buf, cap); if (*pos != (size_t)-1 && *pos <= e.len) e.pos = *pos; - if (*fresh) { - int editing = ch >= 32 && ch < 256; - int moving = ch == KEY_LEFT || ch == KEY_RIGHT || ch == KEY_HOME || - ch == KEY_END || ch == 1 || ch == 5; - if (editing || moving) { - if (editing) - tui_le_clear(&e); - *fresh = 0; /* movement keeps the content and moves the caret */ - } - } + if (*fresh && ch >= 32 && ch < 256 && ch != 127) + tui_le_clear(&e); /* the first printable replaces the value */ int handled = tui_le_key(&e, ch); + if (handled) + *fresh = 0; /* Backspace/Del/^U/moves edit what is there */ *pos = e.pos; return handled; } -int tui_edit_field(int y, int x, char *buf, size_t cap, int mask) +size_t tui_field_scroll(const char *buf, size_t pos, int width, size_t start) { - size_t pos = (size_t)-1; - int fresh = 1; - for (;;) { - move(y, x); - if (mask) { + size_t len = strlen(buf); + if (width < 1) + width = 1; + if (pos > len) + pos = len; + if (start > len) + start = 0; + while (start > 0 && ((unsigned char)buf[start] & 0xC0) == 0x80) + start--; + if (start > pos) + start = pos; + /* the caret needs a column of its own, also after the last character */ + while (tui_disp_width_n(buf + start, pos - start) > width - 1) { + start++; + while (start < pos && ((unsigned char)buf[start] & 0xC0) == 0x80) + start++; + } + /* scroll back when text was removed and more of it fits again */ + while (start > 0) { + size_t p = start - 1; + while (p > 0 && ((unsigned char)buf[p] & 0xC0) == 0x80) + p--; + if (tui_disp_width(buf + p) > width - 1) + break; + start = p; + } + return start; +} + +/* Paints width columns at (y,x) in attr: buf from byte `start`, clipped and + padded, '*' per character when masked. Returns the caret column. */ +static int field_paint(int y, int x, int width, const char *buf, size_t pos, + int mask, size_t start, int attr) +{ + char vis[1024]; + if (mask) { + int n = tui_disp_width(buf + start); + if (n > (int)sizeof vis - 1) + n = (int)sizeof vis - 1; + memset(vis, '*', (size_t)n); + vis[n] = '\0'; + } else { + snprintf(vis, sizeof vis, "%s", buf + start); + } + tui_pad_field(vis, sizeof vis, width); + attrset(attr); + mvaddstr(y, x, vis); + attrset(A_NORMAL); + size_t len = strlen(buf); + if (pos > len) + pos = len; + int cx = pos > start ? tui_disp_width_n(buf + start, pos - start) : 0; + return x + (cx < width ? cx : width - 1); +} + +#define FIELD_EDIT_HINT \ + "Enter = klar Esc = ångra vänster/höger/Home/End = flytta" \ + " ^U = töm" + +/* The one field editor: edits buf in place inside width columns at (y,x), + scrolling horizontally so the caret stays visible. `first` (0 = none) is + applied before the first paint, so typing on a focused field starts + editing with that key. Returns 1 on Enter, 2 on Tab, 3 on Shift-Tab (only + when `tabs`), 0 on Esc. The caller owns commit/restore. */ +static int field_loop(int y, int x, int width, char *buf, size_t cap, + int mask, int date, int tabs, int fresh, int first) +{ + size_t pos = (size_t)-1, start = 0; + int prev = curs_set(1); + int rc = 0; + if (g_hints) + g_hints(FIELD_EDIT_HINT); + for (int ch = first;; ch = 0) { + if (!ch) { size_t len = strlen(buf); - for (size_t i = 0; i < len; i++) - addch('*'); - } else { - addnstr(buf, (int)cap); + size_t cp = pos == (size_t)-1 || pos > len ? len : pos; + start = tui_field_scroll(buf, cp, width, start); + int cx = field_paint(y, x, width, buf, cp, mask, start, + (int)A_REVERSE); + move(y, cx); + refresh(); + ch = in_key(); } - clrtoeol(); - size_t len = strlen(buf); - size_t cp = pos == (size_t)-1 || pos > len ? len : pos; - move(y, x + (mask ? (int)cp : tui_disp_width_n(buf, cp))); - refresh(); - int ch = in_key(); - if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) - return 1; - if (ch == 27) - return 0; - if (ch == '\t') - return 2; - if (ch == KEY_BTAB) - return 3; - tui_field_edit(buf, cap, &pos, ch, &fresh); + if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { + rc = 1; + break; + } + if (ch == 27) { + rc = 0; + break; + } + if (tabs && (ch == '\t' || ch == KEY_BTAB)) { + rc = ch == '\t' ? 2 : 3; + break; + } + if (date) + tui_date_field_edit(buf, cap, &pos, ch, &fresh); + else + tui_field_edit(buf, cap, &pos, ch, &fresh); } + curs_set(prev == ERR ? 0 : prev); + return rc; +} + +/* Edits a copy of buf; only Enter/Tab write it back, Esc leaves buf as it + was. Same return codes as field_loop. */ +static int field_scratch(int y, int x, int width, char *buf, size_t cap, + const char *def, int mask, int date, int tabs, + int first) +{ + if (!cap) + return 0; + char *scratch = xcalloc(cap, 1); + snprintf(scratch, cap, "%s", def ? def : buf); + int rc = field_loop(y, x, width, scratch, cap, mask, date, tabs, 1, first); + if (rc) + snprintf(buf, cap, "%s", scratch); + free(scratch); + return rc; +} + +static int field_width(int x) +{ + int w = COLS - 2 - x; + return w < 1 ? 1 : w; +} + +int tui_edit_field(int y, int x, char *buf, size_t cap, int mask) +{ + return field_loop(y, x, field_width(x), buf, cap, mask, 0, 1, 1, 0); } int tui_parse_kr(const char *s, int64_t *out) @@ -613,20 +733,55 @@ void tui_amt_col(char *buf, size_t n, int width, int64_t ore) /* modal dialogs */ /* ------------------------------------------------------------------ */ -/* Center a box for a fixed number of lines and draw the frame. */ -static WINDOW *dlg_open(const char *title, int h, int w) +/* Centered box with the text and a dim key footer. Keys are read through + the application's input hook (keypad decoding, ^C/^R), so an arrow key + cannot leak escape-sequence bytes into the next view. */ +static WINDOW *dlg_open(const char *title, const char *text, + const char *footer) { + int lines = 1, maxw = 0, cur = 0; + for (const unsigned char *p = (const unsigned char *)text; *p; p++) { + if (*p == '\n') { + lines++; + cur = 0; + } else if ((*p & 0xC0) != 0x80 && ++cur > maxw) { + maxw = cur; + } + } + int fw = tui_disp_width(footer) + 4, tw = tui_disp_width(title) + 6; + int h = lines + 4, w = maxw + 4; + if (w < fw) + w = fw; + if (w < tw) + w = tw; if (w > COLS - 4) w = COLS - 4; if (w < 30) w = 30; if (h > LINES - 2) h = LINES - 2; + refresh(); /* flush the view first so reading a key cannot repaint it */ WINDOW *win = newwin(h, w, (LINES - h) / 2, (COLS - w) / 2); box(win, 0, 0); wattrset(win, tui_style_attrs(TUI_TITLE, g_colours, 0)); mvwaddnstr(win, 0, 2, title, w - 4); wattrset(win, A_NORMAL); + int row = 2; + char *copy = xstrdup(text); + for (char *p = copy; p && *p && row < h - 1; row++) { + char *nl = strchr(p, '\n'); + if (nl) + *nl = '\0'; + char line[1024]; + snprintf(line, sizeof line, "%s", p); + tui_pad_field(line, sizeof line, w - 4); + mvwaddstr(win, row, 2, line); + p = nl ? nl + 1 : NULL; + } + free(copy); + wattrset(win, tui_style_attrs(TUI_DIM, g_colours, 0)); + mvwaddstr(win, h - 1, w - 2 - tui_disp_width(footer), footer); + wattrset(win, A_NORMAL); wrefresh(win); nodelay(stdscr, FALSE); timeout(-1); @@ -640,54 +795,33 @@ static void dlg_close(WINDOW *win) refresh(); } -void tui_message(const char *title, const char *fmt, ...) +/* Waits for a dialog key; returns 1 for a "yes" key, 0 for a "no" key. */ +static int dlg_wait(int yes_no) { - char text[2048]; - va_list ap; - va_start(ap, fmt); - vsnprintf(text, sizeof text, fmt, ap); - va_end(ap); - int lines = 1; - size_t maxw = 0, cur = 0; - for (const char *p = text; *p; p++) { - if (*p == '\n') { - lines++; - if (cur > maxw) - maxw = cur; - cur = 0; - } else { - cur++; - } - } - if (cur > maxw) - maxw = cur; - int h = lines + 4, w = (int)maxw + 4; - WINDOW *win = dlg_open(title, h, w); - int row = 2; - char *copy = xstrdup(text); - for (char *p = copy; p && *p; row++) { - char *nl = strchr(p, '\n'); - if (nl) - *nl = '\0'; - mvwaddnstr(win, row, 2, p, w - 4); - p = nl ? nl + 1 : NULL; - } - free(copy); - wattrset(win, tui_style_attrs(TUI_DIM, g_colours, 0)); - mvwaddstr(win, h - 1, w - 13, " tryck Enter"); - wattrset(win, A_NORMAL); - wrefresh(win); for (;;) { - int ch = wgetch(win); + int ch = in_key(); if (ch == 3) { if (g_quit) g_quit(); - break; + return 0; } - if (ch == '\n' || ch == '\r' || ch == ' ' || ch == 27 || - ch == KEY_ENTER) - break; + if (ch == '\n' || ch == '\r' || ch == KEY_ENTER || + (yes_no && (ch == 'j' || ch == 'J')) || (!yes_no && ch == ' ')) + return 1; + if (ch == 27 || (yes_no && (ch == 'n' || ch == 'N'))) + return 0; } +} + +void tui_message(const char *title, const char *fmt, ...) +{ + char text[2048]; + va_list ap; + va_start(ap, fmt); + vsnprintf(text, sizeof text, fmt, ap); + va_end(ap); + WINDOW *win = dlg_open(title, text, " tryck Enter "); + dlg_wait(0); dlg_close(win); } @@ -699,52 +833,8 @@ int tui_confirm(const char *title, const char *fmt, ...) va_start(ap, fmt); vsnprintf(text, sizeof text, fmt, ap); va_end(ap); - int lines = 1; - size_t maxw = 0, cur = 0; - for (const char *p = text; *p; p++) { - if (*p == '\n') { - lines++; - if (cur > maxw) - maxw = cur; - cur = 0; - } else { - cur++; - } - } - if (cur > maxw) - maxw = cur; - int h = lines + 4, w = (int)maxw + 4; - WINDOW *win = dlg_open(title, h, w); - int row = 2; - char *copy = xstrdup(text); - for (char *p = copy; p && *p; row++) { - char *nl = strchr(p, '\n'); - if (nl) - *nl = '\0'; - mvwaddnstr(win, row, 2, p, w - 4); - p = nl ? nl + 1 : NULL; - } - free(copy); - wattrset(win, tui_style_attrs(TUI_DIM, g_colours, 0)); - mvwaddstr(win, h - 1, w - 25, " j = ja n = nej"); - wattrset(win, A_NORMAL); - wrefresh(win); - int yes = 0; - for (;;) { - int ch = wgetch(win); - if (ch == 3) { - if (g_quit) - g_quit(); - break; - } - if (ch == 'j' || ch == 'J' || ch == '\n' || ch == '\r' || - ch == KEY_ENTER) { - yes = 1; - break; - } - if (ch == 'n' || ch == 'N' || ch == 27) - break; - } + WINDOW *win = dlg_open(title, text, " j = ja n = nej "); + int yes = dlg_wait(1); dlg_close(win); return yes; } @@ -753,117 +843,108 @@ int tui_confirm(const char *title, const char *fmt, ...) /* prompts (write into the caller's buffer, 0 on Esc) */ /* ------------------------------------------------------------------ */ -int tui_prompt_into(char *buf, size_t cap, const char *label, - const char *def, int mask) +/* Draws the prompt label on the prompt line and returns where input starts. */ +static int prompt_label(const char *label, int gap) { - char scratch[512]; - size_t scap = cap < sizeof scratch ? cap : sizeof scratch; - snprintf(scratch, scap, "%s", def ? def : buf); - int y = LINES - 3; - move(y, 2); + move(y, 1); clrtoeol(); tui_style(TUI_HEADING); - mvaddstr(y, 2, label); + mvaddnstr(y, 2, label, COLS - 4); tui_style_reset(); - refresh(); - int prev = curs_set(1); - int rc = tui_edit_field(y, (int)strlen(label) + 3, scratch, scap, mask); - curs_set(prev == ERR ? 0 : prev); - if (rc != 0) - snprintf(buf, cap, "%s", scratch); - return rc != 0; + int x = 2 + tui_disp_width(label) + gap; + return x < COLS - 3 ? x : COLS - 3; +} + +int tui_prompt_into(char *buf, size_t cap, const char *label, + const char *def, int mask) +{ + int x = prompt_label(label, 1); + return field_scratch(LINES - 3, x, field_width(x), buf, cap, def, mask, 0, + 1, 0) != 0; } int tui_date_prompt_into(char *buf, size_t cap, const char *label, const char *def) { - char scratch[16]; - size_t scap = cap < sizeof scratch ? cap : sizeof scratch; - snprintf(scratch, scap, "%s", def ? def : buf); + int x = prompt_label(label, 1); + return field_scratch(LINES - 3, x, field_width(x), buf, cap, def, 0, 1, 0, + 0) != 0; +} - int y = LINES - 3; - move(y, 2); - clrtoeol(); - tui_style(TUI_HEADING); - mvaddstr(y, 2, label); - tui_style_reset(); - size_t pos = (size_t)-1; +/* Amount editor at (y,x): an unparsable amount is reported and the typed + text stays in the field for correction. Returns 1..3 like field_loop. */ +static int amount_loop(int y, int x, int width, int64_t *out, int64_t def_ore, + int tabs, int first) +{ + char buf[48]; + tui_kr_format(def_ore, buf, sizeof buf); int fresh = 1; - int prev = curs_set(1); - int rc = 0; for (;;) { - move(y, (int)strlen(label) + 3); - clrtoeol(); - addnstr(scratch, (int)scap); - clrtoeol(); - size_t slen = strlen(scratch); - size_t cp = pos == (size_t)-1 || pos > slen ? slen : pos; - move(y, (int)strlen(label) + 3 + (int)tui_disp_width_n(scratch, cp)); - refresh(); - int ch = in_key(); - if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { - rc = 1; - break; + int rc = field_loop(y, x, width, buf, sizeof buf, 0, 0, tabs, fresh, + first); + if (!rc) + return 0; + int64_t v; + if (tui_parse_kr(buf, &v) == 0) { + *out = v; + return rc; } - if (ch == 27) - break; - tui_date_field_edit(scratch, scap, &pos, ch, &fresh); + tui_message("Ogiltigt belopp", "%s", buf); + fresh = 0; + first = 0; } - curs_set(prev == ERR ? 0 : prev); - if (rc) - snprintf(buf, cap, "%s", scratch); - return rc; } int tui_amount_prompt_into(int64_t *out, const char *label, int64_t def_ore) { - char def[48], buf[48]; - tui_kr_format(def_ore, def, sizeof def); - if (!tui_prompt_into(buf, sizeof buf, label, def, 0)) - return 0; - if (tui_parse_kr(buf, out) != 0) { - tui_message("Ogiltigt belopp", "%s", buf); - return 0; - } - return 1; + int x = prompt_label(label, 1); + return amount_loop(LINES - 3, x, field_width(x), out, def_ore, 0, 0) != 0; } -int tui_choice_prompt(const char *label, const char *const *opts, int n, - int cur) +/* "< value >" at (y,x); left/right cycle, Enter picks, Esc cancels. + Returns the chosen index or -1 (Tab/Shift-Tab pick too when `tabs`, with + *how set to 2/3; Enter sets 1). */ +static int choice_loop(int y, int x, int width, const char *const *opts, + int n, int cur, int tabs, int *how) { if (n <= 0) return -1; if (cur < 0 || cur >= n) cur = 0; - int y = LINES - 3; int prev = curs_set(0); + int ret = -1; + if (g_hints) + g_hints("vänster/höger = välj Enter = OK Esc = avbryt"); for (;;) { - move(y, 2); - clrtoeol(); char line[512]; - snprintf(line, sizeof line, "%s< %s >", label, opts[cur]); - tui_style(TUI_HEADING); - mvaddnstr(y, 2, line, COLS - 4); - tui_style_reset(); - char hint[256]; - snprintf(hint, sizeof hint, "vänster/höger = välj Enter = OK " - "Esc = avbryt"); - g_hints ? g_hints(hint) : (void)0; + snprintf(line, sizeof line, "< %s >", opts[cur]); + field_paint(y, x, width, line, 0, 0, 0, (int)A_REVERSE); refresh(); int ch = in_key(); - if (ch == KEY_LEFT) + if (ch == KEY_LEFT) { cur = cur > 0 ? cur - 1 : n - 1; - else if (ch == KEY_RIGHT) + } else if (ch == KEY_RIGHT || ch == ' ') { cur = cur + 1 < n ? cur + 1 : 0; - else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { - curs_set(prev == ERR ? 0 : prev); - return cur; + } else if (ch == '\n' || ch == '\r' || ch == KEY_ENTER || + (tabs && (ch == '\t' || ch == KEY_BTAB))) { + if (how) + *how = ch == '\t' ? 2 : ch == KEY_BTAB ? 3 : 1; + ret = cur; + break; } else if (ch == 27) { - curs_set(prev == ERR ? 0 : prev); - return -1; + break; } } + curs_set(prev == ERR ? 0 : prev); + return ret; +} + +int tui_choice_prompt(const char *label, const char *const *opts, int n, + int cur) +{ + int x = prompt_label(label, 0); + return choice_loop(LINES - 3, x, field_width(x), opts, n, cur, 0, NULL); } /* ------------------------------------------------------------------ */ @@ -1715,85 +1796,178 @@ int tui_action_menu(const char *title, const struct tui_action *acts, int n, 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) +/* 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 +#define FORM_LABEL_W 31 +#define FORM_VALUE_X 34 +#define FORM_FOCUS_MIN_W 24 + +static void field_value(const struct tui_form_field *f, char *val, size_t n) { - int y = 5 + nf + 1; - for (int i = 0; i < na; i++) { - if (y + i >= LINES - 2) - break; - char line[832]; - tui_form_act_label(&acts[i], line, sizeof line); - int dim = acts[i].enabled != 1; - if (dim) - attron(tui_style_attrs(TUI_DIM, g_colours, 0)); - if (sel == nf + i) - attron(A_REVERSE); - mvaddnstr(y + i, 2, line, COLS - 4); - if (sel == nf + i) - attroff(A_REVERSE); - if (dim) - attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); + if (f->kind == TUI_F_ACTION) { + snprintf(val, n, "%s", f->value ? f->value : "(lista)"); + } else if (f->kind == TUI_F_AMOUNT && f->ore) { + tui_kr_format(*f->ore, val, n); + } else if (f->kind == TUI_F_CHOICE && f->choice && f->choices) { + int c = *f->choice; + snprintf(val, n, "%s", + c >= 0 && c < f->nchoices ? f->choices[c] : ""); + } else if (f->mask && f->kind == TUI_F_TEXT) { + size_t vl = f->value ? (size_t)tui_disp_width(f->value) : 0; + if (vl >= n) + vl = n - 1; + memset(val, '*', vl); + val[vl] = '\0'; + } else { + snprintf(val, n, "%s", f->value ? f->value : ""); + } +} + +static void form_header(void) +{ + tui_style(TUI_HEADING); + mvaddstr(3, 2, "Uppgift"); + mvaddstr(3, FORM_VALUE_X, "Värde"); + tui_style_reset(); +} + +/* One field row. The focused row has a bold label and a reverse-video + value box (at least FORM_FOCUS_MIN_W wide so an empty field shows); + the label itself is never highlighted. */ +static void form_field_row(int y, const struct tui_form_field *f, int focused, + int dim) +{ + char lbl[160], val[640]; + snprintf(lbl, sizeof lbl, "%s", f->label); + tui_pad_field(lbl, sizeof lbl, FORM_LABEL_W); + int plain = dim ? tui_style_attrs(TUI_DIM, g_colours, 0) : (int)A_NORMAL; + attrset(focused ? tui_style_attrs(TUI_ACCENT, g_colours, 0) : plain); + mvaddstr(y, 2, lbl); + attrset(A_NORMAL); + field_value(f, val, sizeof val); + int vw = field_width(FORM_VALUE_X); + if (focused) { + int w = tui_disp_width(val) + 1; + if (w < FORM_FOCUS_MIN_W) + w = FORM_FOCUS_MIN_W; + field_paint(y, FORM_VALUE_X, w < vw ? w : vw, val, 0, 0, 0, + (int)A_REVERSE); + } else { + field_paint(y, FORM_VALUE_X, vw, val, 0, 0, 0, plain); } } +/* Edits field f in place on row y (the value column). `first` is a key + that starts the edit (0 = none). Returns 0 when cancelled, else 1 for + Enter, 2 for Tab, 3 for Shift-Tab; the field holds the new value. */ +static int form_field_edit(struct tui_form_field *f, int y, int first) +{ + int x = FORM_VALUE_X, w = field_width(x); + attrset(tui_style_attrs(TUI_ACCENT, g_colours, 0)); + char lbl[160]; + snprintf(lbl, sizeof lbl, "%s", f->label); + tui_pad_field(lbl, sizeof lbl, FORM_LABEL_W); + mvaddstr(y, 2, lbl); + attrset(A_NORMAL); + if (f->kind == TUI_F_TEXT || f->kind == TUI_F_DATE) { + if (!f->value) + return 0; + return field_scratch(y, x, w, f->value, f->cap, NULL, f->mask, + f->kind == TUI_F_DATE, 1, first); + } + if (f->kind == TUI_F_AMOUNT && f->ore) { + int64_t v = *f->ore; + int rc = amount_loop(y, x, w, &v, *f->ore, 1, first); + if (rc) + *f->ore = v; + return rc; + } + if (f->kind == TUI_F_CHOICE && f->choice && !first) { + int how = 0; + int c = choice_loop(y, x, w, f->choices, f->nchoices, *f->choice, 1, + &how); + if (c < 0) + return 0; + *f->choice = c; + return how; + } + return 0; +} + +/* Whether typing a printable key on the focused field starts editing it. */ +static int field_types(const struct tui_form_field *f) +{ + return (f->kind == TUI_F_TEXT || f->kind == TUI_F_DATE) ? f->value != NULL + : f->kind == TUI_F_AMOUNT ? f->ore != NULL + : 0; +} + +/* Scroll offset (in form lines) that keeps line `sl` of `nlines` visible + in a view of `view` lines. Pure. */ +int tui_form_scroll(int top, int sl, int nlines, int view) +{ + if (view < 1) + view = 1; + if (sl < top) + top = sl; + if (sl >= top + view) + top = sl - view + 1; + if (top + view > nlines) + top = nlines - view; + return top < 0 ? 0 : top; +} + static int form_run(const char *title, struct tui_form_field *f, int nf, int can_edit, const struct tui_form_action *acts, int na, const char *hint, int (*key)(void *ud, int ch), void *ud, int *focus) { int sel = tui_form_focus_clamp(focus ? *focus : -1, nf, acts, na); + int top = 0; + if (na < 0) + na = 0; for (;;) { - int ntot = nf + (na > 0 ? na : 0); + int ntot = nf + na; if (ntot <= 0) return tui_form_focus_store(focus, sel, TUI_FORM_BACK); if (sel < 0) sel = tui_form_act_first(nf, acts, na); if (sel >= ntot) sel = ntot - 1; + /* form lines: the fields, a blank line, then the action rows */ + int nlines = nf + (na > 0 ? na + 1 : 0); + int view = LINES - 3 - FORM_Y; + if (view < 1) + view = 1; + int sl = sel < 0 ? 0 : sel < nf ? sel : sel + 1; + top = tui_form_scroll(top, sl, nlines, view); tui_frame(title); - tui_style(TUI_HEADING); - mvaddstr(3, 2, "Uppgift"); - mvaddstr(3, 34, "Värde"); - tui_style_reset(); - for (int i = 0; i < nf; i++) { - char lbl[160], val[640], line[832]; - snprintf(lbl, sizeof lbl, "%s", f[i].label); - tui_pad_field(lbl, sizeof lbl, 31); - if (f[i].kind == TUI_F_ACTION) { - snprintf(val, sizeof val, "%s", - f[i].value ? f[i].value : "(lista)"); - } else if (f[i].kind == TUI_F_AMOUNT && f[i].ore) { - tui_kr_format(*f[i].ore, val, sizeof val); - } else if (f[i].kind == TUI_F_CHOICE && f[i].choice && - f[i].choices) { - int c = *f[i].choice; - snprintf(val, sizeof val, "%s", - c >= 0 && c < f[i].nchoices ? f[i].choices[c] : ""); - } else if (f[i].mask && f[i].kind == TUI_F_TEXT) { - size_t vl = f[i].value ? strlen(f[i].value) : 0; - if (vl >= sizeof val) - vl = sizeof val - 1; - memset(val, '*', vl); - val[vl] = '\0'; - } else { - snprintf(val, sizeof val, "%s", - f[i].value ? f[i].value : ""); + form_header(); + for (int l = top; l < nlines && l < top + view; l++) { + int y = FORM_Y + l - top; + if (l < nf) { + form_field_row(y, &f[l], l == sel, !can_edit && l != sel); + } else if (l > nf) { + const struct tui_form_action *a = &acts[l - nf - 1]; + char line[832]; + tui_form_act_label(a, line, sizeof line); + int attr = a->enabled != 1 + ? tui_style_attrs(TUI_DIM, g_colours, 0) + : (int)A_NORMAL; + if (sel == l - 1) + attr |= A_REVERSE; + attrset(attr); + mvaddnstr(y, 2, line, COLS - 4); + attrset(A_NORMAL); } - snprintf(line, sizeof line, "%s %s", lbl, val); - if (i == sel) - attron(A_REVERSE); - else if (!can_edit) - attron(tui_style_attrs(TUI_DIM, g_colours, 0)); - mvaddnstr(5 + i, 2, line, COLS - 4); - if (i == sel) - attroff(A_REVERSE); - else if (!can_edit) - attroff(tui_style_attrs(TUI_DIM, g_colours, 0)); } - if (na > 0) - form_draw_actions(acts, na, nf, sel); + tui_style(TUI_DIM); + if (top > 0) + mvaddstr(FORM_Y - 1, 2, "↑ fler"); + if (top + view < nlines) + mvaddstr(FORM_Y + view, 2, "↓ fler"); + tui_style_reset(); const char *h = g_form_hint ? g_form_hint : hint; if (!h) h = na > 0 @@ -1863,31 +2037,18 @@ static int form_run(const char *title, struct tui_form_field *f, int nf, tui_message(title, "Endast behöriga kan ändra."); continue; } - struct tui_form_field *fl = &f[sel]; - if (fl->kind == TUI_F_ACTION) + if (f[sel].kind == TUI_F_ACTION) return tui_form_focus_store(focus, sel, sel); - char label[192]; - snprintf(label, sizeof label, "%s: ", fl->label); - if (fl->kind == TUI_F_TEXT) { - if (fl->value && - tui_prompt_into(fl->value, fl->cap, label, fl->value, - fl->mask)) - return tui_form_focus_store(focus, sel, sel); - } else if (fl->kind == TUI_F_DATE) { - if (fl->value && tui_date_prompt_into(fl->value, fl->cap, label, - fl->value)) - return tui_form_focus_store(focus, sel, sel); - } else if (fl->kind == TUI_F_AMOUNT) { - if (fl->ore && tui_amount_prompt_into(fl->ore, label, *fl->ore)) - return tui_form_focus_store(focus, sel, sel); - } else if (fl->kind == TUI_F_CHOICE && fl->choice) { - int c = tui_choice_prompt(label, fl->choices, fl->nchoices, - *fl->choice); - if (c >= 0) { - *fl->choice = c; - return tui_form_focus_store(focus, sel, sel); - } - } + int rc = form_field_edit(&f[sel], FORM_Y + sel - top, 0); + if (!rc) + continue; + /* Tab/Shift-Tab commit and move on; Enter stays on the field */ + int next = rc == 1 ? sel + : tui_form_act_step(sel, nf, acts, na, + rc == 2 ? 1 : -1); + if (focus) + *focus = next; + return sel; } } @@ -2080,6 +2241,8 @@ int tui_rt_move(struct tui_rt *t, int dr, int dc) moved = 1; } t->fresh = 1; + t->pos = (size_t)-1; /* a new cell starts with the caret at its end */ + t->hscroll = 0; return moved; } @@ -2112,87 +2275,34 @@ int tui_rt_clear(struct tui_rt *t) tui_rt_normalize(t); t->fresh = 1; t->pos = (size_t)-1; + t->hscroll = 0; return 1; } -static void rt_field_value(const struct tui_form_field *f, char *val, - size_t n) -{ - if (f->kind == TUI_F_ACTION) { - snprintf(val, n, "%s", f->value ? f->value : "(lista)"); - } else if (f->kind == TUI_F_AMOUNT && f->ore) { - tui_kr_format(*f->ore, val, n); - } else if (f->kind == TUI_F_CHOICE && f->choice && f->choices) { - int c = *f->choice; - snprintf(val, n, "%s", - c >= 0 && c < f->nchoices ? f->choices[c] : ""); - } else if (f->mask && f->kind == TUI_F_TEXT) { - size_t vl = f->value ? strlen(f->value) : 0; - if (vl >= n) - vl = n - 1; - memset(val, '*', vl); - val[vl] = '\0'; - } else { - snprintf(val, n, "%s", f->value ? f->value : ""); - } -} - static void rt_draw_fields(const struct tui_rt *t) { if (t->nfields <= 0) return; - tui_style(TUI_HEADING); - mvaddstr(3, 2, "Uppgift"); - mvaddstr(3, 34, "Värde"); - tui_style_reset(); - for (int i = 0; i < t->nfields; i++) { - char lbl[160], val[640], line[832]; - snprintf(lbl, sizeof lbl, "%s", t->fields[i].label); - tui_pad_field(lbl, sizeof lbl, 31); - rt_field_value(&t->fields[i], val, sizeof val); - snprintf(line, sizeof line, "%s %s", lbl, val); - if (i == t->focus) - attron(A_REVERSE); - mvaddnstr(5 + i, 2, line, COLS - 4); - if (i == t->focus) - attroff(A_REVERSE); - } + form_header(); + for (int i = 0; i < t->nfields; i++) + form_field_row(FORM_Y + i, &t->fields[i], i == t->focus, 0); } -/* Opens the editor for header field i; on success focus advances to the - next field (or the table). Esc cancels and keeps the focus. */ -static void rt_edit_field(struct tui_rt *t, int i) +/* Edits header field i in place (`first` = the key that started it, or 0). + On a commit the focus moves on (Shift-Tab: back); Esc keeps it. */ +static void rt_edit_field(struct tui_rt *t, int i, int first) { if (i < 0 || i >= t->nfields) return; - struct tui_form_field *fl = &t->fields[i]; - char label[192]; - snprintf(label, sizeof label, "%s: ", fl->label); - int ok = 0; - if (fl->kind == TUI_F_TEXT) { - if (fl->value && tui_prompt_into(fl->value, fl->cap, label, fl->value, - fl->mask)) - ok = 1; - } else if (fl->kind == TUI_F_DATE) { - if (fl->value && tui_date_prompt_into(fl->value, fl->cap, label, - fl->value)) - ok = 1; - } else if (fl->kind == TUI_F_AMOUNT) { - if (fl->ore && tui_amount_prompt_into(fl->ore, label, *fl->ore)) - ok = 1; - } else if (fl->kind == TUI_F_CHOICE && fl->choice) { - int c = tui_choice_prompt(label, fl->choices, fl->nchoices, - *fl->choice); - if (c >= 0) { - *fl->choice = c; - ok = 1; - } - } - if (!ok) + int rc = form_field_edit(&t->fields[i], FORM_Y + i, first); + if (!rc) return; tui_rt_normalize(t); t->focus = tui_rt_focus_step(i, t->nfields, &t->row, &t->col, t->nrows, - t->neditable, 1); + t->neditable, rc == 3 ? -1 : 1); + t->fresh = 1; + t->pos = (size_t)-1; + t->hscroll = 0; } /* Returns -1 back, -2 refresh, -3 submit; otherwise an edited key was @@ -2200,13 +2310,14 @@ static void rt_edit_field(struct tui_rt *t, int i) int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) { int top = 0; - int prev_cursor = curs_set(1); + int prev_cursor = curs_set(0); + int ret; for (;;) { if (g_frame) g_frame(title); int y = t->y; - if (t->nfields > 0 && y < t->nfields + 6) - y = t->nfields + 6; /* fields at 5.., column header, then rows */ + if (t->nfields > 0 && y < t->nfields + FORM_Y + 1) + y = t->nfields + FORM_Y + 1; /* fields, column header, rows */ int view = LINES - 2 - y; if (view < 1) view = 1; @@ -2228,47 +2339,33 @@ int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) } tui_style_reset(); int caret_y = -1, caret_x = -1; - if (t->focus >= 0 && t->focus < t->nfields) { - char val[640]; - rt_field_value(&t->fields[t->focus], val, sizeof val); - caret_y = 5 + t->focus; - caret_x = 34 + (int)tui_disp_width(val); - if (caret_x > COLS - 1) - caret_x = COLS - 1; - } int ei = 0; for (int i = 0; i < t->ncols; i++) { - int focused_col = (t->focus < 0 && - t->cols[i].kind != TUI_RT_INFO && ei == t->col); - if (t->cols[i].kind != TUI_RT_INFO) - ei++; - if (t->cols[i].kind == TUI_RT_INFO) { + if (t->cols[i].kind == TUI_RT_INFO) continue; - } + int focused_col = t->focus < 0 && ei == t->col; + ei++; for (int v = 0; v < view && top + v < t->nrows; v++) { int row = top + v; char *buf = NULL; size_t cap = 0; rt_get(t, row, i, &buf, &cap); - char padded[512]; - snprintf(padded, sizeof padded, "%s", buf ? buf : ""); - tui_pad_field(padded, sizeof padded, t->cols[i].width); - int vy = y + v; + const char *text = buf ? buf : ""; if (focused_col && row == t->row) { - attron(A_REVERSE); - caret_y = vy; - size_t slen = strlen(buf ? buf : ""); - size_t cp = (t->fresh || t->pos == (size_t)-1 || - t->pos > slen) + size_t slen = strlen(text); + size_t cp = t->pos == (size_t)-1 || t->pos > slen ? slen : t->pos; - int cw = tui_disp_width_n(buf ? buf : "", cp); - caret_x = t->cols[i].x + - (cw > t->cols[i].width ? t->cols[i].width : cw); + t->hscroll = tui_field_scroll(text, cp, t->cols[i].width, + t->hscroll); + caret_y = y + v; + caret_x = field_paint(y + v, t->cols[i].x, + t->cols[i].width, text, cp, 0, + t->hscroll, (int)A_REVERSE); + } else { + field_paint(y + v, t->cols[i].x, t->cols[i].width, text, + 0, 0, 0, (int)A_NORMAL); } - mvaddstr(vy, t->cols[i].x, padded); - if (focused_col && row == t->row) - attroff(A_REVERSE); } } if (t->info) { @@ -2300,35 +2397,39 @@ int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) } if (g_hints) g_hints(hint); + /* the caret only shows in a table cell; a focused header field is + marked by its highlighted value until it is edited */ + curs_set(caret_y >= 0 ? 1 : 0); if (caret_y >= 0) move(caret_y, caret_x); refresh(); int ch = in_key(); - if (ch == 27 || ch == 'q') { - curs_set(prev_cursor == ERR ? 0 : prev_cursor); - return -1; + /* no 'q' here: every printable key is text in a cell or field */ + if (ch == 27) { + ret = -1; + break; } if (ch == KEY_F(5)) { - curs_set(prev_cursor == ERR ? 0 : prev_cursor); - return -2; + ret = -2; + break; } if (ch == TUI_KEY_CTRL_ENTER || ch == KEY_F(9)) { - curs_set(prev_cursor == ERR ? 0 : prev_cursor); - return -3; + ret = -3; + break; } if (t->key) { int h = t->key(t->ud, ch); if (h == TUI_HOOK_BACK) { - curs_set(prev_cursor == ERR ? 0 : prev_cursor); - return -1; + ret = -1; + break; } if (h == TUI_HOOK_REFRESH) { - curs_set(prev_cursor == ERR ? 0 : prev_cursor); - return -2; + ret = -2; + break; } if (h == TUI_HOOK_SUBMIT) { - curs_set(prev_cursor == ERR ? 0 : prev_cursor); - return -3; + ret = -3; + break; } if (h == TUI_HOOK_STAY) continue; @@ -2340,6 +2441,7 @@ int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) ch == '\t' ? 1 : -1); t->fresh = 1; t->pos = (size_t)-1; + t->hscroll = 0; continue; } if (ch == KEY_UP || ch == KEY_DOWN) { @@ -2354,17 +2456,12 @@ int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) continue; } if (t->focus >= 0) { - if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { - rt_edit_field(t, t->focus); - continue; - } - if (ch < 32 || ch >= 256) - continue; - /* typing is meant for the rows: hand the focus to the table */ - t->focus = -1; - t->col = 0; - t->fresh = 1; - t->pos = (size_t)-1; + if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) + rt_edit_field(t, t->focus, 0); + else if (ch >= 32 && ch < 256 && ch != 127 && + field_types(&t->fields[t->focus])) + rt_edit_field(t, t->focus, ch); + continue; } if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) { tui_rt_move(t, 1, 0); @@ -2394,4 +2491,6 @@ int tui_rt_run(const char *title, struct tui_rt *t, const char *hint) tui_field_edit(buf, cap, &t->pos, ch, &t->fresh); tui_rt_normalize(t); } + curs_set(prev_cursor == ERR ? 0 : prev_cursor); + return ret; } diff --git a/clients/tui.h b/clients/tui.h index 1785a32..5ede574 100644 --- a/clients/tui.h +++ b/clients/tui.h @@ -99,7 +99,14 @@ int tui_field_edit(char *buf, size_t cap, size_t *pos, int ch, int *fresh); int tui_date_field_edit(char *buf, size_t cap, size_t *pos, int ch, int *fresh); -/* Line editor at (y,x) with the label already drawn. Returns 1 on Enter, +/* First byte to show so the caret at byte pos is visible in a field of + width columns (one column is kept for the caret after the last + character). `start` is the previous value: it is kept while the caret + stays visible, and moved back when removed text makes room. Pure. */ +size_t tui_field_scroll(const char *buf, size_t pos, int width, size_t start); + +/* Line editor at (y,x) up to the right border, scrolling horizontally, + with the label already drawn. Edits buf in place. Returns 1 on Enter, 2 on Tab, 3 on Shift-Tab, 0 on Esc. */ int tui_edit_field(int y, int x, char *buf, size_t cap, int mask); @@ -187,6 +194,8 @@ struct tui_form_field { }; int tui_form_next(int sel, int n, int ch); +/* First visible form line so line sl of nlines shows in view lines. Pure. */ +int tui_form_scroll(int top, int sl, int nlines, int view); int tui_form_run(const char *title, struct tui_form_field *f, int n, int can_edit, int *focus); @@ -283,6 +292,7 @@ struct tui_rt { int row, col; int fresh; size_t pos; + size_t hscroll; /* first byte shown in the focused cell */ void (*cell)(void *, int, int, char **, size_t *); void (*info)(void *, int, char *, size_t); void *ud; diff --git a/docs/STATE.md b/docs/STATE.md index 7fccc31..fa0db74 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -14,6 +14,17 @@ unit tests and the docs consistency check. ## Resume here (2026-09-22) +- **TUI field editing (2026-09-23, branch `eff/tui-fields`, not merged)**: + one shared field editor in `clients/tui.c` for forms, row-table header + fields and prompts. Forms edit in place in the value column (no bottom + prompt), only the value is highlighted, long text scrolls horizontally + instead of wrapping, and tall forms scroll. Bug fixes: Backspace/Del then + typing no longer wipes the field; `q` is text in row tables; the caret + resets when moving between cells; dialogs decode arrow keys (no `[A` + leaking into the next view); a partial UTF-8 character is never inserted + into a full buffer; an invalid amount stays in the field. Typing on a + focused row-table header field now edits that field instead of jumping + to the first cell. Awaits the human's Ctrl+R test. - **Deklaration 2025/2026 (org 2, 2026-09-22)**: the imported year is closed by the source's `Stäng intäktskonton/kostnadskonton`, so the resultatrapport (and the INK2/SRU derived from it) showed 0. `report.income_statement` now diff --git a/docs/TUI-GUIDELINES.md b/docs/TUI-GUIDELINES.md index 1c201aa..c791f25 100644 --- a/docs/TUI-GUIDELINES.md +++ b/docs/TUI-GUIDELINES.md @@ -136,7 +136,7 @@ there. |---|---| | `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 | -| `Esc` / `q` | Back one level. At the dashboard it does nothing — Esc never exits the app | +| `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) | | `Ctrl+A` | Close or reopen the highlighted räkenskapsår (Räkenskapsår screen; asks for confirmation) | @@ -193,14 +193,32 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. chosen action, an edited field) the row is written back, so a re-run after a save or a round-trip through another screen lands on the same row. `NULL` starts at the first focusable row and stores nothing. -- The active field is drawn reverse-video and holds the hardware cursor at the - caret. The caret is a real position: ←/→/Home/End/Del work inside the field, - `Ctrl+U` clears it (see `field_edit`). +- Focus marks the **value, not the row**: the focused field's label is + accent/bold and only its value is reverse video, in a box at least 24 + columns wide so an empty field is visible. Action rows (`Spara`, …) are + buttons and highlight their whole label. +- Fields are **edited in place**, in the value column (never in a prompt at + the bottom): `Enter` opens the editor on the focused field, which widens + to the right border and shows the caret. One editor (`field_loop` in + `tui.c`) serves forms, row-table header fields and the bottom-line + prompts. The caret is a real position: ←/→/Home/End/Del work inside the + field, `Ctrl+U` clears it. Text longer than the field scrolls + horizontally (`tui_field_scroll`) so the caret is always visible; it + never wraps onto the lines below. +- In the editor `Enter` commits and stays on the field, `Tab`/`Shift-Tab` + commit and move to the next/previous row, `Esc` restores the old value + (the editor works on a scratch copy). A choice field shows `< value >` + in place (←/→ or space cycle). An amount that does not parse is reported + and stays in the field for correction. - First keystroke in a freshly focused field replaces its content (`field_fresh`), so prefilled values like dates can be typed over. - Backspace/Del/`^U` still delete normally instead of replacing. -- A prompt edits a scratch copy: only `Enter` commits it to the field; - `Esc` leaves the field exactly as it was. + Backspace/Del/`^U`/caret moves edit what is there and end that state: the + next printable key is inserted, it never wipes the field. +- Byte input is UTF-8 safe: a character that does not fit in the buffer is + dropped whole, never half-inserted. +- Forms taller than the screen scroll with the focus; dim `↑ fler` / + `↓ fler` markers show that rows are hidden above/below + (`tui_form_scroll`). - Date fields (`date_field_edit`, `date_prompt`) accept digits only and insert the dashes themselves: type `20260315` and the field shows `2026-03-15`. Backspace deletes a digit (with its separator), ←/→/Home/End move by digit, @@ -222,11 +240,16 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. the fields above the table on the same screen. Focus starts on the first field; `Tab`/`Shift-Tab` walk fields, then the cells row by row, and wrap back to the first field. `Up`/`Down` move between fields (in the table - they change row). `Enter` opens the field's editor (text/date/amount/ - choice) and advances to the next field; `Esc` cancels the editor without - changing anything. Typing a printable character hands the focus to the - first table cell and inserts it there, so rows can be entered without - touching the header. `F5`/`Ctrl+Enter`/`Esc` work the same from both. + they change row). `Enter` opens the field's in-place editor (text/date/ + amount/choice) and advances to the next field on commit; `Esc` cancels + the editor without changing anything. Typing a printable character on a + focused text/date/amount field starts editing that field with the key + (replacing the value, like any fresh field); on a choice/action field it + 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` + 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`/ up/down, `Home`/`End`; the ring wraps and heading rows are skipped). @@ -265,6 +288,9 @@ written compactly as `^N`, `^A`, `^C`, `^R` to save width. ## Messages - Info/confirmation: `message(title, ...)` box, dismissed with Enter. + Dialogs read keys through the application's input hook like every other + widget, so arrow keys are decoded (never leak `[A` into the next view) + and `^C`/`^R` work inside them. - Quitting: only `Ctrl+C` (from anywhere) or "Logga ut / avsluta" ends the app; `Esc`/`q` only navigate. After `Ctrl+C` every screen unwinds, the session is closed and the terminal restored. @@ -358,8 +384,10 @@ only place that touches ncurses. Rules: hints; return `Esc`/`q` to the parent. 3. Use `tui_menu`/`tui_select_list` instead of writing a new loop; pass `allow_new`/`allow_refresh` so the universal keys apply. -4. Forms use the shared editor (`tui_edit_field`, `tui_prompt_into`, - `tui_date_prompt_into`, `tui_amount_prompt_into`) and the row helpers. +4. Forms use `tui_form_run*`/`tui_rt_run`, which edit fields in place; + 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. 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 diff --git a/scripts/tui-golden.py b/scripts/tui-golden.py index 4fee4df..9d6e9ca 100755 --- a/scripts/tui-golden.py +++ b/scripts/tui-golden.py @@ -61,6 +61,8 @@ KEYS = { "f5": "\x1b[15~", "f9": "\x1b[20~", "f2": "\x1bOQ", + "backspace": "\x7f", + "tab": "\t", } # {org_name} {org_nr} {fy_label} {fy_start} {fy_end} are substituted at run @@ -540,6 +542,50 @@ SCENARIOS = [ }, ], }, + { + # 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"], + "expect": ["Ny kund", "<UTKAST>"], + }, + { + "keys": ["enter", "Bakstegskund", "enter"], + "expect": ["Bakstegskund"], + }, + { + "keys": ["enter", "backspace", "x", "enter"], + "expect": ["Bakstegskunx"], + }, + ], + }, + { + # Typing on a header field edits it in place; 'q' is text in both + # header fields and cells, never "back". + "name": "voucher-form-typing", + "screen": "vouchers", + "steps": [ + { + "keys": ["ctrln"], + "expect": ["Nytt verifikat", "Datum", "Konto"], + }, + { + "keys": ["20260315", "enter"], + "expect": ["Nytt verifikat", "2026-03-15"], + }, + { + "keys": ["down", "Qliro q", "enter"], + "expect": ["Nytt verifikat", "Qliro q"], + }, + { + "keys": ["1930", "tab", "tab", "tab", "equity q"], + "expect": ["Nytt verifikat", "1930", "equity q"], + }, + ], + }, ] # -------------------------------------------------------------------------- diff --git a/tests/test_tui.c b/tests/test_tui.c index aa062fc..b48544e 100644 --- a/tests/test_tui.c +++ b/tests/test_tui.c @@ -153,6 +153,108 @@ static void test_field_fresh(void) fresh = 1; CHECK(tui_field_edit(buf, sizeof buf, &pos, KEY_DC, &fresh)); CHECK(strcmp(buf, "bc") == 0); + + /* after Backspace/Del/^U the next printable edits, it never replaces + the whole value (the "text disappears" bug) */ + snprintf(buf, sizeof buf, "Kontor"); + pos = (size_t)-1; + fresh = 1; + CHECK(tui_field_edit(buf, sizeof buf, &pos, KEY_BACKSPACE, &fresh)); + CHECK(fresh == 0); + CHECK(tui_field_edit(buf, sizeof buf, &pos, 'x', &fresh)); + CHECK(strcmp(buf, "Kontox") == 0); + snprintf(buf, sizeof buf, "abc"); + pos = 0; + fresh = 1; + tui_field_edit(buf, sizeof buf, &pos, KEY_DC, &fresh); + tui_field_edit(buf, sizeof buf, &pos, 'x', &fresh); + CHECK(strcmp(buf, "xbc") == 0); + snprintf(buf, sizeof buf, "abc"); + pos = (size_t)-1; + fresh = 1; + tui_field_edit(buf, sizeof buf, &pos, 21, &fresh); + tui_field_edit(buf, sizeof buf, &pos, 'x', &fresh); + tui_field_edit(buf, sizeof buf, &pos, 'y', &fresh); + CHECK(strcmp(buf, "xy") == 0); + /* a key the editor ignores keeps the field fresh */ + snprintf(buf, sizeof buf, "abc"); + pos = (size_t)-1; + fresh = 1; + CHECK(tui_field_edit(buf, sizeof buf, &pos, KEY_F(7), &fresh) == 0); + CHECK(fresh == 1); + /* a multi-byte first key replaces the value once, as one character */ + snprintf(buf, sizeof buf, "abc"); + pos = (size_t)-1; + fresh = 1; + tui_field_edit(buf, sizeof buf, &pos, 0xC3, &fresh); + tui_field_edit(buf, sizeof buf, &pos, 0xA5, &fresh); + CHECK(strcmp(buf, "å") == 0); +} + +static void test_ledit_utf8_full(void) +{ + char buf[4]; + struct tui_ledit e; + snprintf(buf, sizeof buf, "ab"); + tui_le_init(&e, buf, sizeof buf); + /* "å" needs two bytes, only one is free: the whole character is + dropped, including its continuation byte */ + tui_le_key(&e, 0xC3); + tui_le_key(&e, 0xA5); + CHECK(strcmp(buf, "ab") == 0 && e.len == 2); + tui_le_key(&e, 'c'); + CHECK(strcmp(buf, "abc") == 0); + /* a stray continuation byte is never inserted on its own */ + char b2[8] = ""; + tui_le_init(&e, b2, sizeof b2); + tui_le_key(&e, 0xA5); + CHECK(e.len == 0); + tui_le_key(&e, 0xC3); + tui_le_key(&e, 0xA5); + tui_le_key(&e, 0xA5); /* a third byte does not extend a 2-byte "å" */ + CHECK(strcmp(b2, "å") == 0); +} + +static void test_field_scroll(void) +{ + /* fits: no scroll */ + CHECK(tui_field_scroll("abc", 3, 10, 0) == 0); + /* caret at the end of a 10-char text in a 5-column field: 4 chars + before the caret, the caret in the last column */ + CHECK(tui_field_scroll("abcdefghij", 10, 5, 0) == 6); + /* moving the caret left inside the view keeps the scroll */ + CHECK(tui_field_scroll("abcdefghij", 8, 5, 6) == 6); + /* moving left past the view scrolls back to the caret */ + CHECK(tui_field_scroll("abcdefghij", 2, 5, 6) == 2); + /* Home */ + CHECK(tui_field_scroll("abcdefghij", 0, 5, 6) == 0); + /* deleted text: scroll back as far as the tail fits */ + CHECK(tui_field_scroll("abcdef", 6, 5, 4) == 2); + CHECK(tui_field_scroll("abc", 3, 5, 2) == 0); + /* multi-byte: offsets stay on character boundaries */ + const char *s = "åäöåäö"; /* 12 bytes, 6 columns */ + size_t st = tui_field_scroll(s, 12, 4, 0); + CHECK(st == 6 && tui_disp_width(s + st) == 3); + CHECK(tui_field_scroll(s, 12, 4, 7) == 6); /* mid-character start */ + /* a stale start past the end (another cell's text) resets */ + CHECK(tui_field_scroll("ab", 2, 5, 40) == 0); + CHECK(tui_field_scroll("", 0, 1, 0) == 0); +} + +static void test_form_scroll(void) +{ + /* everything fits */ + CHECK(tui_form_scroll(0, 3, 5, 10) == 0); + /* focus below the view scrolls down just enough */ + CHECK(tui_form_scroll(0, 12, 20, 10) == 3); + /* focus inside the view keeps the offset */ + CHECK(tui_form_scroll(3, 5, 20, 10) == 3); + /* focus above the view scrolls up to it */ + CHECK(tui_form_scroll(8, 2, 20, 10) == 2); + /* never scroll past the last line */ + CHECK(tui_form_scroll(15, 19, 20, 10) == 10); + /* a shrunken form clamps */ + CHECK(tui_form_scroll(9, 0, 3, 10) == 0); } static void date_feed(char *buf, size_t cap, const char *digits) @@ -202,6 +304,15 @@ static void test_date_field(void) fresh = 1; CHECK(tui_date_field_edit(buf, sizeof buf, &pos, 21, &fresh)); CHECK(strcmp(buf, "") == 0); + + /* after Backspace the next digit is inserted, not a fresh date */ + snprintf(buf, sizeof buf, "2026-03-15"); + pos = (size_t)-1; + fresh = 1; + tui_date_field_edit(buf, sizeof buf, &pos, KEY_BACKSPACE, &fresh); + CHECK(fresh == 0); + tui_date_field_edit(buf, sizeof buf, &pos, '9', &fresh); + CHECK(strcmp(buf, "2026-03-19") == 0); } static void nav_init(struct tui_list_nav *v, int n) @@ -635,6 +746,19 @@ static void test_rowtable(void) CHECK(t.nrows == 1); tui_rt_clear(&t); CHECK(t.nrows == 1); /* never below one row */ + + /* moving to another cell forgets the old caret and scroll: the next + ←/→ works from the end of the new cell */ + t.nrows = 3; + t.row = 0; + t.pos = 3; + t.hscroll = 5; + t.fresh = 0; + tui_rt_move(&t, 1, 0); + CHECK(t.pos == (size_t)-1 && t.hscroll == 0 && t.fresh == 1); + t.pos = 2; + tui_rt_tab(&t, 1); + CHECK(t.pos == (size_t)-1); } static void test_rt_fields(void) @@ -813,6 +937,9 @@ int main(void) test_parse_kr(); test_ledit(); test_field_fresh(); + test_ledit_utf8_full(); + test_field_scroll(); + test_form_scroll(); test_date_field(); test_styles(); test_markup(); |
