Skip to content

TUI Conventions

or1k edited this page Aug 6, 2026 · 1 revision

TUI Conventions

Shared building blocks every screen uses, so the app reads as one tool instead of ten independently-styled ones. Live in src/tui/widgets.rs, src/tui/theme.rs, and src/tui/mouse.rs.

Colors: always through theme.rs, never a literal Color

widgets.rs re-exports one function per palette role — bg(), bg2(), bg3(), border(), title_color(), fg(), fg2(), accent(), green(), red(), yellow(). Each reads the current theme fresh on every call (theme::palette()), which is what makes F9 (cycle theme, Shift+F9 to go back) repaint every open screen immediately with no extra plumbing. Using a literal Color::Rgb(...)/Color::Cyan anywhere in a screen breaks that — it'll look right under the default theme and wrong under every other one.

Role meanings, so you pick the right one:

  • bg() / bg2() / bg3() — increasing "elevation": full-screen background, then a modal's background, then an unfocused input field's box (needs to stand out against either of the first two).
  • fg() / fg2() — primary text vs. secondary/label/hint text.
  • accent() — focused input fields, focused buttons, active tab underline.
  • green()/red()/yellow() — success/error/warning, e.g. History log lines (draw_history colors each line by the bool half of its (bool, String) tuple).

Text input: widgets::Input

A small hand-rolled single-line text buffer (text: String, cursor: usize char index) with insert/backspace/delete/left/right/home/ end_of_line. Every form field in every screen is one of these. Render it with input_span(&input, is_focused, password: bool, field_w: usize) — handles horizontal scroll-to-keep-cursor-visible and masking (password: true renders * per character) for you; don't hand-render an Input's text yourself.

Buttons and tabs

  • btn_span(label, focused)[ Label ], highlighted when focused.
  • tab_span(label, active) → bare underlined text when active, dim otherwise — this is what tab bars (F1/F2/F3-style, or a Tab key cycling Tab/Ctrl+Tab) render.

Both are meant to be joined into one Line with a two-space gap between each — mouse::label_row_hit/button_row_hit hard-code that spacing when hit-testing a click, so don't lay out a button/tab row with different spacing or clicks on it will land on the wrong button.

Modals

  • draw_modal(f, title, msg, area) — a simple centered text-only dialog ("Enter / Esc to close" is appended for you), for one-off errors/confirmations.
  • For anything with its own state/interaction (pick a host, pick a file, pick privileges), see host_picker.rs/file_picker.rs/priv_picker.rs and reuse one rather than building a bespoke modal.
  • centered_rect(width, height, area) if you need to place a fully custom modal — clamps so it never overflows a small terminal.

The History panel

Nearly every screen has at least one scrolling log of past actions — draw_history(f, history: &[(bool, String)], area, scroll_up_offset). The bool is "was this line a success" (colors it green vs. red); scroll_up_offset is measured from the bottom so it stays meaningful as new lines keep appending while the user is scrolled up looking at old ones — Ctrl+↑/Ctrl+↓ is the established keybinding for scrolling it. copy_history_to_clipboard(history) copies the whole log (not just what's currently visible) as plain text — bound to Ctrl+Y everywhere it appears, because with mouse capture on (F12), the terminal's own native click-drag-select stops working, so this is the only way to get log output onto the clipboard at that point.

Keyboard conventions to follow

  • Esc — close the current modal/picker if one is open; otherwise go back a level (tab → screen list, screen → Home). handle_key returning true is exactly "go back to Home from the top level."
  • Tab / Shift+Tab — move focus forward/back through form fields.
  • Enter — activate the focused button, or (list/table row focused) open/select it.
  • Ctrl+↑ / Ctrl+↓ — scroll a History/log panel without moving field focus.
  • Ctrl+Y — copy History panel to clipboard.
  • Global, handled once in App::handle_key so no screen reimplements it: Ctrl+C quit, F9/Shift+F9 cycle theme, F12 toggle mouse capture.

Mouse conventions (tui/mouse.rs)

Mouse capture is off by default (App::mouse_enabled = false) — deliberately, so a plain click-drag does native terminal text selection and the terminal's own copy shortcut just works without the app's involvement. F12 toggles it on for click/scroll support. Every mouse capability this app has is a second way to reach something already reachable by keyboard — never make mouse-only functionality.

Each screen does its own hit-testing against the same Rects its draw() already computed via Layout::split — there's no generic widget tree to query. Reuse the helpers rather than writing new coordinate math:

  • left_click(me) / scroll_delta(me) — pull the relevant event out of a MouseEvent, None if it's not that kind of event.
  • label_row_hit / button_row_hit — which tab/button in a row a click landed on (see spacing note above).
  • table_row_hit(x, y, table_area, header_rows, row_count, selected) — the one to reach for for any Table/List row. It's not just y - top: it replicates ratatui's own auto-scroll-to-keep-selection-visible formula, because without that, clicking a row past the first screenful (once the list had auto-scrolled to keep the selection visible) selected a different row than the one under the cursor. plain_row_hit is the simpler version for an unbordered hand-rendered list of Lines with no scrolling.
  • in_rect / block_inner — basic point-in-rect and "inside a bordered Block's content area" helpers.

A single physical click can arrive as more than one Down event depending on the terminal — App::handle_mouse already debounces this globally (150ms same-position window, see CLICK_DEBOUNCE in tui/mod.rs), so individual screens don't need to guard against it themselves.

Clone this wiki locally