返回 CodeWhale
CONTRIBUTING-COMPONENTS.md
根目录 / vendor / codewhale-ratatui / CONTRIBUTING-COMPONENTS.md
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
160 lines MARKDOWN