-
-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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_historycolors each line by theboolhalf of its(bool, String)tuple).
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.
-
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 aTabkey cyclingTab/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.
-
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.rsand 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.
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.
-
Esc— close the current modal/picker if one is open; otherwise go back a level (tab → screen list, screen → Home).handle_keyreturningtrueis 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_keyso no screen reimplements it:Ctrl+Cquit,F9/Shift+F9cycle theme,F12toggle mouse capture.
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 aMouseEvent,Noneif 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 anyTable/Listrow. It's not justy - 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_hitis the simpler version for an unbordered hand-rendered list ofLines 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.