返回 CodeWhale
gallery.rs
1 //! Browse every component in every terminal profile.
2 //!
3 //! cargo run --example gallery # browse interactively
4 //! cargo run --example gallery -- --print dark-256 # print every component once
5 //! cargo run --example gallery -- --dump out/ # write every component x profile
6 //! cargo run --example gallery -- --svg target/readme-buffers # real buffers as SVG
7 //!
8 //! Browsing: ↑↓ choose a component, p / Shift+P change the profile, w /
9 //! Shift+W change the width (the component's own, then 40, 80 and 120
10 //! columns), PgUp/PgDn scroll tall previews, Home/End jump through them,
11 //! q quit. Every area's entries (`src/gallery/*.rs`) are listed.
12 //! `--print` writes ANSI to stdout, so you can see a profile in any terminal
13 //! without raw mode. `--dump` writes `<component>.<profile>.ans` (view with
14 //! `cat`) and `.txt` (glyphs plus the roles each run was painted with).
15 //!
16 //! Profiles: dark-truecolor, dark-graphite, light-truecolor, dark-256,
17 //! light-256, ansi-16, unknown-ground, no-color, ascii.
18
19 use std::io::{self, Write as _};
20 use std::path::Path;
21
22 use codewhale_ratatui::{
23 Depth, KeyHint, KeyHints, Paint, Panel, Picker, PickerItem, PickerState, Role, Theme, gallery,
24 testing::{self, Profile},
25 };
26 use crossterm::{
27 event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
28 execute,
29 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
30 };
31 use ratatui::{
32 Terminal,
33 backend::CrosstermBackend,
34 layout::Rect,
35 text::{Line, Span},
36 widgets::Widget,
37 };
38
39 fn main() -> io::Result<()> {
40 let args: Vec<String> = std::env::args().skip(1).collect();
41 match args.first().map(String::as_str) {
42 Some("--dump") => dump(
43 Path::new(args.get(1).map_or("gallery-out", String::as_str)),
44 false,
45 ),
46 Some("--svg") => dump(
47 Path::new(args.get(1).map_or("target/readme-buffers", String::as_str)),
48 true,
49 ),
50 Some("--print") => print(args.get(1).map(String::as_str)),
51 Some("--help" | "-h") => {
52 println!("usage: gallery [--print [profile] | --dump [dir] | --svg [dir]]");
53 Ok(())
54 }
55 None => browse(),
56 Some(arg) => Err(io::Error::other(format!(
57 "unknown argument {arg}; use --help"
58 ))),
59 }
60 }
61
62 fn profile_arg(name: Option<&str>) -> io::Result<Vec<Profile>> {
63 match name {
64 None => Ok(Profile::ALL.to_vec()),
65 Some(name) => Profile::from_name(name).map(|p| vec![p]).ok_or_else(|| {
66 let names: Vec<_> = Profile::ALL.iter().map(|p| p.name()).collect();
67 io::Error::other(format!(
68 "unknown profile {name}; one of {}",
69 names.join(", ")
70 ))
71 }),
72 }
73 }
74
75 fn print(profile: Option<&str>) -> io::Result<()> {
76 let mut out = io::stdout().lock();
77 for profile in profile_arg(profile)? {
78 let theme = profile.theme();
79 for entry in gallery::entries() {
80 writeln!(out, "\n{} · {}", entry.name, profile.name())?;
81 out.write_all(testing::ansi(&gallery::render(&entry, &theme)).as_bytes())?;
82 }
83 }
84 Ok(())
85 }
86
87 fn dump(dir: &Path, svg: bool) -> io::Result<()> {
88 let entries = gallery::entries();
89 let names: std::collections::BTreeSet<_> = entries.iter().map(|e| e.name).collect();
90 let safe_name = |name: &str| {
91 !name.is_empty()
92 && name
93 .bytes()
94 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
95 };
96 if names.len() != entries.len() || names.iter().any(|name| !safe_name(name)) {
97 return Err(io::Error::other(
98 "gallery entry names must be unique safe basenames",
99 ));
100 }
101 let manifest_path = dir.join("manifest.json");
102 let previous: Vec<serde_json::Value> = if svg && manifest_path.exists() {
103 serde_json::from_slice(&std::fs::read(&manifest_path)?)?
104 } else {
105 Vec::new()
106 };
107 let previous_names = previous
108 .iter()
109 .map(|entry| entry["name"].as_str().filter(|name| safe_name(name)))
110 .collect::<Option<Vec<_>>>()
111 .ok_or_else(|| io::Error::other("invalid name in previous SVG manifest"))?;
112 std::fs::create_dir_all(dir)?;
113 let mut written = 0;
114 for profile in Profile::ALL {
115 let theme = profile.theme();
116 for entry in &entries {
117 let buf = gallery::render(entry, &theme);
118 let stem = format!("{}.{}", entry.name, profile.name());
119 if svg {
120 std::fs::write(
121 dir.join(format!("{stem}.svg")),
122 testing::svg(&buf, &gallery::theme_for(entry, &theme)),
123 )?;
124 written += 1;
125 } else {
126 std::fs::write(dir.join(format!("{stem}.ans")), testing::ansi(&buf))?;
127 std::fs::write(
128 dir.join(format!("{stem}.txt")),
129 testing::styled(&buf, &gallery::theme_for(entry, &theme)),
130 )?;
131 written += 2;
132 }
133 }
134 }
135 if svg {
136 // Only the prior manifest owns obsolete exports; preserve unrelated files.
137 for name in previous_names.iter().filter(|name| !names.contains(**name)) {
138 for profile in Profile::ALL {
139 let path = dir.join(format!("{name}.{}.svg", profile.name()));
140 match std::fs::remove_file(path) {
141 Ok(()) => {}
142 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
143 Err(error) => return Err(error),
144 }
145 }
146 }
147 let manifest: Vec<_> = entries.iter().map(|entry| {
148 serde_json::json!({"name": entry.name, "width": entry.width, "height": entry.height})
149 }).collect();
150 std::fs::write(manifest_path, serde_json::to_string_pretty(&manifest)?)?;
151 }
152 println!("Wrote {written} files to {}", dir.display());
153 Ok(())
154 }
155
156 // Restore the terminal even when construction or drawing fails.
157 struct RestoreTerminal;
158 impl Drop for RestoreTerminal {
159 fn drop(&mut self) {
160 let _ = disable_raw_mode();
161 let _ = execute!(io::stdout(), LeaveAlternateScreen);
162 }
163 }
164
165 fn browse() -> io::Result<()> {
166 enable_raw_mode()?;
167 let _restore = RestoreTerminal;
168 codewhale_ratatui::detect::probe_terminal_background();
169 let mut stdout = io::stdout();
170 execute!(stdout, EnterAlternateScreen)?;
171 let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?;
172 run(&mut terminal)
173 }
174
175 fn run(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
176 let entries = gallery::entries();
177 let items: Vec<PickerItem> = entries.iter().map(|e| PickerItem::new(e.name)).collect();
178 let mut state = PickerState::new(0);
179 // `None` is this terminal as detected; otherwise a forced profile.
180 let mut profile: Option<usize> = None;
181 let mut scroll = 0u16;
182 let mut viewport_height = 1u16;
183 let mut content_height = 1u16;
184 // `None` is the component's own width; otherwise one of `testing::WIDTHS`.
185 let mut width: Option<usize> = None;
186 let mut expanded = false;
187 loop {
188 let theme = profile.map_or_else(Theme::detect, |i| Profile::ALL[i].theme());
189 let profile_name = profile.map_or("this terminal", |i| Profile::ALL[i].name());
190 let forced_width = width.map(|i| testing::WIDTHS[i]);
191 terminal.draw(|frame| {
192 let area = frame.area();
193 let buf = frame.buffer_mut();
194 let hints = KeyHints::new(vec![
195 KeyHint::new(if theme.ascii() { "Up/Down" } else { "↑↓" }, "choose"),
196 KeyHint::new("p", "next profile"),
197 KeyHint::new("PgUp/PgDn", "scroll"),
198 KeyHint::new("w", "next width"),
199 KeyHint::new("f", "expand"),
200 KeyHint::new("q", "quit"),
201 ]);
202 let inner = Panel::new(Depth::Stage)
203 .title("Codewhale components")
204 .aside(profile_name)
205 .hints(&hints)
206 .draw(area, buf, &theme);
207 let rail = Rect {
208 width: if expanded || inner.width < 40 { 0 } else { 20 },
209 ..inner
210 };
211 let rail_inner = Panel::new(Depth::Deep).draw(rail, buf, &theme);
212 state.scroll_into_view(items.len(), rail_inner.height);
213 Picker::new(&items, state).paint(rail_inner, buf, &theme);
214
215 let gap = if rail.width == 0 { 0 } else { 2 };
216 let stage = Rect {
217 x: rail.right().saturating_add(gap),
218 width: inner.width.saturating_sub(rail.width + gap),
219 ..inner
220 };
221 let entry = &entries[state.selected];
222 let render_width = forced_width.unwrap_or(entry.width).min(stage.width);
223 let capacity = if entry.name.contains("scene") {
224 entry.height
225 } else {
226 entry
227 .height
228 .saturating_mul(entry.width.div_ceil(render_width.max(1)))
229 .max(1)
230 };
231 let full = gallery::render_at(entry, &theme, render_width, capacity);
232 content_height = content_rows(&full, entry.height);
233 let canvas = Rect {
234 y: stage.y.saturating_add(2),
235 width: render_width,
236 height: content_height.min(stage.height.saturating_sub(2)),
237 ..stage
238 }
239 .intersection(inner);
240 viewport_height = canvas.height.max(1);
241 scroll = scroll.min(content_height.saturating_sub(canvas.height));
242 let position = if content_height > canvas.height {
243 format!(
244 " · rows {}-{} of {}",
245 scroll + 1,
246 scroll + canvas.height,
247 content_height
248 )
249 } else {
250 String::new()
251 };
252 Line::from(Span::styled(
253 format!(
254 "{} · {}x{}{position}",
255 entry.name, render_width, content_height
256 ),
257 theme.fg(Role::Muted),
258 ))
259 .render(
260 Rect {
261 height: stage.height.min(1),
262 ..stage
263 },
264 buf,
265 );
266 copy_viewport(&full, canvas, scroll, buf);
267 })?;
268 if let Event::Key(key) = event::read()?
269 && key.kind == KeyEventKind::Press
270 {
271 match key.code {
272 KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
273 KeyCode::Up | KeyCode::Char('k') => {
274 state.prev(items.len());
275 scroll = 0;
276 }
277 KeyCode::Down | KeyCode::Char('j') => {
278 state.next(items.len());
279 scroll = 0;
280 }
281 KeyCode::PageDown => scroll = scroll.saturating_add(viewport_height),
282 KeyCode::PageUp => scroll = scroll.saturating_sub(viewport_height),
283 KeyCode::Home => scroll = 0,
284 KeyCode::End => scroll = content_height,
285 KeyCode::Char('f') => {
286 expanded = !expanded;
287 scroll = 0;
288 }
289 KeyCode::Char('p') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
290 profile = match profile {
291 None => Some(0),
292 Some(i) if i + 1 < Profile::ALL.len() => Some(i + 1),
293 Some(_) => None,
294 };
295 }
296 KeyCode::Char('w') if !key.modifiers.contains(KeyModifiers::SHIFT) => {
297 scroll = 0;
298 width = match width {
299 None => Some(0),
300 Some(i) if i + 1 < testing::WIDTHS.len() => Some(i + 1),
301 Some(_) => None,
302 };
303 }
304 KeyCode::Char('W') | KeyCode::Char('w') => {
305 scroll = 0;
306 width = match width {
307 None => Some(testing::WIDTHS.len() - 1),
308 Some(0) => None,
309 Some(i) => Some(i - 1),
310 };
311 }
312 KeyCode::Char('P') | KeyCode::Char('p') => {
313 profile = match profile {
314 None => Some(Profile::ALL.len() - 1),
315 Some(0) => None,
316 Some(i) => Some(i - 1),
317 };
318 }
319 _ => {}
320 }
321 }
322 }
323 }
324
325 // Extra capacity accommodates wrapping; don't let End land in empty capacity.
326 fn content_rows(buf: &ratatui::buffer::Buffer, minimum: u16) -> u16 {
327 let occupied = if buf.area.width == 0 {
328 0
329 } else {
330 buf.content
331 .chunks(usize::from(buf.area.width))
332 .rposition(|row| {
333 row.iter()
334 .any(|cell| cell.symbol().chars().any(|c| !c.is_whitespace()))
335 })
336 .map_or(0, |row| u16::try_from(row + 1).unwrap_or(u16::MAX))
337 };
338 occupied.max(minimum.min(buf.area.height))
339 }
340
341 /// Copy an actual rendered buffer into the visible scroll window.
342 fn copy_viewport(
343 source: &ratatui::buffer::Buffer,
344 area: Rect,
345 offset: u16,
346 dest: &mut ratatui::buffer::Buffer,
347 ) {
348 let area = area.intersection(dest.area);
349 for row in 0..area.height.min(source.area.height.saturating_sub(offset)) {
350 for col in 0..area.width.min(source.area.width) {
351 dest[(area.x + col, area.y + row)] =
352 source[(source.area.x + col, source.area.y + offset + row)].clone();
353 }
354 }
355 }
356
357 #[cfg(test)]
358 mod tests {
359 use super::*;
360 #[test]
361 fn scroll_can_reach_last_row_and_stays_in_viewport() {
362 use ratatui::buffer::Buffer;
363 let mut source = Buffer::empty(Rect::new(0, 0, 2, 66));
364 source[(0, 65)].set_symbol("Z");
365 let mut dest = Buffer::filled(Rect::new(0, 0, 8, 8), ratatui::buffer::Cell::new("."));
366 copy_viewport(&source, Rect::new(3, 2, 2, 3), 63, &mut dest);
367 assert_eq!(dest[(3, 4)].symbol(), "Z");
368 assert_eq!(dest[(2, 4)].symbol(), ".");
369 assert_eq!(dest[(5, 4)].symbol(), ".");
370 }
371
372 #[test]
373 fn end_reaches_the_final_whale_action_without_scrolling_into_empty_capacity() {
374 let entry = gallery::entries()
375 .into_iter()
376 .find(|entry| entry.name == "whale-actions")
377 .unwrap();
378 let theme = Profile::DarkTrue.theme();
379 for width in [80, 84] {
380 let full = gallery::render_at(&entry, &theme, width, 132);
381 let height = content_rows(&full, entry.height);
382 assert_eq!(height, 66);
383 let mut view = ratatui::buffer::Buffer::empty(Rect::new(0, 0, width, 20));
384 let area = view.area;
385 copy_viewport(&full, area, height - 20, &mut view);
386 assert!(testing::text(&view).contains("Calling a connected app"));
387 }
388 }
389 }
390
390 lines RUST