| 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 |