返回 CodeWhale
showcase.rs
1 //! A live component gallery of Codewhale's actual terminal kit.
2 //!
3 //! F1-F6 choose Work, Decisions, Controls, Color, Life and Components.
4 //! F7 profile, F8 motion, F9 palette, F10 next work phase.
5 //! The composer starts focused. Enter queues while working; Escape interrupts
6 //! the illustrative turn and preserves the draft. Ctrl+C closes the gallery.
7 //! Shift+Tab changes permission; Ctrl+X focuses the native workbar.
8 //! Life: Left/Right studies an action; Space plays all seventeen native acts.
9 //! --frames DIR [--profile NAME] [--section NAME] exports actual-buffer SVGs.
10 //! The fixture never executes its displayed command or contacts a provider.
11
12 use std::{
13 io,
14 path::PathBuf,
15 time::{Duration, Instant},
16 };
17
18 use codewhale_ratatui::{
19 ApprovalOutcome, FormOutcome, FrameBudget, MotionMode, OmbreDirection, Paint, PickerOutcome,
20 SegmentedOutcome, SegmentedState, TextInputOutcome, Theme, ToggleOutcome, TuiPalette,
21 VerificationSpinner, WaterPalette, WhaleState, WorkbarOutcome, WorkbarPanel,
22 detect::Appearance,
23 gallery::showcase::{
24 ShowcaseFrame, ShowcasePhase, ShowcaseSection, ShowcaseState, showcase_inputs,
25 },
26 spin,
27 testing::{self, Profile},
28 whale_motion::{ColoredGrid, Stage, Tier, colored_braille},
29 };
30 use crossterm::{
31 event::{
32 self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
33 KeyModifiers,
34 },
35 execute,
36 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
37 };
38 use ratatui::{Terminal, backend::CrosstermBackend, layout::Rect};
39
40 const FRAME_INTERVAL: Duration = Duration::from_millis(200);
41 const FRAME_COUNT: u64 = 128;
42 const ACTION_FRAMES: u64 = 10;
43 const NATIVE_FINISH: Duration = Duration::from_millis(1_600);
44
45 /// One owner for all presentation state and the native whale's Stage. The
46 /// only Instant is supplied by this host; views receive an elapsed Duration.
47 struct Studio {
48 view: ShowcaseState,
49 started: Instant,
50 stage: Stage,
51 life_started: Duration,
52 generation: u64,
53 viewport: Rect,
54 }
55 impl Studio {
56 fn new(now: Instant) -> Self {
57 Self {
58 view: ShowcaseState::new(),
59 started: now,
60 stage: Stage::new(),
61 life_started: Duration::ZERO,
62 generation: 0,
63 viewport: Rect::new(0, 0, 104, 30),
64 }
65 }
66 fn elapsed(&self, now: Instant) -> Duration {
67 now.saturating_duration_since(self.started)
68 }
69 fn advance_phase(&mut self, elapsed: Duration) {
70 let next = match self.view.phase {
71 ShowcasePhase::Working => ShowcasePhase::NeedsYou,
72 ShowcasePhase::NeedsYou => {
73 self.view.approval_open = true;
74 self.view.section = ShowcaseSection::Decisions;
75 return;
76 }
77 ShowcasePhase::Verifying => ShowcasePhase::Done,
78 ShowcasePhase::Done => ShowcasePhase::Working,
79 };
80 self.view.set_phase(next, elapsed);
81 if next == ShowcasePhase::NeedsYou {
82 self.view.section = ShowcaseSection::Decisions;
83 } else if next == ShowcasePhase::Done && !self.view.queued.is_empty() {
84 self.view.followups.push(self.view.queued.remove(0));
85 self.view.set_phase(ShowcasePhase::Working, elapsed);
86 self.view.section = ShowcaseSection::Work;
87 self.view.editing = true;
88 }
89 }
90 fn restart(&mut self, elapsed: Duration) {
91 self.generation = self.generation.saturating_add(1);
92 self.view.set_phase(ShowcasePhase::Working, elapsed);
93 self.view.section = ShowcaseSection::Work;
94 self.view.editing = true;
95 self.view.dock.focused = false;
96 self.view.note = "Restarted the example; your draft is retained.".into();
97 }
98 fn actor(&mut self, now: Instant, area: Rect, theme: &Theme) -> Option<ColoredGrid> {
99 self.viewport = area;
100 let elapsed = self.elapsed(now);
101 if self.view.section == ShowcaseSection::Life && self.view.action_playing {
102 self.view.action = ((elapsed.saturating_sub(self.life_started).as_millis() / 2_000)
103 as usize)
104 % WhaleState::ALL.len();
105 }
106 let size = ShowcaseFrame::new(&self.view, elapsed).whale_size(area);
107 let finite_done = self.view.phase == ShowcasePhase::Done
108 && self.view.phase_elapsed(elapsed) < NATIVE_FINISH;
109 let moving = self.view.active_motion()
110 || (finite_done
111 && self.view.section == ShowcaseSection::Work
112 && self.view.motion == MotionMode::Full);
113 let action = if self.view.section == ShowcaseSection::Life {
114 WhaleState::ALL[self.view.action.min(WhaleState::ALL.len() - 1)]
115 } else {
116 self.view.phase.whale()
117 };
118 let session = if self.view.section == ShowcaseSection::Life {
119 format!("action-study-{}", action.key())
120 } else {
121 format!("component-example-{}", self.generation)
122 };
123 let inputs = showcase_inputs(action, &session);
124 self.stage.observe(Some(&session), inputs, !moving);
125 self.stage
126 .set_visible(size.is_some() && !self.view.approval_open);
127 if moving {
128 self.stage.advance(now);
129 }
130 size.map(|(cols, rows)| {
131 colored_braille(
132 self.stage.director(),
133 cols,
134 rows,
135 theme.caps().appearance != Appearance::Light,
136 )
137 })
138 }
139 fn next_frame_in(&self, now: Instant, area: Rect, theme: &Theme) -> Option<Duration> {
140 let elapsed = self.elapsed(now);
141 if self.view.motion != MotionMode::Full || self.view.approval_open {
142 return None;
143 }
144 let mut budget = FrameBudget::new();
145 if self.view.active_motion() {
146 // One active spinner, one native actor, and one shared water
147 // column ask the same host for a frame. The terminal ceiling
148 // applies to the whole composition rather than each actor.
149 if matches!(
150 self.view.section,
151 ShowcaseSection::Work | ShowcaseSection::Decisions
152 ) && budget.claim_spinner()
153 {
154 let phase = self.view.phase_elapsed(elapsed);
155 budget.request(match self.view.phase {
156 ShowcasePhase::Working => spin::next_frame_in(phase, self.view.motion),
157 ShowcasePhase::Verifying => {
158 VerificationSpinner::next_frame_in(phase, self.view.motion)
159 }
160 _ => None,
161 });
162 }
163 if ShowcaseFrame::new(&self.view, elapsed)
164 .whale_size(area)
165 .is_some()
166 {
167 budget.request(self.stage.cadence(Tier::Terminal));
168 }
169 budget.request(Some(FRAME_INTERVAL));
170 } else if self.view.section == ShowcaseSection::Work
171 && self.view.phase == ShowcasePhase::Done
172 && self.view.phase_elapsed(elapsed) < NATIVE_FINISH
173 {
174 budget.request(Some(FRAME_INTERVAL));
175 }
176 // No frame deadline after a finite completion, in a hidden scene,
177 // while waiting for an answer, or under Reduced/Still motion.
178 let _ = theme;
179 budget
180 .next_frame_in()
181 .map(|d| d.max(Duration::from_secs_f64(1.0 / 6.0)))
182 }
183 fn select_section(&mut self, index: usize) {
184 self.view.section = ShowcaseSection::ALL[index];
185 self.view.editing = self.view.section == ShowcaseSection::Work;
186 self.view.dock.focused = false;
187 self.view.focus = if self.view.section == ShowcaseSection::Controls {
188 2
189 } else {
190 0
191 };
192 }
193 fn key(&mut self, key: KeyEvent, now: Instant) -> bool {
194 if key.kind == KeyEventKind::Release {
195 return false;
196 }
197 if key.kind != KeyEventKind::Press && !self.view.editing {
198 return false;
199 }
200 let elapsed = self.elapsed(now);
201 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
202 return true;
203 }
204 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('r') {
205 self.restart(elapsed);
206 return false;
207 }
208 if let KeyCode::F(n @ 1..=6) = key.code {
209 self.select_section(usize::from(n - 1));
210 return false;
211 }
212 match key.code {
213 KeyCode::F(7) => {
214 cycle_profile(&mut self.view, key.modifiers.contains(KeyModifiers::SHIFT));
215 return false;
216 }
217 KeyCode::F(8) => {
218 self.view.motion = next_motion(self.view.motion);
219 return false;
220 }
221 KeyCode::F(9) => {
222 cycle_palette(&mut self.view, key.modifiers.contains(KeyModifiers::SHIFT));
223 return false;
224 }
225 KeyCode::F(10) => {
226 self.view.editing = false;
227 self.advance_phase(elapsed);
228 return false;
229 }
230 _ => {}
231 }
232 if self.view.approval_open
233 && matches!(
234 self.view.section,
235 ShowcaseSection::Work | ShowcaseSection::Decisions
236 )
237 {
238 match self.view.approval.handle_key(key) {
239 ApprovalOutcome::Chose(id) if self.view.approval.grants(id) => {
240 self.view.set_phase(ShowcasePhase::Verifying, elapsed);
241 self.view.note = "Command approved in the example.".into();
242 }
243 ApprovalOutcome::Chose(_) | ApprovalOutcome::Cancelled => {
244 self.view.approval_open = false;
245 self.view.note = "Waiting for your answer; draft retained.".into();
246 }
247 ApprovalOutcome::Reveal => {
248 self.view.note = "The complete illustrative command is visible.".into();
249 }
250 _ => {}
251 }
252 return false;
253 }
254 if self.view.section == ShowcaseSection::Work {
255 if !key
256 .modifiers
257 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
258 && (key.code == KeyCode::BackTab
259 || (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT)))
260 {
261 self.view.permission = self.view.permission.next();
262 return false;
263 }
264 if key.modifiers == KeyModifiers::ALT && key.code == KeyCode::Char('w') {
265 self.view.readouts.on = true;
266 if self.view.dock.focused {
267 self.view.dock.focused = false;
268 } else {
269 let rows = self.view.workbar().rows;
270 self.view.dock.handle_key(key, &rows, 2);
271 }
272 self.view.editing = !self.view.dock.focused;
273 return false;
274 }
275 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('x') {
276 self.view.readouts.on = true;
277 self.view.dock.panel = WorkbarPanel::Fleet;
278 self.view.dock.focused = false;
279 let rows = self.view.workbar().rows;
280 self.view.dock.handle_key(
281 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
282 &rows,
283 2,
284 );
285 self.view.editing = false;
286 return false;
287 }
288 }
289 let panel_chord = key.modifiers.contains(KeyModifiers::CONTROL)
290 && matches!(
291 key.code,
292 KeyCode::Tab | KeyCode::BackTab | KeyCode::Char(']')
293 );
294 if self.view.section == ShowcaseSection::Work && (!self.view.editing || panel_chord) {
295 // The row facts are the same fixture supplied to the rendered dock.
296 let dock = self.view.workbar();
297 let theme = self.view.theme_for(&self.view.profile.theme());
298 let region = ShowcaseFrame::new(&self.view, elapsed)
299 .work_areas(self.viewport, &theme)
300 .workbar;
301 let visible_rows = dock.layout(region).visible_rows;
302 match self.view.dock.handle_key(key, &dock.rows, visible_rows) {
303 WorkbarOutcome::Close => {
304 self.view.readouts.on = false;
305 self.view.editing = true;
306 return false;
307 }
308 WorkbarOutcome::Panel(_) | WorkbarOutcome::Changed => {
309 self.view.readouts.on = true;
310 return false;
311 }
312 WorkbarOutcome::Activate(id) => {
313 self.view.note = format!("Selected {id}");
314 return false;
315 }
316 WorkbarOutcome::ReleaseFocus => {
317 self.view.editing = true;
318 }
319 WorkbarOutcome::Ignored => {}
320 }
321 }
322 if self.view.editing {
323 match self.view.section {
324 ShowcaseSection::Work => match self.view.draft.handle_key(key) {
325 TextInputOutcome::Submitted => {
326 if !self.view.draft.text().trim().is_empty() {
327 if matches!(
328 self.view.phase,
329 ShowcasePhase::Working | ShowcasePhase::Verifying
330 ) {
331 self.view.queued.push(self.view.draft.text().to_owned());
332 self.view.draft.set_text("");
333 } else {
334 self.view.editing = false;
335 self.view.set_phase(ShowcasePhase::NeedsYou, elapsed);
336 self.view.section = ShowcaseSection::Decisions;
337 }
338 }
339 }
340 TextInputOutcome::Cancelled => {
341 self.view.editing = false;
342 if matches!(
343 self.view.phase,
344 ShowcasePhase::Working | ShowcasePhase::Verifying
345 ) {
346 self.view.set_phase(ShowcasePhase::NeedsYou, elapsed);
347 self.view.approval_open = false;
348 }
349 }
350 _ => {}
351 },
352 ShowcaseSection::Controls => match self.view.form.handle_key(key) {
353 FormOutcome::Submitted => {
354 self.view.editing = false;
355 self.view.note = "Settings applied to this example".into();
356 }
357 FormOutcome::Cancelled => self.view.editing = false,
358 _ => {}
359 },
360 ShowcaseSection::Components => {
361 let before = self.view.query.text().to_owned();
362 match self.view.query.handle_key(key) {
363 TextInputOutcome::Cancelled | TextInputOutcome::Submitted => {
364 self.view.editing = false
365 }
366 _ => {}
367 }
368 if self.view.query.text() != before {
369 self.view.picker.reset();
370 self.view.preview_scroll = 0;
371 }
372 self.catalogue_key(key);
373 }
374 _ => self.view.editing = false,
375 }
376 return false;
377 }
378 match key.code {
379 KeyCode::Esc | KeyCode::Char('q') => return true,
380 KeyCode::Tab | KeyCode::BackTab => {
381 let count = match self.view.section {
382 ShowcaseSection::Controls => 3,
383 ShowcaseSection::Color => 4,
384 _ => 1,
385 };
386 self.view.focus = if key.code == KeyCode::BackTab {
387 (self.view.focus + count - 1) % count
388 } else {
389 (self.view.focus + 1) % count
390 };
391 }
392 KeyCode::Enter => match self.view.section {
393 ShowcaseSection::Work | ShowcaseSection::Components => self.view.editing = true,
394 ShowcaseSection::Controls if self.view.focus == 2 => self.view.editing = true,
395 ShowcaseSection::Controls => self.control_key(key),
396 _ => {}
397 },
398 _ => match self.view.section {
399 ShowcaseSection::Controls => self.control_key(key),
400 ShowcaseSection::Color => self.color_key(key),
401 ShowcaseSection::Life => match key.code {
402 KeyCode::Left | KeyCode::Right => {
403 self.view.action = if key.code == KeyCode::Left {
404 (self.view.action + 16) % 17
405 } else {
406 (self.view.action + 1) % 17
407 };
408 self.view.action_playing = false;
409 }
410 KeyCode::Char(' ') => {
411 self.view.action_playing = !self.view.action_playing;
412 self.life_started = elapsed
413 .saturating_sub(Duration::from_secs(self.view.action as u64 * 2));
414 }
415 _ => {}
416 },
417 ShowcaseSection::Components => self.catalogue_key(key),
418 _ => {}
419 },
420 }
421 false
422 }
423 fn control_key(&mut self, key: KeyEvent) {
424 let toggle = if self.view.focus == 0 {
425 &mut self.view.details
426 } else if self.view.focus == 1 {
427 &mut self.view.readouts
428 } else {
429 return;
430 };
431 if let ToggleOutcome::Toggled(_) = toggle.handle_key(key, true) {
432 self.view.note = "Choice applied.".into();
433 }
434 }
435 fn color_key(&mut self, key: KeyEvent) {
436 match self.view.focus {
437 0 => {
438 let current = WaterPalette::ALL
439 .iter()
440 .position(|v| *v == self.view.palette)
441 .unwrap_or(0);
442 let mut state = SegmentedState::new(current);
443 if let SegmentedOutcome::Selected(index) =
444 state.handle_key(key, WaterPalette::ALL.len(), true)
445 {
446 self.view.palette = WaterPalette::ALL[index];
447 }
448 }
449 1 => {
450 let mut state = SegmentedState::new(match self.view.motion {
451 MotionMode::Full => 0,
452 MotionMode::Reduced => 1,
453 MotionMode::Still => 2,
454 });
455 if let SegmentedOutcome::Selected(index) = state.handle_key(key, 3, true) {
456 self.view.motion =
457 [MotionMode::Full, MotionMode::Reduced, MotionMode::Still][index];
458 }
459 }
460 2 if matches!(key.code, KeyCode::Left | KeyCode::Right) => {
461 cycle_profile(&mut self.view, key.code == KeyCode::Left)
462 }
463 3 if matches!(
464 key.code,
465 KeyCode::Left | KeyCode::Right | KeyCode::Char(' ')
466 ) =>
467 {
468 self.view.direction = match self.view.direction {
469 OmbreDirection::Vertical => OmbreDirection::Diagonal,
470 OmbreDirection::Diagonal => OmbreDirection::Vertical,
471 }
472 }
473 _ => {}
474 }
475 }
476 fn catalogue_key(&mut self, key: KeyEvent) {
477 if matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) {
478 self.view.preview_scroll = if key.code == KeyCode::PageUp {
479 self.view.preview_scroll.saturating_sub(5)
480 } else {
481 self.view.preview_scroll.saturating_add(5)
482 };
483 } else if matches!(
484 key.code,
485 KeyCode::Up | KeyCode::Down | KeyCode::Home | KeyCode::End
486 ) {
487 let (_, _, matches) = self.view.catalogue();
488 if self.view.picker.handle_key(key, matches.len(), 10) != PickerOutcome::Ignored {
489 self.view.preview_scroll = 0;
490 }
491 } else if key.code == KeyCode::Char('/') {
492 self.view.editing = true;
493 }
494 }
495 fn paste(&mut self, value: &str) {
496 if !self.view.editing {
497 return;
498 }
499 match self.view.section {
500 ShowcaseSection::Work => {
501 self.view.draft.paste(value);
502 }
503 ShowcaseSection::Controls => {
504 self.view.form.paste(value);
505 }
506 ShowcaseSection::Components => {
507 self.view.query.paste(value);
508 self.view.picker.reset();
509 }
510 _ => {}
511 }
512 }
513 }
514
515 fn next_motion(mode: MotionMode) -> MotionMode {
516 match mode {
517 MotionMode::Full => MotionMode::Reduced,
518 MotionMode::Reduced => MotionMode::Still,
519 MotionMode::Still => MotionMode::Full,
520 }
521 }
522 fn cycle_profile(state: &mut ShowcaseState, reverse: bool) {
523 let current = Profile::ALL
524 .iter()
525 .position(|p| *p == state.profile)
526 .unwrap_or(0);
527 state.profile = Profile::ALL
528 [(current + if reverse { Profile::ALL.len() - 1 } else { 1 }) % Profile::ALL.len()];
529 }
530 fn cycle_palette(state: &mut ShowcaseState, reverse: bool) {
531 let current = TuiPalette::ALL
532 .iter()
533 .position(|p| *p == state.native_palette)
534 .unwrap_or(0);
535 state.native_palette = TuiPalette::ALL[(current
536 + if reverse {
537 TuiPalette::ALL.len() - 1
538 } else {
539 1
540 })
541 % TuiPalette::ALL.len()];
542 }
543
544 struct Export {
545 dir: PathBuf,
546 profile: Profile,
547 section: Option<ShowcaseSection>,
548 }
549 fn options(args: &[String]) -> io::Result<Option<Export>> {
550 if args.is_empty() {
551 return Ok(None);
552 }
553 let usage = || {
554 io::Error::new(
555 io::ErrorKind::InvalidInput,
556 "usage: showcase [--frames DIR [--profile NAME] [--section work|decisions|controls|color|life|components]]",
557 )
558 };
559 if args.first().map(String::as_str) != Some("--frames")
560 || args.len() < 2
561 || !args.len().is_multiple_of(2)
562 {
563 return Err(usage());
564 }
565 let mut export = Export {
566 dir: PathBuf::from(&args[1]),
567 profile: Profile::DarkTrue,
568 section: None,
569 };
570 let mut seen_profile = false;
571 let mut seen_section = false;
572 for pair in args[2..].as_chunks::<2>().0 {
573 match pair[0].as_str() {
574 "--profile" if !seen_profile => {
575 export.profile = Profile::from_name(&pair[1]).ok_or_else(usage)?;
576 seen_profile = true;
577 }
578 "--section" if !seen_section => {
579 export.section = Some(
580 ShowcaseSection::ALL
581 .into_iter()
582 .find(|s| s.name().eq_ignore_ascii_case(&pair[1]))
583 .ok_or_else(usage)?,
584 );
585 seen_section = true;
586 }
587 _ => return Err(usage()),
588 }
589 }
590 Ok(Some(export))
591 }
592 fn exported_state(studio: &mut Studio, elapsed: Duration, section: Option<ShowcaseSection>) {
593 let ms = elapsed.as_millis();
594 let (phase, started) = if ms < 5_000 {
595 (ShowcasePhase::Working, 0)
596 } else if ms < 9_000 {
597 (ShowcasePhase::NeedsYou, 5_000)
598 } else if ms < 13_000 {
599 (ShowcasePhase::Verifying, 9_000)
600 } else {
601 (ShowcasePhase::Done, 13_000)
602 };
603 if studio.view.phase != phase {
604 studio.view.set_phase(phase, Duration::from_millis(started));
605 }
606 studio.view.section = section.unwrap_or(if phase == ShowcasePhase::NeedsYou {
607 ShowcaseSection::Decisions
608 } else {
609 ShowcaseSection::Work
610 });
611 if section == Some(ShowcaseSection::Life) {
612 studio.view.action_playing = true;
613 studio.view.approval_open = false;
614 }
615 if section == Some(ShowcaseSection::Color) {
616 studio.view.palette = WaterPalette::ALL[((ms / 2_000) as usize) % WaterPalette::ALL.len()];
617 }
618 }
619 fn export_frames(export: Export) -> io::Result<()> {
620 std::fs::create_dir_all(&export.dir)?;
621 let theme = export.profile.theme().tui();
622 let base = Instant::now();
623 let mut studio = Studio::new(base);
624 studio.view.profile = export.profile;
625 let count = if export.section == Some(ShowcaseSection::Life) {
626 WhaleState::ALL.len() as u64 * ACTION_FRAMES
627 } else {
628 FRAME_COUNT
629 };
630 let mut manifest = Vec::new();
631 for index in 0..count {
632 let elapsed = Duration::from_millis(index * 200);
633 exported_state(&mut studio, elapsed, export.section);
634 let area = if matches!(
635 export.section,
636 None | Some(ShowcaseSection::Work | ShowcaseSection::Decisions)
637 ) {
638 Rect::new(0, 0, 104, 30)
639 } else {
640 Rect::new(0, 0, 112, 38)
641 };
642 let actor = studio.actor(base + elapsed, area, &theme);
643 let buf = testing::render(area.width, area.height, |area, buf| {
644 let frame = ShowcaseFrame::new(&studio.view, elapsed);
645 if let Some(actor) = &actor {
646 frame.whale(actor).paint(area, buf, &theme);
647 } else {
648 frame.paint(area, buf, &theme);
649 }
650 });
651 let file = format!("frame-{index:03}.svg");
652 std::fs::write(export.dir.join(&file), testing::svg(&buf, &theme))?;
653 manifest.push(serde_json::json!({"file":file,"elapsed_ms":index*200,"profile":export.profile.name(),"width":area.width,"height":area.height}));
654 }
655 std::fs::write(
656 export.dir.join("manifest.json"),
657 serde_json::to_vec_pretty(&manifest)?,
658 )?;
659 Ok(())
660 }
661
662 struct Restore;
663 impl Drop for Restore {
664 fn drop(&mut self) {
665 let _ = disable_raw_mode();
666 let _ = execute!(io::stdout(), DisableBracketedPaste, LeaveAlternateScreen);
667 }
668 }
669 fn main() -> io::Result<()> {
670 let args: Vec<_> = std::env::args().skip(1).collect();
671 if let Some(export) = options(&args)? {
672 return export_frames(export);
673 }
674 enable_raw_mode()?;
675 let _restore = Restore;
676 execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?;
677 let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
678 let mut studio = Studio::new(Instant::now());
679 loop {
680 let now = Instant::now();
681 let elapsed = studio.elapsed(now);
682 let theme = studio.view.theme_for(&studio.view.profile.theme());
683 let size = terminal.size()?;
684 let area = Rect::new(0, 0, size.width, size.height);
685 let actor = studio.actor(now, area, &theme);
686 terminal.draw(|frame| {
687 let view = ShowcaseFrame::new(&studio.view, elapsed);
688 let cursor = if studio.view.editing && studio.view.section == ShowcaseSection::Work {
689 view.composer_cursor(frame.area(), &theme)
690 } else {
691 None
692 };
693 if let Some(actor) = &actor {
694 view.whale(actor)
695 .paint(frame.area(), frame.buffer_mut(), &theme);
696 } else {
697 view.paint(frame.area(), frame.buffer_mut(), &theme);
698 }
699 if let Some(cursor) = cursor {
700 frame.set_cursor_position(cursor);
701 }
702 })?;
703 if let Some(after) = studio.next_frame_in(now, area, &theme)
704 && !event::poll(after.saturating_sub(now.elapsed()))?
705 {
706 continue;
707 }
708 match event::read()? {
709 Event::Key(key) => {
710 if studio.key(key, Instant::now()) {
711 break;
712 }
713 }
714 Event::Paste(value) => studio.paste(&value),
715 Event::Resize(_, _) => {}
716 _ => {}
717 }
718 }
719 Ok(())
720 }
721
722 #[cfg(test)]
723 mod tests {
724 use super::*;
725 use codewhale_ratatui::whale_motion::acting_for;
726
727 fn key(code: KeyCode) -> KeyEvent {
728 KeyEvent::new(code, KeyModifiers::NONE)
729 }
730
731 #[test]
732 fn quiet_scenes_wait_for_input_and_terminal_animation_has_one_ceiling() {
733 let base = Instant::now();
734 let area = Rect::new(0, 0, 112, 38);
735 let theme = Profile::DarkTrue.theme();
736 let mut studio = Studio::new(base);
737 let now = base + Duration::from_secs(1);
738 studio.actor(now, area, &theme);
739 assert!(
740 studio
741 .next_frame_in(now, area, &theme)
742 .is_some_and(|d| d >= Duration::from_secs_f64(1.0 / 6.0))
743 );
744 for mode in [MotionMode::Reduced, MotionMode::Still] {
745 studio.view.motion = mode;
746 assert_eq!(studio.next_frame_in(now, area, &theme), None);
747 }
748 studio.view.motion = MotionMode::Full;
749 for section in [
750 ShowcaseSection::Controls,
751 ShowcaseSection::Color,
752 ShowcaseSection::Components,
753 ] {
754 studio.view.section = section;
755 assert_eq!(studio.next_frame_in(now, area, &theme), None);
756 }
757 studio.view.section = ShowcaseSection::Work;
758 studio
759 .view
760 .set_phase(ShowcasePhase::NeedsYou, Duration::ZERO);
761 assert_eq!(studio.next_frame_in(now, area, &theme), None);
762 studio.view.set_phase(ShowcasePhase::Done, Duration::ZERO);
763 assert_eq!(
764 studio.next_frame_in(base + Duration::from_secs(4), area, &theme),
765 None
766 );
767 }
768
769 #[test]
770 fn editable_unicode_draft_keeps_letters_and_escape_preserves_it() {
771 let base = Instant::now();
772 let mut studio = Studio::new(base);
773 studio.view.draft.set_text("鲸鱼 cafe\u{301}");
774 assert!(studio.view.editing);
775 assert!(!studio.key(key(KeyCode::Char('q')), base));
776 assert_eq!(studio.view.draft.text(), "鲸鱼 cafe\u{301}q");
777 assert!(!studio.key(key(KeyCode::Esc), base));
778 assert!(!studio.view.editing);
779 assert_eq!(studio.view.draft.text(), "鲸鱼 cafe\u{301}q");
780 assert_eq!(studio.view.phase, ShowcasePhase::NeedsYou);
781 studio.restart(Duration::from_secs(2));
782 assert_eq!(studio.view.draft.text(), "鲸鱼 cafe\u{301}q");
783 }
784
785 #[test]
786 fn composer_focus_queue_permission_and_dock_keys_match_the_visible_chrome() {
787 let now = Instant::now();
788 let mut studio = Studio::new(now);
789 assert!(studio.view.editing && !studio.view.dock.focused);
790 let draft = studio.view.draft.text().to_owned();
791 studio.key(key(KeyCode::Enter), now);
792 assert_eq!(studio.view.queued, [draft]);
793 assert!(studio.view.draft.text().is_empty());
794 assert_eq!(studio.view.phase, ShowcasePhase::Working);
795 studio.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), now);
796 assert_eq!(studio.view.permission.word(), "auto-review");
797 studio.key(
798 KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
799 now,
800 );
801 assert!(studio.view.dock.focused && !studio.view.editing);
802 studio.key(key(KeyCode::Esc), now);
803 assert!(studio.view.editing && !studio.view.dock.focused);
804 studio.view.dock.panel = WorkbarPanel::Tasks;
805 assert_eq!(studio.view.workbar().rows.len(), 2);
806 studio
807 .view
808 .set_phase(ShowcasePhase::Done, Duration::from_secs(6));
809 assert!(
810 studio
811 .view
812 .workbar()
813 .rows
814 .iter()
815 .all(|row| row.tone == codewhale_ratatui::WorkbarTone::Success)
816 );
817 assert_eq!(
818 studio.view.workbar().progress.as_deref(),
819 Some("TODO · 2/2 · done")
820 );
821 }
822
823 #[test]
824 fn dock_preserves_first_typed_key_panel_chords_and_narrow_selection() {
825 let now = Instant::now();
826 let mut studio = Studio::new(now);
827 studio.viewport = Rect::new(0, 0, 40, 28);
828 studio.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), now);
829 assert!(studio.view.dock.focused && studio.view.dock.selected.is_some());
830 studio.key(key(KeyCode::Down), now);
831 assert_eq!(studio.view.dock.offset, 1);
832 let before = studio.view.draft.text().to_owned();
833 studio.key(key(KeyCode::Char('q')), now);
834 assert!(studio.view.editing && !studio.view.dock.focused);
835 assert_eq!(studio.view.draft.text(), format!("{before}q"));
836 studio.key(
837 KeyEvent::new(
838 KeyCode::BackTab,
839 KeyModifiers::CONTROL | KeyModifiers::SHIFT,
840 ),
841 now,
842 );
843 assert_eq!(studio.view.dock.panel, WorkbarPanel::Cost);
844 assert_eq!(studio.view.permission.word(), "ask");
845 for word in ["auto-review", "full access", "ask"] {
846 studio.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), now);
847 assert_eq!(studio.view.permission.word(), word);
848 }
849 studio.key(key(KeyCode::BackTab), now);
850 assert_eq!(studio.view.permission.word(), "auto-review");
851 studio.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT), now);
852 assert_eq!(studio.view.permission.word(), "full access");
853 }
854
855 #[test]
856 fn queued_followup_advances_after_completion_and_restart_keeps_pending_input() {
857 let now = Instant::now();
858 let mut studio = Studio::new(now);
859 let draft = studio.view.draft.text().to_owned();
860 studio.key(key(KeyCode::Enter), now);
861 studio.restart(Duration::from_secs(1));
862 assert_eq!(studio.view.queued, [draft.clone()]);
863 studio
864 .view
865 .set_phase(ShowcasePhase::Verifying, Duration::from_secs(2));
866 studio.advance_phase(Duration::from_secs(6));
867 assert!(studio.view.queued.is_empty());
868 assert_eq!(studio.view.followups, [draft]);
869 assert_eq!(studio.view.phase, ShowcasePhase::Working);
870 assert_eq!(studio.view.phase_started, Duration::from_secs(6));
871 }
872
873 #[test]
874 fn work_surface_shares_native_water_and_light_has_no_underwater_habitat() {
875 use codewhale_ratatui::{OceanColumn, OceanPhase};
876 let state = ShowcaseState::new();
877 let elapsed = Duration::from_millis(1_600);
878 let frame = ShowcaseFrame::new(&state, elapsed);
879 let theme = state.theme_for(&Profile::DarkTrue.theme());
880 let area = Rect::new(0, 0, 104, 30);
881 let regions = frame.work_areas(area, &theme);
882 assert_eq!(regions.composer.height, 3);
883 assert_eq!(regions.workbar.height, 5);
884 let buf = testing::render(area.width, area.height, |a, b| frame.paint(a, b, &theme));
885 let column = OceanColumn::new(elapsed, MotionMode::Full)
886 .phase(OceanPhase::Working)
887 .context_percent(24)
888 .viewport(area);
889 for y in [
890 regions.composer.y + 1,
891 regions.posture.y,
892 regions.metrics.y,
893 regions.workbar.y + 4,
894 ] {
895 assert_eq!(buf[(0, y)].bg, column.color_at_y(y, area, &theme).unwrap());
896 }
897 let text = testing::text(&buf);
898 assert!(text.contains("send after this turn") && text.contains("ctx 24%"));
899 assert!(!text.contains("Shift+Enter newline"));
900 let light = state.theme_for(&Profile::LightTrue.theme());
901 let buf = testing::render(area.width, area.height, |a, b| frame.paint(a, b, &light));
902 for y in 12..regions.conversation.bottom() {
903 assert!((0..area.width).all(|x| buf[(x, y)].symbol() == " "));
904 }
905 }
906
907 #[test]
908 fn decision_waits_for_an_explicit_answer_and_release_does_not_advance() {
909 let base = Instant::now();
910 let mut studio = Studio::new(base);
911 studio.key(key(KeyCode::F(10)), base);
912 assert_eq!(studio.view.phase, ShowcasePhase::NeedsYou);
913 assert!(studio.view.approval_open);
914 let mut release = key(KeyCode::Char('y'));
915 release.kind = KeyEventKind::Release;
916 studio.key(release, base);
917 assert_eq!(studio.view.phase, ShowcasePhase::NeedsYou);
918 studio.key(key(KeyCode::Esc), base);
919 assert_eq!(studio.view.phase, ShowcasePhase::NeedsYou);
920 assert!(!studio.view.approval_open);
921 studio.key(key(KeyCode::F(10)), base);
922 studio.key(key(KeyCode::Char('y')), base + Duration::from_secs(3));
923 assert_eq!(studio.view.phase, ShowcasePhase::Verifying);
924 assert_eq!(studio.view.phase_started, Duration::from_secs(3));
925 }
926
927 #[test]
928 fn action_study_reports_all_seventeen_actual_native_actions() {
929 for action in WhaleState::ALL {
930 let inputs = showcase_inputs(action, "action-study");
931 assert_eq!(
932 acting_for(inputs.presence, inputs.activity.as_ref(), &inputs.context).id(),
933 action.key()
934 );
935 }
936 let args = [
937 "--frames",
938 "frames",
939 "--profile",
940 "light-truecolor",
941 "--section",
942 "life",
943 ]
944 .map(str::to_owned);
945 let export = options(&args).unwrap().unwrap();
946 assert_eq!(export.profile, Profile::LightTrue);
947 assert_eq!(export.section, Some(ShowcaseSection::Life));
948 assert!(options(&["--frames".into()]).is_err());
949 }
950
951 #[test]
952 fn shared_renderer_clips_offset_tiny_and_ascii_frames_in_every_section() {
953 use ratatui::buffer::Buffer;
954 let base = Instant::now();
955 for profile in Profile::ALL {
956 for section in ShowcaseSection::ALL {
957 let mut studio = Studio::new(base);
958 studio.view.section = section;
959 for (width, height) in [(0, 0), (1, 1), (4, 3), (40, 28)] {
960 let mut buffer = Buffer::empty(Rect::new(7, 11, width + 4, height + 4));
961 for cell in &mut buffer.content {
962 cell.set_symbol(".");
963 }
964 let area = Rect::new(9, 13, width, height);
965 ShowcaseFrame::new(&studio.view, Duration::from_secs(2)).paint(
966 area,
967 &mut buffer,
968 &profile.theme(),
969 );
970 for y in buffer.area.top()..buffer.area.bottom() {
971 for x in buffer.area.left()..buffer.area.right() {
972 if !area.contains((x, y).into()) {
973 assert_eq!(buffer[(x, y)].symbol(), ".");
974 }
975 }
976 }
977 if profile == Profile::Ascii {
978 assert!(buffer.content.iter().all(|c| c.symbol().is_ascii()));
979 }
980 }
981 let mut edge = Buffer::empty(Rect::new(u16::MAX - 6, u16::MAX - 5, 6, 5));
982 ShowcaseFrame::new(&studio.view, Duration::ZERO).paint(
983 edge.area,
984 &mut edge,
985 &profile.theme(),
986 );
987 }
988 }
989 }
990
991 #[test]
992 fn completed_hold_is_deterministic_and_native_stage_is_shared_with_renderer() {
993 fn frame(base: Instant, elapsed: Duration) -> ratatui::buffer::Buffer {
994 let mut studio = Studio::new(base);
995 let theme = Profile::DarkTrue.theme();
996 exported_state(&mut studio, elapsed, None);
997 let area = Rect::new(0, 0, 112, 38);
998 let actor = studio.actor(base + elapsed, area, &theme);
999 testing::render(area.width, area.height, |area, buf| {
1000 let view = ShowcaseFrame::new(&studio.view, elapsed);
1001 if let Some(actor) = &actor {
1002 view.whale(actor).paint(area, buf, &theme);
1003 } else {
1004 view.paint(area, buf, &theme);
1005 }
1006 })
1007 }
1008 let base = Instant::now();
1009 assert_eq!(
1010 frame(base, Duration::from_secs(20)),
1011 frame(base + Duration::from_secs(3_600), Duration::from_secs(20))
1012 );
1013 assert_eq!(
1014 frame(base, Duration::from_secs(20)),
1015 frame(base, Duration::from_secs(24))
1016 );
1017 let text = testing::text(&frame(base, Duration::from_secs(20)));
1018 assert!(text.contains("Done"));
1019 assert!(text.contains("Tasks") && text.contains("The session picker is ready for review."));
1020 assert!(!text.contains("queued") && !text.contains("Send now / Edit / Drop"));
1021 }
1022 #[test]
1023 fn native_theme_appearance_and_workbar_keys_share_the_rendered_state() {
1024 let now = Instant::now();
1025 let mut studio = Studio::new(now);
1026 studio.view.native_palette = TuiPalette::ShorelineLight;
1027 let theme = studio.view.theme_for(&Profile::DarkTrue.theme());
1028 assert_eq!(theme.caps().appearance, Appearance::Light);
1029 assert_eq!(theme.native_palette(), Some(TuiPalette::ShorelineLight));
1030 let ctrl = KeyModifiers::CONTROL;
1031 assert!(!studio.key(KeyEvent::new(KeyCode::Char('x'), ctrl), now));
1032 assert_eq!(studio.view.dock.panel, WorkbarPanel::Fleet);
1033 assert!(studio.view.readouts.on && studio.view.dock.focused);
1034 studio.key(key(KeyCode::Right), now);
1035 assert_eq!(studio.view.dock.panel, WorkbarPanel::Jobs);
1036 assert!(!studio.key(key(KeyCode::Esc), now));
1037 assert!(!studio.view.readouts.on);
1038 studio.view.editing = true;
1039 let frame = ShowcaseFrame::new(&studio.view, Duration::ZERO);
1040 let area = Rect::new(0, 0, 80, 24);
1041 assert!(
1042 frame
1043 .composer_cursor(area, &theme)
1044 .is_some_and(|cursor| frame.work_areas(area, &theme).composer.contains(cursor))
1045 );
1046 }
1047 }
1048
1048 lines RUST