| 1 | # Adding a component |
| 2 | |
| 3 | Several packages add components at once. Each package owns its own files and |
| 4 | nothing else, so work never collides. |
| 5 | |
| 6 | ## The contract |
| 7 | |
| 8 | 1. **`Paint`, `Theme` and `Role` only.** A component implements |
| 9 | `Paint::paint(&self, area, buf, &Theme)` and styles every cell with |
| 10 | `theme.fg(Role::..)` / `theme.bg(Role::..)`. Never a raw color: no |
| 11 | `Color::..`, `Rgb(..)` or `Indexed(..)` in `src/components` |
| 12 | (`tests/contract.rs` fails on it). Where grounds do not paint |
| 13 | (`!theme.paints_grounds()`), draw an edge or a mark instead of relying on |
| 14 | a fill; `theme.grounds_differ(a, b)` says when two grounds look alike. |
| 15 | 2. **Every state has a mark and a word.** Color is never the only carrier. |
| 16 | Marks come from `glyphs` with their ASCII form (`glyphs::pick(mark, |
| 17 | theme.ascii())`); caller text goes through `text::display_safe` before it |
| 18 | is measured or painted. |
| 19 | 3. **Words are parameters.** The kit owns no copy beyond English defaults. A |
| 20 | component that shows its own words takes a plain struct of |
| 21 | `Cow<'static, str>` fields with `impl Default` (English), by reference: |
| 22 | `StateWords` and `StatusMark::with_words` are the model. Words the host |
| 23 | already has go in as `impl Into<Cow<'static, str>>` arguments. |
| 24 | 4. **State is a plain struct, keys become an outcome.** A stateful component |
| 25 | exposes a `Copy`/`Clone` state struct, an outcome enum, and |
| 26 | `fn handle_key(&mut self, key: crossterm::event::KeyEvent, ..) -> Outcome` |
| 27 | that ignores `KeyEventKind::Release`. The host decides what an outcome |
| 28 | does. `PickerState::handle_key` and `PickerOutcome` are the model. |
| 29 | 5. **No new runtime dependency without the lead.** `ratatui`, `ratatui-core`, `crossterm`, |
| 30 | `unicode-width`, `unicode-segmentation`, native character decoding through |
| 31 | `serde`/`serde_json` (and `libc` on unix). `tests/contract.rs` and CI |
| 32 | (`cargo tree` must show one ratatui and one crossterm) enforce it. |
| 33 | 6. **Names are unique crate-wide.** Everything `pub` in your module is |
| 34 | re-exported from the crate root, so prefix it: `TextInputWords`, not |
| 35 | `Words`. |
| 36 | 7. **At most two native surface hairlines per frame; no `═`; preserve source |
| 37 | punctuation (native lists use `...`);** ASCII |
| 38 | output stays ASCII. `testing::assert_rules` checks all of it. |
| 39 | |
| 40 | ## Your files |
| 41 | |
| 42 | | Package | Component files (`src/components/`) | Gallery (`src/gallery/`) | Tests (`tests/`) | |
| 43 | |---|---|---|---| |
| 44 | | Input | `text_input.rs`, `form.rs` | `input.rs` | `input.rs` | |
| 45 | | Lists | `list.rs`, `fuzzy.rs`, `empty.rs`, and `picker.rs` | `lists.rs` | `lists.rs` | |
| 46 | | Chrome | `heading.rs`, `tabs.rs`, `toggle.rs`, `segmented.rs`, `keymap.rs`, and `surface.rs`, `hints.rs` | `chrome.rs` | `chrome.rs` | |
| 47 | | Display | `receipt.rs`, `diff.rs`, `tree.rs`, `progress.rs`, and `toast.rs` | `display.rs` | `display.rs` | |
| 48 | | Approval | `approval.rs` | `approval.rs` | `approval.rs` | |
| 49 | | Motion | `motion.rs` | `motion.rs` | `motion.rs` | |
| 50 | |
| 51 | Make an item `pub` in your module and it is exported; no other file changes. |
| 52 | **Never edit** `src/components/mod.rs`, `src/lib.rs`, `src/gallery/mod.rs`, |
| 53 | `src/testing.rs`, `Cargo.toml`, `Cargo.lock`, `src/roles.rs`, `tests/contract.rs`, |
| 54 | `tests/generated.rs`, `tests/snapshots.rs` or another package's files without |
| 55 | the lead. If you need a new `Role`, a testing helper or a dependency, ask. |
| 56 | Stage only the paths you changed. |
| 57 | |
| 58 | ## Gallery entries |
| 59 | |
| 60 | In your `src/gallery/<package>.rs`, add one `Entry` per state worth seeing |
| 61 | (normal, empty, error, narrow, disabled, long and CJK text): |
| 62 | |
| 63 | ```rust |
| 64 | pub(crate) fn entries() -> Vec<Entry> { |
| 65 | vec![Entry { name: "text-input", width: 40, height: 3, draw: text_input }] |
| 66 | } |
| 67 | // fn text_input(area: Rect, buf: &mut Buffer, theme: &Theme) |
| 68 | ``` |
| 69 | |
| 70 | `draw` paints inside the `Rect` it is given and uses only the `Theme`. The |
| 71 | gallery draws it at its own size and at 40, 80 and 120 columns |
| 72 | (`cargo run --example gallery`: `p` profile, `w` width). |
| 73 | |
| 74 | ## Tests |
| 75 | |
| 76 | In your `tests/<package>.rs`, for each component at its real heights: |
| 77 | |
| 78 | ```rust |
| 79 | use codewhale_ratatui::{Paint, Theme, testing}; |
| 80 | use ratatui::{buffer::Buffer, layout::Rect}; |
| 81 | |
| 82 | fn paint(area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 83 | MyComponent::new(/* fixtures */).paint(area, buf, theme); |
| 84 | } |
| 85 | |
| 86 | #[test] |
| 87 | fn my_component() { |
| 88 | testing::assert_rules(4, paint); // 9 profiles x 40/80/120 |
| 89 | insta::assert_snapshot!("my-component", testing::snapshot(4, paint)); |
| 90 | } |
| 91 | ``` |
| 92 | |
| 93 | - `testing::frames(height, paint) -> Vec<Frame>`: every `Profile` at every |
| 94 | width in `testing::WIDTHS` (40, 80, 120), painted on `Background` like the |
| 95 | gallery. `Frame` has `text()`, `styled()`, `label()`, `violations()`. |
| 96 | - `testing::assert_rules(height, paint)`: the rule check over `frames`. |
| 97 | `testing::assert_frames_keep_the_rules(&frames)` for frames you built. |
| 98 | - `testing::snapshot(height, paint) -> String`: `DarkTrue` styled (roles per |
| 99 | run, never hex) plus `NoColor` and `Ascii` as text, each at every width. |
| 100 | `snapshot_all_profiles` records every profile; prefer `snapshot`. |
| 101 | - Also test the state: keys in, outcomes and state out. |
| 102 | |
| 103 | Review snapshot changes (`cargo insta review`, or `INSTA_UPDATE=always cargo |
| 104 | test` and read `git diff tests/snapshots/`); accept only what you meant. |
| 105 | |
| 106 | ## Worked example |
| 107 | |
| 108 | Abridged from `src/components/picker.rs`: words as parameters, state plus |
| 109 | outcome plus `handle_key`, paint through roles. |
| 110 | |
| 111 | ```rust |
| 112 | pub struct PickerItem { |
| 113 | pub label: Cow<'static, str>, // words come in; none live here |
| 114 | pub detail: Option<Cow<'static, str>>, |
| 115 | pub disabled: Option<Cow<'static, str>>, // a disabled row says why |
| 116 | } |
| 117 | |
| 118 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 119 | pub enum PickerOutcome { Ignored, Moved, Chose(usize), Toggled(usize), Cancelled } |
| 120 | |
| 121 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 122 | pub struct PickerState { pub selected: usize, pub offset: usize } |
| 123 | |
| 124 | impl PickerState { |
| 125 | pub fn handle_key(&mut self, key: KeyEvent, len: usize, rows: u16) -> PickerOutcome { |
| 126 | if key.kind == KeyEventKind::Release || len == 0 { return PickerOutcome::Ignored; } |
| 127 | match key.code { |
| 128 | KeyCode::Up => self.prev(len), |
| 129 | KeyCode::Down => self.next(len), |
| 130 | KeyCode::Enter => return PickerOutcome::Chose(self.selected), |
| 131 | KeyCode::Esc => return PickerOutcome::Cancelled, |
| 132 | _ => return PickerOutcome::Ignored, |
| 133 | } |
| 134 | self.scroll_into_view(len, rows); |
| 135 | PickerOutcome::Moved |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | impl Paint for Picker<'_> { |
| 140 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 141 | // The selected row carries a marker, bold and a ground: three cues, so |
| 142 | // it still shows where grounds do not paint. |
| 143 | let marker = glyphs::pick(glyphs::selection_marker(selected), theme.ascii()); |
| 144 | row.push(format!("{marker} "), theme.fg(Role::Primary)); |
| 145 | row.push(text::display_safe(&item.label), theme.fg(Role::Foreground).add_modifier(Modifier::BOLD)); |
| 146 | buf.set_style(rect, theme.bg(Role::Selected)); |
| 147 | } |
| 148 | } |
| 149 | ``` |
| 150 | |
| 151 | ## Before you hand back |
| 152 | |
| 153 | ```sh |
| 154 | cargo fmt --check && cargo clippy --all-targets -- -D warnings |
| 155 | cargo test --test <package> # your own file; CI runs the whole suite |
| 156 | ``` |
| 157 | |
| 158 | Format only your files (`rustfmt --edition 2024 <path>`), not the crate: other |
| 159 | packages' files are in flight. |
| 160 |