# bokftui guidelines Rules for the ncurses client so every view behaves the same. When in doubt, copy the behaviour of the voucher list / voucher form; they are the reference implementations. Inspired by Midnight Commander, htop, mutt and calcurse. ## Session start After login the org picker ("Välj organisation att representera") is always shown; the selected org is fixed for the session (`--org ID` bypasses it for scripts). The fiscal year is chosen from the dashboard and is changeable during the session; the `Räkenskapsår` screen also closes and reopens years there. ## Universal keys | Key | Meaning | |---|---| | `Ctrl+N` | Add: new verifikat (dashboard, voucher list/detail), new mall (Mallar), new fiscal year (year picker), open editor (IB), upload file (Underlag) | | `F5` | Refresh the view | | `Esc` / `q` | Back one level. At the dashboard it does nothing — Esc never exits the app | | `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) | | `1`–`9` | In lists: jump to that row. In menus: activate that item | | `g` | Live goto: "Gå till rad/nummer:" updates the selection as you type digits (backspace steps back); Enter closes the prompt without opening anything | | arrows, PgUp/PgDn, Home/End | Move/scroll; selection always stays visible. Pages stop at the first/last row, they never wrap | | `e` | Edit the shown object (IB, where applicable) | | `a` | Add/upload (Underlag) | | `c` | Correct (voucher detail) | | `d` | Delete/arkivera the selected row (only where the action exists; asks for confirmation) | | `f` | Voucher detail: list the voucher's underlag — Enter opens Granska (text in a pager, PDFs/images in the desktop viewer) or Ladda ned…, `d` removes the link (asks first). Underlag: Enter does the same | | `u` / `b` | Faktura detail: `u` duplicates the invoice into a new draft (same rows, dates reset to today), `b` (unpaid invoices) prefills and posts the payment voucher, then marks the invoice paid | | `Ctrl+F` | Attach a file via the file browser (voucher form and voucher detail) | | `k` | Underlag: link the highlighted attachment to a voucher picked from a list | | `Ctrl+X` | Clear the current row — only inside row editors (never "new") | | `Ctrl+Enter` | Save/post the current form. Needs xterm `modifyOtherKeys` level 2 or the Kitty keyboard protocol (xterm, kitty, foot, WezTerm); gnome-terminal/VTE sends neither, so the hints advertise `F9`, which works everywhere | Every screen prints its keys in the footer via `hints()`. If a key exists, the footer shows it; if the footer shows it, the key works. Control keys are written compactly as `^N`, `^A`, `^C`, `^R` to save width. ## Lists (`select_list`, `menu`) - Rows are numbered `NN. text`, right-aligned so 2- and 3-digit numbers line up. - Verifikation ids are shown concatenated as `series+number` (`V-8`, `A8`), using the org's `series_voucher` (Bolaget → Verifikationsserier) for new vouchers. - The last row may be an action (e.g. `+ Nytt verifikat (Ctrl+N)`); selecting it runs the action instead of opening a detail view. - Selection memory: lists remember the selected row by identity (voucher id), not index, across detail round-trips, refreshes and screen re-entry. - Digits move the highlight; only Enter activates. Menus are the exception: digits activate directly (they are shortcuts). - Empty lists are never a dead end: show a message or keep the add-row. - Menus may be split into sections: an item whose text starts with `\x01` (`TUI_MARK_HEADING`) is a non-selectable header, drawn dim and without a number. Numbering, `1`–`9` and `g` count only the selectable items and continue across sections; arrows/PgUp/PgDn/Home/End skip the headers. A cursor that points at a header snaps to the next selectable item. ## Forms - Layout: bold header fields at the top, then a bold column header, then rows; footer hints carry the keys. - Field navigation: `Tab` / `Shift-Tab` forward/back, arrows Up/Down between rows. `Enter` advances in simple forms; it never saves unless the screen is a one-line prompt. - Focus memory: the screen keeps the focused row in an `int` and passes its address as the last argument to `tui_form_run`, `tui_form_run_hook` and `tui_form_run_actions` (like a list cursor). On entry `*focus` is clamped to a selectable row — out of range snaps to the nearest end, a heading row snaps forward and wraps — and on every return (`Esc`, `F5`, `^Enter`, a chosen action, an edited field) the row is written back, so a re-run after a save or a round-trip through another screen lands on the same row. `NULL` starts at the first focusable row and stores nothing. - The active field is drawn reverse-video and holds the hardware cursor at the caret. The caret is a real position: ←/→/Home/End/Del work inside the field, `Ctrl+U` clears it (see `field_edit`). - 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. - 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, and the caret renders like in any other field. Validation (`util_parse_iso_date`) happens on F5/`^Enter`. - Derived values (account names, balances) are dim and non-editable. - Row tables: always exactly one empty trailing row; entering data appends a new empty row; an empty row followed by another empty row collapses. `Ctrl+X` clears the selected row. `Tab` walks the editable cells and wraps to the next row's first cell (`Shift-Tab` back). A dim footer callback shows one derived line (balance, attachment list) on its own row above the hints (`LINES-2`), never over a table row; `\n` in the text becomes a space. A cell holding one of a fixed set of values (unit, VAT code) is changed with `Enter`, which opens `tui_choice_prompt` from the row table's key hook; the chosen value stays in the same cell buffer as any other row. - Fields with `mask` set are shown as `*` (`tui_form_field.mask`); used for passwords. ACTION fields show their `value` when set, else `(lista)`. - Header fields plus rows: one `tui_rt_run` with `tui_rt_set_fields` draws 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. - 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). `enabled: 1` runs on `Enter`; `0` is dimmed but selectable and shown as `label (reason)`, `Enter` shows the reason in a message; `-1` is a dim non-selectable heading/status row like a menu section. The call returns the field index after an edit, `TUI_FORM_ACTION + i` for a chosen action, or `TUI_FORM_BACK`/`REFRESH`/`SUBMIT`, and stores the row it stopped on in the `int *focus` argument (see focus memory above); `tui_form_hint()` overrides the footer of the next run as usual. - `Bokslut` is the year-end hub in one view: the year fields, the status heading `Bokslut bokfört: ja/nej` (derived from the `Skatt på årets resultat` voucher that `bokslut.post` creates; a closed year counts as posted) and the actions Årsredovisning (K2), Inkomstdeklaration (INK2/SRU), Bokslutsplan (torrkörning) and Bokför bokslut — the last dimmed with a reason when the year is closed or already posted. `F5` shows the plan, `Ctrl+Enter` posts it. - Validation: `F5` validates without writing and reports exactly what is wrong (field, row number). Server `dry_run` is used where available. - Saving: `Ctrl+Enter` writes, shows a confirmation message, and returns to the previous view. `Esc` cancels without saving. - Screen-specific keys (template picker, attach, link, download) are handled in a key hook, never with a local loop. The hook runs for keys the widget itself does not handle and returns `TUI_HOOK_*`: `STAY` (consumed, redraw), `BACK`, `REFRESH` or `SUBMIT` (the widget returns as on `Esc`/`F5`/ `Ctrl+Enter`). Hooks exist on `tui_form_run_hook`, `tui_rt_set_key`, `tui_pager_hook` and `tui_select_list_hook`. - File browser (`file_browser`): starts in the directory where the last attachment was picked (remembered in `tui.conf`), falling back to `$HOME` when it is gone (a leading `~` is expanded to `$HOME`), `.. (uppåt)` is the first row, directories sort first with a trailing `/`, hidden files are skipped. Enter enters a directory or picks a file; `Esc` cancels. Picking a file remembers its directory. Selected files are uploaded immediately as unlinked underlag and linked when the voucher is posted. ## Messages - Info/confirmation: `message(title, ...)` box, dismissed with Enter. - 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. - Errors: `show_error(title, resp)` prints `CODE: message` from the server error object; if the response is missing/empty it says "Inget svar från servern (kör daemonen?)". Never render an empty error. - Irreversible actions (close year, lock, archive) ask first. ## Layout and text - Screen frame: title top-left, dim status line under it (`org | start - end | role | user`), hints in the last line. - Columns are padded with `pad_field()` (UTF-8 display width) and truncated to their column; amounts right-aligned via `kr_format()`; never pad with `%s` widths directly on user text. - Selection = reverse video, headers = bold, derived data = dim. Drawing uses the semantic styles below, never raw `A_BOLD`/`A_DIM`. ## Theme and styles `clients/tui.c` owns the theme. The widget layer (and only the widget layer) calls `tui_style(enum tui_style)` to set a semantic style and `tui_style_reset()` to return to the terminal default: | Style | Meaning | |---|---| | `TUI_TITLE` | screen title in the frame | | `TUI_HEADING` | headings, column headers, form labels | | `TUI_SUBHEADING` | secondary headings | | `TUI_DIM` | derived/secondary data, section headers | | `TUI_ACCENT` | interactive prompts (goto) | | `TUI_POS` / `TUI_NEG` | positive/negative amounts | | `TUI_RULE` | full-width rules | | `TUI_STATUS` | status line, hints, footers | Colours are initialised in `tui_keys_setup()`: a small set of pairs (cyan headings, yellow accent, green/red amounts, blue rules) on the default background. If the terminal has no colour support or `NO_COLOR` is set in the environment, every style falls back to attributes (bold/dim) via the pure `tui_style_attrs(style, colours_ok, no_color)`; reverse video for the selection is kept in both modes. ## Pager markup Pager text may carry a one-byte leading marker per line (set by the report formatters, interpreted by `tui_markup`): | Marker | Effect | |---|---| | `\x01` | line is a heading (`TUI_HEADING`) | | `\x02` | line is dimmed (`TUI_DIM`) | | `\x03` | full-width `ACS_HLINE` rule (`TUI_RULE`); the rest of the line is ignored | | `\x04` | line is pinned as a column header: the pager keeps it visible above the scrolling body (combine with another marker, e.g. `\x04\x01`) | Report formatters mark headings, sums/derived lines and rules before the text reaches `tui_pager`; the markers never change column widths. Anything that saves pager text to a file strips the markers first (`strip_markup()` in `bokftui.c`). ## Widget layer (`clients/tui.[ch]`) Every element a screen needs is a widget here, and the widget layer is the only place that touches ncurses. Rules: - Screens never call ncurses directly (`mvadd*`, `attron`, `getch`…) and never keep a local one-off input or drawing loop. - If no widget fits, **extend the widget layer first** (spec in this file + unit tests in `tests/test_tui.c`) and then use it. There is no "do it locally once" option. - Pure logic (line editing, date input, list navigation, prompts parsing) is separated from drawing so it can be tested without a terminal. - Widgets return key codes (`-1` back, `-2` refresh, `-4` new, `-6` remove, `-7` toggle) and write text into the caller's buffer; nothing returns static storage. `TUI_NAV_NONE`, `TUI_FORM_BACK/REFRESH/SUBMIT` and the `TUI_HOOK_*` codes are all distinct. - `tui_pager_hook` takes `TUI_PAGER_SAVE` to enable the `s` save action; the `extra_hint` is shown in the footer. Plain `tui_pager` keeps `s` on when a save hint is passed. Pager lines go through `tui_markup` (see below). - Styles come from `tui_style`/`tui_style_attrs`; report markup and menu sections from `tui_markup`/`TUI_MARK_HEADING`. Their fallbacks and the navigation over section headers are unit-tested in `tests/test_tui.c`. - `tui_redraw` flushes a `tui_frame`/`tui_set_status` pair drawn by the application outside a widget loop (startup/reconnect messages). - The application supplies input, frame/hints and quit through `tui_set_input`/`tui_set_screen`/`tui_set_quit`. ## Adding a view — checklist 1. Data comes from public protocol commands only. 2. Wrap the screen in `frame()`/`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. 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.