|
| 1 | +--- |
| 2 | +name: tui-sections-focusing-best-practices |
| 3 | +description: > |
| 4 | + Best practices for implementing focusable sections in a Ratatui/crossterm TUI app. |
| 5 | + Use when adding a new focusable widget, wiring a focus cycle, showing focus indicators, |
| 6 | + or deciding how keybindings should be scoped to focused sections. |
| 7 | +--- |
| 8 | + |
| 9 | +# TUI Section Focus — Best Practices |
| 10 | + |
| 11 | +## Core model |
| 12 | + |
| 13 | +Focus is a property of `AppState`, not of individual widgets. |
| 14 | +One enum variant per focusable section (e.g. `Sidebar`, `UrlBar`, `TabBar`, `Editor`, `ResponseViewer`). |
| 15 | + |
| 16 | +```rust |
| 17 | +#[derive(Debug, Clone, PartialEq, Eq, Default)] |
| 18 | +pub enum Focus { |
| 19 | + Sidebar, |
| 20 | + #[default] |
| 21 | + UrlBar, |
| 22 | + TabBar, |
| 23 | + Editor, |
| 24 | + ResponseViewer, |
| 25 | +} |
| 26 | +``` |
| 27 | + |
| 28 | +`AppState.focus: Focus` is the single source of truth. |
| 29 | +Widgets read it; they never write it. |
| 30 | + |
| 31 | +--- |
| 32 | + |
| 33 | +## Focus cycle |
| 34 | + |
| 35 | +Add `next()` and `prev()` methods to `Focus`. |
| 36 | +Document the order in the comment — it's the canonical reference. |
| 37 | + |
| 38 | +```rust |
| 39 | +/// Cycle: Sidebar → UrlBar → TabBar → Editor → ResponseViewer → Sidebar |
| 40 | +pub fn next(&self) -> Focus { ... } |
| 41 | +pub fn prev(&self) -> Focus { ... } |
| 42 | +``` |
| 43 | + |
| 44 | +- `Tab` calls `self.state.focus = self.state.focus.next()`. |
| 45 | +- `BackTab` calls `.prev()`. |
| 46 | +- Numeric shortcuts (`1`–`N`) jump directly to a section. |
| 47 | + |
| 48 | +When inserting a new section, update *both* `next()` and `prev()`, update |
| 49 | +the doc comment, and add a direct shortcut if the section is frequently used. |
| 50 | + |
| 51 | +--- |
| 52 | + |
| 53 | +## Keybindings scoped to focus |
| 54 | + |
| 55 | +Guard every section-specific keybinding with a focus check. |
| 56 | +Use a `if` guard on the match arm — never rely on mode alone. |
| 57 | + |
| 58 | +```rust |
| 59 | +KeyCode::Left if self.state.focus == Focus::TabBar => { |
| 60 | + self.state.active_tab = self.state.active_tab.prev(); |
| 61 | +} |
| 62 | +KeyCode::Right if self.state.focus == Focus::TabBar => { |
| 63 | + self.state.active_tab = self.state.active_tab.next(); |
| 64 | +} |
| 65 | +``` |
| 66 | + |
| 67 | +This prevents key collisions between sections that share the same key |
| 68 | +(e.g. `Left`/`Right` in UrlBar for cursor vs. TabBar for tab switch). |
| 69 | + |
| 70 | +--- |
| 71 | + |
| 72 | +## Visual focus indicator |
| 73 | + |
| 74 | +Each widget is responsible for rendering its own focus state. |
| 75 | +Receive `state: &AppState` and check `state.focus == Focus::MySection`. |
| 76 | + |
| 77 | +**Border colour** (most common): |
| 78 | +```rust |
| 79 | +let border_style = if state.focus == Focus::Editor { |
| 80 | + Style::default().fg(ACCENT_BLUE) |
| 81 | +} else { |
| 82 | + Style::default().fg(BORDER_INACTIVE) |
| 83 | +}; |
| 84 | +Block::default().borders(Borders::ALL).border_style(border_style) |
| 85 | +``` |
| 86 | + |
| 87 | +**Bracket label** (for tab bars / inline items): |
| 88 | +```rust |
| 89 | +let label = if is_active && state.focus == Focus::TabBar { |
| 90 | + format!("[{name}]") |
| 91 | +} else { |
| 92 | + name.to_string() |
| 93 | +}; |
| 94 | +``` |
| 95 | + |
| 96 | +Avoid flashing: indicator should be based entirely on `state.focus` — no |
| 97 | +animation or timer needed. |
| 98 | + |
| 99 | +--- |
| 100 | + |
| 101 | +## Sub-selection within a section |
| 102 | + |
| 103 | +When a section contains multiple selectable items (tabs, list rows, etc.), |
| 104 | +store the selection in `AppState` alongside `focus`. |
| 105 | + |
| 106 | +```rust |
| 107 | +pub active_tab: ActiveTab, // which tab is selected |
| 108 | +pub focus: Focus, // which section owns keyboard input |
| 109 | +``` |
| 110 | + |
| 111 | +Provide `next()`/`prev()` on the sub-selection enum too. |
| 112 | +Switch sub-selection only when `focus == Focus::ThatSection`. |
| 113 | + |
| 114 | +--- |
| 115 | + |
| 116 | +## Dirty flag discipline |
| 117 | + |
| 118 | +Set `AppState.dirty = true` only when visible state actually changes. |
| 119 | + |
| 120 | +- Key events: set dirty in the event dispatcher (always safe). |
| 121 | +- Sub-selection change: already covered by the key event path. |
| 122 | +- Focus change: covered by Tab/BackTab handler. |
| 123 | +- Tick: set dirty **only** during loading spinner (`RequestStatus::Loading`). |
| 124 | + |
| 125 | +--- |
| 126 | + |
| 127 | +## Checklist for a new focusable section |
| 128 | + |
| 129 | +- [ ] Add variant to `Focus` enum |
| 130 | +- [ ] Update `Focus::next()` and `Focus::prev()` |
| 131 | +- [ ] Update doc comment on `next()` |
| 132 | +- [ ] Add direct numeric shortcut in `handle_normal_key()` if needed |
| 133 | +- [ ] Render focus indicator inside the widget's `render()` function |
| 134 | +- [ ] Guard section-specific keybindings with `focus == Focus::MySection` |
| 135 | +- [ ] If section has sub-selection: add sub-enum + `next()`/`prev()` + store in `AppState` |
0 commit comments