返回 CodeWhale
showcase.rs
根目录 / vendor / codewhale-ratatui / src / gallery / showcase.rs
1 //! An authored, interactive specimen of the public kit. All state is supplied
2 //! by a host; no provider, filesystem, execution or timer lives in this view.
3 use std::time::Duration;
4
5 use ratatui::{
6 buffer::Buffer,
7 layout::{Margin, Rect},
8 style::Modifier,
9 text::{Line, Span},
10 };
11
12 use super::Entry;
13 use crate::{
14 ApprovalCard, ApprovalChoice, ApprovalEffect, ApprovalKey, ApprovalKind, ApprovalScope,
15 ApprovalState, ApprovalSubject, ChoiceId, Depth, Diff, DiffGutter, DiffWrap, Form, FormField,
16 FormState, Habitat, HabitatDensity, HorizonRule, Message, MetricKind, MetricSegment,
17 MetricsLine, MotionMode, NativeComposer, NativeComposerDensity, OceanColumn, OceanPhase, Ombre,
18 OmbreDirection, Paint, PaneHeader, Picker, PickerItem, PickerMatches, PickerState, PostureBar,
19 Receipt, ReceiptValue, Role, Segmented, SegmentedState, SettingDetail, SettingRow, Spinner,
20 State, StatusMark, TerminalShell, TextInputState, Theme, Toggle, ToggleState, TuiGround,
21 TuiInk, TuiPalette, VerificationSpinner, WaterPalette, Whale, WhaleState, Workbar,
22 WorkbarAgent, WorkbarPanel, WorkbarRow, WorkbarState, WorkbarTab, WorkbarTone,
23 WorkflowProgress, WorkflowRun, WorkflowRunState, parse_unified,
24 testing::Profile,
25 text,
26 whale_motion::{Activity, ColoredGrid, Context, Inputs, Presence, Stage, colored_braille},
27 };
28
29 /// Rows for the standalone workbar gallery. The Work view uses
30 /// [`ShowcaseState::workbar`] for its phase-aware fixture.
31 #[must_use]
32 pub fn workbar_rows(panel: WorkbarPanel) -> Vec<crate::WorkbarRow> {
33 super::workbar::sample(panel).rows
34 }
35
36 /// The six destinations of the demonstration. The last exposes every
37 /// existing catalogue entry, rather than selecting a second component list.
38 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
39 pub enum ShowcaseSection {
40 #[default]
41 Work,
42 Decisions,
43 Controls,
44 Color,
45 Life,
46 Components,
47 }
48 impl ShowcaseSection {
49 pub const ALL: [Self; 6] = [
50 Self::Work,
51 Self::Decisions,
52 Self::Controls,
53 Self::Color,
54 Self::Life,
55 Self::Components,
56 ];
57 pub const fn name(self) -> &'static str {
58 match self {
59 Self::Work => "Work",
60 Self::Decisions => "Decisions",
61 Self::Controls => "Controls",
62 Self::Color => "Color",
63 Self::Life => "Life",
64 Self::Components => "Components",
65 }
66 }
67 pub fn index(self) -> usize {
68 Self::ALL.iter().position(|v| *v == self).unwrap_or(0)
69 }
70 }
71
72 /// Explicit host-owned fixture phases. Advancing this never runs a command.
73 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
74 pub enum ShowcasePhase {
75 #[default]
76 Working,
77 NeedsYou,
78 Verifying,
79 Done,
80 }
81 impl ShowcasePhase {
82 pub const fn word(self) -> &'static str {
83 match self {
84 Self::Working => "Working",
85 Self::NeedsYou => "Needs you",
86 Self::Verifying => "Verifying",
87 Self::Done => "Done",
88 }
89 }
90 pub const fn state(self) -> State {
91 match self {
92 Self::Working | Self::Verifying => State::Working,
93 Self::NeedsYou => State::NeedsYou,
94 Self::Done => State::Done,
95 }
96 }
97 pub const fn whale(self) -> WhaleState {
98 match self {
99 Self::Working => WhaleState::Write,
100 Self::NeedsYou => WhaleState::NeedsYou,
101 Self::Verifying => WhaleState::Run,
102 Self::Done => WhaleState::Done,
103 }
104 }
105 }
106
107 /// Native permission labels for the specimen. These never grant the gallery
108 /// host access to a command, provider or filesystem operation.
109 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
110 pub enum ShowcasePermission {
111 #[default]
112 Ask,
113 AutoReview,
114 FullAccess,
115 }
116 impl ShowcasePermission {
117 pub const fn next(self) -> Self {
118 match self {
119 Self::Ask => Self::AutoReview,
120 Self::AutoReview => Self::FullAccess,
121 Self::FullAccess => Self::Ask,
122 }
123 }
124 pub const fn word(self) -> &'static str {
125 match self {
126 Self::Ask => "ask",
127 Self::AutoReview => "auto-review",
128 Self::FullAccess => "full access",
129 }
130 }
131 pub const fn ink(self) -> TuiInk {
132 match self {
133 Self::Ask => TuiInk::PermissionAsk,
134 Self::AutoReview => TuiInk::PermissionAutoReview,
135 Self::FullAccess => TuiInk::PermissionFullAccess,
136 }
137 }
138 }
139
140 /// State kept by the example host. Its clock is an elapsed Duration, supplied
141 /// to the renderer; the fields do not infer account, cost or execution state.
142 #[derive(Clone, Debug)]
143 pub struct ShowcaseState {
144 pub section: ShowcaseSection,
145 pub phase: ShowcasePhase,
146 pub phase_started: Duration,
147 pub palette: WaterPalette,
148 pub direction: OmbreDirection,
149 pub profile: Profile,
150 pub motion: MotionMode,
151 pub focus: usize,
152 pub editing: bool,
153 pub permission: ShowcasePermission,
154 pub queued: Vec<String>,
155 pub followups: Vec<String>,
156 pub details: ToggleState,
157 pub readouts: ToggleState,
158 pub draft: TextInputState,
159 pub form: FormState,
160 pub approval: ApprovalState,
161 pub approval_open: bool,
162 pub note: String,
163 pub action: usize,
164 pub action_playing: bool,
165 pub query: TextInputState,
166 pub picker: PickerState,
167 pub preview_scroll: u16,
168 pub dock: WorkbarState,
169 pub native_palette: TuiPalette,
170 }
171 impl Default for ShowcaseState {
172 fn default() -> Self {
173 Self::new()
174 }
175 }
176 impl ShowcaseState {
177 /// Resolve one palette for the character, surface and exported frame.
178 #[must_use]
179 pub fn theme_for(&self, input: &Theme) -> Theme {
180 let palette = if self.native_palette == TuiPalette::Underwater
181 && input.caps().appearance == crate::detect::Appearance::Light
182 {
183 TuiPalette::WhaleLight
184 } else {
185 self.native_palette
186 };
187 super::tui_palettes::theme_for(palette, input)
188 }
189
190 pub fn new() -> Self {
191 Self {
192 section: ShowcaseSection::Work,
193 phase: ShowcasePhase::Working,
194 phase_started: Duration::ZERO,
195 palette: WaterPalette::Ocean,
196 direction: OmbreDirection::Diagonal,
197 profile: Profile::DarkTrue,
198 motion: MotionMode::Full,
199 focus: 0,
200 editing: true,
201 permission: ShowcasePermission::Ask,
202 queued: Vec::new(),
203 followups: Vec::new(),
204 details: ToggleState::from(false),
205 readouts: ToggleState::from(true),
206 draft: TextInputState::with_text("Also preserve the search shortcut."),
207 form: FormState::new(vec![
208 FormField::text("Project")
209 .value("codewhale-ratatui")
210 .validate(project_name),
211 FormField::text("Result title")
212 .value("A quieter workspace")
213 .validate(project_name),
214 FormField::check("Keep the receipt", true),
215 FormField::read_only("Execution", "Preview"),
216 ]),
217 approval: approval_choices(),
218 approval_open: false,
219 note: "Choose a component to explore.".into(),
220 action: 0,
221 action_playing: false,
222 query: TextInputState::new(),
223 picker: PickerState::default(),
224 preview_scroll: 0,
225 dock: WorkbarState::default(),
226 native_palette: TuiPalette::Underwater,
227 }
228 }
229 pub fn set_phase(&mut self, phase: ShowcasePhase, elapsed: Duration) {
230 self.phase = phase;
231 self.phase_started = elapsed;
232 self.approval_open = phase == ShowcasePhase::NeedsYou;
233 if self.approval_open {
234 self.approval = approval_choices();
235 }
236 }
237 pub fn phase_elapsed(&self, elapsed: Duration) -> Duration {
238 elapsed.saturating_sub(self.phase_started)
239 }
240
241 /// The work view and its keyboard host consume the same phase-aware rows.
242 #[must_use]
243 pub fn workbar(&self) -> Workbar {
244 let mut dock = super::workbar::sample(self.dock.panel);
245 let done = self.phase == ShowcasePhase::Done;
246 let live = if done {
247 WorkbarTone::Success
248 } else {
249 WorkbarTone::Live
250 };
251 let mark = if done { "✓" } else { "●" };
252 dock.rows = match self.dock.panel {
253 WorkbarPanel::Tasks => vec![
254 WorkbarRow::new("session:selection", "Preserve session selection")
255 .mark("✓")
256 .tone(WorkbarTone::Success),
257 WorkbarRow::new("session:layout", "Refine the narrow layout")
258 .mark(mark)
259 .tone(live),
260 ],
261 WorkbarPanel::Fleet => vec![
262 WorkbarRow::new("worker:build", "Builder").mark(mark).agent(
263 WorkbarAgent::new(
264 "builder",
265 if done { "completed" } else { "running" },
266 "Keep the session list readable in compact windows",
267 )
268 .elapsed_seconds(6),
269 ),
270 WorkbarRow::new("worker:review", "Reviewer")
271 .mark("✓")
272 .agent(
273 WorkbarAgent::new(
274 "reviewer",
275 "completed",
276 "Check search and keyboard selection",
277 )
278 .elapsed_seconds(3),
279 ),
280 ],
281 WorkbarPanel::Jobs => vec![
282 WorkbarRow::new("shell:layout", "Check the compact session layout")
283 .mark(mark)
284 .tone(live),
285 ],
286 WorkbarPanel::Files => vec![
287 WorkbarRow::new("files:edited", "Edited 2")
288 .mark("▾")
289 .tone(WorkbarTone::Heading)
290 .selectable(false),
291 WorkbarRow::new("files:list", "src/components/native_views.rs")
292 .mark("✎")
293 .tone(WorkbarTone::Success)
294 .detail("+18 -8"),
295 WorkbarRow::new("files:gallery", "src/gallery/native_views.rs")
296 .mark("✎")
297 .tone(WorkbarTone::Success)
298 .detail("+6 -2"),
299 ],
300 WorkbarPanel::Notes => vec![
301 WorkbarRow::new("notes:1", "Keep search visible at every width")
302 .mark("▪")
303 .tone(WorkbarTone::Live),
304 WorkbarRow::new("notes:2", "Preserve selection while filtering")
305 .mark("▪")
306 .tone(WorkbarTone::Live),
307 ],
308 WorkbarPanel::Context => vec![
309 WorkbarRow::new("context:budget", "30.7k of 128k · 24% · compacts at 90%")
310 .mark("◔")
311 .tone(WorkbarTone::Live),
312 WorkbarRow::new("context:system", "system + tools · 8.4k")
313 .mark("·")
314 .selectable(false),
315 WorkbarRow::new("context:conversation", "conversation + output · 22.3k")
316 .mark("·")
317 .selectable(false),
318 WorkbarRow::new("context:compact", "compact now")
319 .mark("▸")
320 .tone(WorkbarTone::Live),
321 ],
322 WorkbarPanel::Git => vec![
323 WorkbarRow::new("git:branch", "session-layout · up to date")
324 .mark("⎇")
325 .tone(WorkbarTone::Live)
326 .detail("codewhale-ratatui"),
327 WorkbarRow::new("git:changes", "2 modified files")
328 .mark("±")
329 .tone(WorkbarTone::Live)
330 .detail("/diff"),
331 ],
332 WorkbarPanel::Cost => dock.rows,
333 };
334 if self.dock.panel == WorkbarPanel::Tasks {
335 dock.goal = Some("Make session navigation feel effortless".into());
336 dock.progress = Some(
337 if done {
338 "TODO · 2/2 · done"
339 } else {
340 "TODO · 1/2 · 1 left"
341 }
342 .into(),
343 );
344 }
345 dock.tabs = WorkbarPanel::ORDER
346 .into_iter()
347 .map(|panel| match panel {
348 WorkbarPanel::Tasks
349 | WorkbarPanel::Fleet
350 | WorkbarPanel::Files
351 | WorkbarPanel::Notes => WorkbarTab::new(panel).count(2),
352 WorkbarPanel::Jobs => WorkbarTab::new(panel).count(1),
353 _ => WorkbarTab::new(panel),
354 })
355 .collect();
356 self.dock.apply(dock)
357 }
358 pub fn active_motion(&self) -> bool {
359 self.motion == MotionMode::Full
360 && !self.approval_open
361 && match self.section {
362 ShowcaseSection::Work => matches!(
363 self.phase,
364 ShowcasePhase::Working | ShowcasePhase::Verifying
365 ),
366 ShowcaseSection::Decisions => self.phase == ShowcasePhase::Verifying,
367 ShowcaseSection::Life => self.action_playing,
368 _ => false,
369 }
370 }
371 pub fn catalogue(&self) -> (Vec<Entry>, Vec<PickerItem>, PickerMatches) {
372 let entries: Vec<_> = super::entries()
373 .into_iter()
374 .filter(|e| !e.name.starts_with("showcase-"))
375 .collect();
376 let items: Vec<_> = entries
377 .iter()
378 .map(|e| PickerItem::new(e.name).detail(format!("{} x {}", e.width, e.height)))
379 .collect();
380 let matches = PickerMatches::rank(&items, self.query.text(), None);
381 (entries, items, matches)
382 }
383 }
384 fn project_name(value: &str) -> Result<(), std::borrow::Cow<'static, str>> {
385 if value.trim().is_empty() {
386 Err("Give this example a name.".into())
387 } else {
388 Ok(())
389 }
390 }
391
392 /// Typed inputs for the explicitly labeled action study. These describe
393 /// authored specimen facts; they are never inferred from a command or log.
394 pub fn showcase_inputs(action: WhaleState, identity: &str) -> Inputs {
395 let (presence, kind) = match action {
396 WhaleState::Rest => (Presence::Idle, None),
397 WhaleState::Listen => (Presence::Listening, None),
398 WhaleState::Think => (Presence::Thinking, None),
399 WhaleState::NeedsYou => (Presence::NeedsYou, None),
400 WhaleState::Done => (Presence::Done, None),
401 WhaleState::Stuck => (Presence::Stuck, None),
402 WhaleState::Asleep => (Presence::Offline, None),
403 WhaleState::Read => (Presence::Working, Some("reading")),
404 WhaleState::Search => (Presence::Working, Some("searching")),
405 WhaleState::Write => (Presence::Working, Some("editing")),
406 WhaleState::Run => (Presence::Working, Some("executing")),
407 WhaleState::Browse => (Presence::Working, Some("browsing")),
408 WhaleState::Talk => (Presence::Working, Some("responding")),
409 WhaleState::Pod { .. } => (Presence::Working, Some("delegating")),
410 WhaleState::Computer => (Presence::Working, Some("computer")),
411 WhaleState::Connect => (Presence::Working, Some("network")),
412 WhaleState::Busy => (Presence::Working, None),
413 };
414 Inputs {
415 presence,
416 activity: Some(Activity {
417 kind: kind.map(str::to_owned),
418 observed: kind.is_some(),
419 parallel: if matches!(action, WhaleState::Pod { .. }) {
420 Some(3.0)
421 } else {
422 None
423 },
424 active: vec![],
425 }),
426 context: Context {
427 live: true,
428 turn_id: Some(identity.to_owned()),
429 status: Some(
430 if action == WhaleState::Stuck {
431 "failed"
432 } else {
433 "completed"
434 }
435 .into(),
436 ),
437 now_ms: Some(1_000.0),
438 failed_at_ms: Some(0.0),
439 },
440 }
441 }
442 fn approval_choices() -> ApprovalState {
443 ApprovalState::new(vec![
444 ApprovalChoice::new(ChoiceId(1), "Allow once", ApprovalEffect::Grants).char_key('y'),
445 ApprovalChoice::new(ChoiceId(2), "Keep waiting", ApprovalEffect::Refuses).char_key('n'),
446 ])
447 .reveal(ApprovalKey::char('o'))
448 }
449
450 /// The same pure renderer is used by the live example and frame exports.
451 /// Native whale geometry is supplied by the host's one Stage; absent geometry
452 /// uses the kit's still pose. Rendering never reads an Instant or advances it.
453 pub struct ShowcaseFrame<'a> {
454 pub state: &'a ShowcaseState,
455 pub elapsed: Duration,
456 pub whale: Option<&'a ColoredGrid>,
457 }
458 impl<'a> ShowcaseFrame<'a> {
459 pub const fn new(state: &'a ShowcaseState, elapsed: Duration) -> Self {
460 Self {
461 state,
462 elapsed,
463 whale: None,
464 }
465 }
466 pub const fn whale(mut self, grid: &'a ColoredGrid) -> Self {
467 self.whale = Some(grid);
468 self
469 }
470 /// The caller can rasterize its one native Stage at the actual viewport
471 /// size. Hidden side panes request no art and therefore no actor clock.
472 pub fn whale_size(&self, area: Rect) -> Option<(usize, usize)> {
473 if self.state.section != ShowcaseSection::Life {
474 return None;
475 }
476 let art = Self::life_art(area);
477 let rows = art.height.saturating_sub(1).min(16);
478 (art.width >= 16 && rows >= 8).then_some((usize::from(art.width), usize::from(rows)))
479 }
480 }
481 impl Paint for ShowcaseFrame<'_> {
482 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
483 let area = area.intersection(buf.area);
484 if area.is_empty() {
485 return;
486 }
487 // Kit widgets can use ordinary local coordinates even when the host
488 // paints into an offset buffer at the last representable coordinate.
489 let mut local = Buffer::empty(Rect::new(0, 0, area.width, area.height));
490 self.paint_local(local.area, &mut local, theme);
491 for y in 0..area.height {
492 for x in 0..area.width {
493 buf[(area.x + x, area.y + y)] = local[(x, y)].clone();
494 }
495 }
496 }
497 }
498
499 fn band(area: Rect, offset: u16, height: u16) -> Rect {
500 let offset = offset.min(area.height);
501 Rect::new(
502 area.x,
503 area.y.saturating_add(offset),
504 area.width,
505 height.min(area.height.saturating_sub(offset)),
506 )
507 }
508 fn row(area: Rect, buf: &mut Buffer, line: Line<'_>) {
509 let area = area.intersection(buf.area);
510 if !area.is_empty() {
511 buf.set_line(area.x, area.y, &line, area.width);
512 }
513 }
514 fn caption(area: Rect, buf: &mut Buffer, theme: &Theme, value: &str, role: Role) {
515 row(
516 area,
517 buf,
518 Line::styled(
519 text::truncate_words(
520 &text::display_safe(value),
521 usize::from(area.width),
522 theme.ascii(),
523 )
524 .into_owned(),
525 theme.fg(role),
526 ),
527 );
528 }
529 fn title(area: Rect, buf: &mut Buffer, theme: &Theme, value: &str) {
530 row(
531 area,
532 buf,
533 Line::styled(
534 text::truncate_words(
535 &text::display_safe(value),
536 usize::from(area.width),
537 theme.ascii(),
538 )
539 .into_owned(),
540 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
541 ),
542 );
543 }
544 fn mode_word(mode: MotionMode) -> &'static str {
545 match mode {
546 MotionMode::Full => "Full",
547 MotionMode::Reduced => "Reduced",
548 MotionMode::Still => "Still",
549 }
550 }
551
552 impl ShowcaseFrame<'_> {
553 fn native_composer(&self, theme: &Theme) -> NativeComposer<'_> {
554 let mut composer = NativeComposer::new(self.state.draft.text())
555 .density(NativeComposerDensity::Compact)
556 .cursor(self.state.draft.cursor())
557 .placeholder("Write a task or use /.")
558 .focused(self.state.editing && !self.state.dock.focused)
559 .can_submit(!self.state.draft.text().is_empty());
560 if matches!(
561 self.state.phase,
562 ShowcasePhase::Working | ShowcasePhase::Verifying
563 ) {
564 composer = composer.submit_hint(if theme.ascii() {
565 "Enter send after this turn"
566 } else {
567 "↵ send after this turn"
568 });
569 }
570 composer
571 }
572
573 /// Native slot geometry shared with the terminal cursor owner.
574 #[must_use]
575 pub fn work_areas(&self, area: Rect, theme: &Theme) -> crate::ShellAreas {
576 let dock = self.state.workbar();
577 let dock_rows = if self.state.readouts.on {
578 dock.height(area.width, theme)
579 } else {
580 0
581 };
582 let pending_rows = u16::from(!self.state.queued.is_empty());
583 let workflow_rows = u16::from(self.state.details.on);
584 let composer_budget = area
585 .height
586 .saturating_sub(3 + 2 + dock_rows + pending_rows + workflow_rows)
587 .max(1)
588 .min(area.height);
589 let composer_height = self
590 .native_composer(theme)
591 .desired_height(area.width, composer_budget);
592 TerminalShell::new(composer_height)
593 .pending_rows(pending_rows)
594 .workflow_rows(workflow_rows)
595 .workbar_rows(dock_rows)
596 .areas(area)
597 }
598
599 #[must_use]
600 pub fn composer_cursor(&self, area: Rect, theme: &Theme) -> Option<ratatui::layout::Position> {
601 self.native_composer(theme)
602 .cursor_position(self.work_areas(area, theme).composer)
603 }
604
605 fn paint_local(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
606 let theme = self.state.theme_for(theme);
607 buf.set_style(
608 area,
609 theme.bg(Role::Background).patch(theme.fg(Role::Foreground)),
610 );
611 match self.state.section {
612 ShowcaseSection::Work => self.work(area, buf, &theme),
613 ShowcaseSection::Decisions => self.decisions(area, buf, &theme),
614 ShowcaseSection::Controls => self.controls(area, buf, &theme),
615 ShowcaseSection::Color => self.colors(area, buf, &theme),
616 ShowcaseSection::Life => self.life(area, buf, &theme),
617 ShowcaseSection::Components => self.components(area, buf, &theme),
618 }
619 let phase = if self.state.section == ShowcaseSection::Life {
620 match WhaleState::ALL[self.state.action.min(16)].state() {
621 State::Working => OceanPhase::Working,
622 State::NeedsYou => OceanPhase::Waiting,
623 State::Failed => OceanPhase::Failed,
624 State::Done => OceanPhase::Done,
625 _ => OceanPhase::Idle,
626 }
627 } else if self.state.approval_open {
628 OceanPhase::Approval
629 } else {
630 match self.state.phase {
631 ShowcasePhase::Working => OceanPhase::Working,
632 ShowcasePhase::NeedsYou => OceanPhase::Waiting,
633 ShowcasePhase::Verifying => OceanPhase::Verifying,
634 ShowcasePhase::Done => OceanPhase::Done,
635 }
636 };
637 if self.state.palette == WaterPalette::Ocean {
638 let column = OceanColumn::new(
639 self.elapsed,
640 if self.state.active_motion() {
641 self.state.motion
642 } else {
643 MotionMode::Still
644 },
645 )
646 .phase(phase)
647 .context_percent(if self.state.section == ShowcaseSection::Work {
648 24
649 } else {
650 0
651 })
652 .completion_elapsed(self.state.phase_elapsed(self.elapsed))
653 .viewport(area);
654 column.apply(area, buf, &theme);
655 if self.state.section == ShowcaseSection::Work {
656 let regions = self.work_areas(area, &theme);
657 for (region, ground) in [
658 (regions.composer, TuiGround::Composer),
659 (regions.posture, TuiGround::Footer),
660 (regions.metrics, TuiGround::Header),
661 ] {
662 if let Some(ground) = theme.tui_ground(ground).bg {
663 column.apply_matching(region, buf, &theme, ground);
664 }
665 }
666 }
667 } else {
668 Ombre::new(self.state.palette)
669 .direction(self.state.direction)
670 .apply(area, buf, &theme);
671 }
672 if self.state.section == ShowcaseSection::Life {
673 self.paint_whale(
674 Self::life_art(area),
675 buf,
676 &theme,
677 WhaleState::ALL[self.state.action.min(16)],
678 );
679 }
680 }
681
682 fn marker(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
683 let elapsed = self.state.phase_elapsed(self.elapsed);
684 match self.state.phase {
685 ShowcasePhase::Working => {
686 Spinner::new("Checking the narrow layout", elapsed, self.state.motion)
687 .paint(area, buf, theme)
688 }
689 ShowcasePhase::Verifying => {
690 VerificationSpinner::new("Checking the changes", elapsed, self.state.motion)
691 .paint(area, buf, theme)
692 }
693 ShowcasePhase::NeedsYou => StatusMark::new(State::NeedsYou)
694 .word("Needs you")
695 .paint(area, buf, theme),
696 ShowcasePhase::Done => Receipt::new(State::Done, "Done")
697 .value(ReceiptValue::Duration(Some(Duration::from_secs(6))))
698 .paint(area, buf, theme),
699 }
700 }
701 fn paint_whale(&self, area: Rect, buf: &mut Buffer, theme: &Theme, state: WhaleState) {
702 let whale = Whale::new(state).words(state.words());
703 if let Some(grid) = self.whale {
704 grid.paint_with_contrast(&whale, area, buf, theme, 3.0);
705 } else {
706 whale.paint(area, buf, theme);
707 }
708 }
709 fn split(&self, area: Rect, side_width: u16) -> (Rect, Option<Rect>) {
710 if area.width >= 96 {
711 (
712 Rect::new(
713 area.x,
714 area.y,
715 area.width.saturating_sub(side_width + 3),
716 area.height,
717 ),
718 Some(Rect::new(
719 area.right().saturating_sub(side_width),
720 area.y,
721 side_width,
722 area.height,
723 )),
724 )
725 } else {
726 (area, None)
727 }
728 }
729 fn composer(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
730 self.native_composer(theme).paint(area, buf, theme);
731 }
732
733 fn work(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
734 let dock = self.state.workbar();
735 let regions = self.work_areas(area, theme);
736 TerminalShell::new(0).paint(area, buf, theme);
737 let user = Message::native("Make the session picker readable in narrow windows.")
738 .role(Role::Primary)
739 .marker(crate::glyphs::USER);
740 let mut next = 1;
741 let height = user.height(regions.conversation.width, theme);
742 user.paint(band(regions.conversation, next, height), buf, theme);
743 next += height + 1;
744 let reply = match self.state.phase {
745 ShowcasePhase::Working => {
746 "The list now keeps its search field, numbering and current-session marker.\nLong titles wrap cleanly, and the selection stays visible as the window narrows.\n\nI'm checking the compact layout before wrapping up."
747 }
748 ShowcasePhase::NeedsYou if self.state.approval_open => {
749 "Review the command below before continuing."
750 }
751 ShowcasePhase::NeedsYou => "The turn is paused. Your draft is still here.",
752 ShowcasePhase::Verifying => {
753 "The list, search field and current-session marker keep their place.\nI'm checking the compact layout and keyboard navigation."
754 }
755 ShowcasePhase::Done => {
756 "The session picker is ready for review.\nSearch, numbering and selection remain visible in narrow windows."
757 }
758 };
759 let reply = Message::native(reply);
760 let height = reply.height(regions.conversation.width, theme);
761 reply.paint(band(regions.conversation, next, height), buf, theme);
762 next += height + 1;
763 for followup in &self.state.followups {
764 let message = Message::native(followup).marker(crate::glyphs::USER);
765 let height = message.height(regions.conversation.width, theme);
766 message.paint(band(regions.conversation, next, height), buf, theme);
767 next = next.saturating_add(height).saturating_add(1);
768 }
769 if next < regions.conversation.height {
770 self.marker(band(regions.conversation, next, 1), buf, theme);
771 }
772 if theme.native_palette() == Some(TuiPalette::Underwater) {
773 Habitat::new(
774 self.elapsed,
775 if self.state.active_motion() {
776 self.state.motion
777 } else {
778 MotionMode::Still
779 },
780 )
781 .density(HabitatDensity::Sparse)
782 .paint(regions.conversation, buf, theme);
783 }
784 if let Some(queued) = self.state.queued.last() {
785 caption(
786 regions.pending,
787 buf,
788 theme,
789 &format!(
790 "{} queued {} {}",
791 self.state.queued.len(),
792 if theme.ascii() { "." } else { "·" },
793 queued
794 ),
795 Role::Primary,
796 );
797 }
798 self.composer(regions.composer, buf, theme);
799 buf.set_style(regions.posture, theme.tui_ground(TuiGround::Footer));
800 let mut posture = PostureBar::new(self.state.permission.word())
801 .permission_ink(self.state.permission.ink())
802 .permission_key("Shift+Tab")
803 .mode_ink("work", TuiInk::ModeWork)
804 .context_percent(24);
805 if self.state.phase == ShowcasePhase::Done {
806 posture = posture.session_clock("worked 6 s", Role::Muted);
807 } else {
808 posture = posture.turn_clock(
809 format!(
810 "{} {}",
811 self.state.phase.word().to_lowercase(),
812 crate::duration(self.state.phase_elapsed(self.elapsed))
813 ),
814 self.state.phase.state().role(),
815 );
816 }
817 if self.state.editing
818 && matches!(
819 self.state.phase,
820 ShowcasePhase::Working | ShowcasePhase::Verifying
821 )
822 {
823 posture = posture.hint("Esc to interrupt", Role::Hint);
824 }
825 posture.paint(regions.posture, buf, theme);
826 WorkflowProgress::new(vec![
827 WorkflowRun::new(
828 "Update the component library",
829 if self.state.phase == ShowcasePhase::Done {
830 WorkflowRunState::Succeeded
831 } else {
832 WorkflowRunState::Running
833 },
834 )
835 .outcomes(
836 if self.state.phase == ShowcasePhase::Done {
837 3
838 } else {
839 1
840 },
841 0,
842 0,
843 3,
844 )
845 .elapsed(if self.state.phase == ShowcasePhase::Done {
846 self.state.phase_started
847 } else {
848 self.elapsed
849 }),
850 ])
851 .paint(regions.workflows, buf, theme);
852 buf.set_style(regions.metrics, theme.tui_ground(TuiGround::Header));
853 MetricsLine::new(vec![
854 MetricSegment::new(MetricKind::Model, "", "deepseek-v4"),
855 MetricSegment::new(MetricKind::Context, "ctx", "24%"),
856 MetricSegment::new(MetricKind::Workspace, "", "codewhale-ratatui"),
857 MetricSegment::new(MetricKind::GitBranch, "", "session-layout"),
858 ])
859 .help_hint("F6 components")
860 .paint(
861 regions
862 .metrics
863 .inner(Margin::new(u16::from(regions.metrics.width >= 8), 0)),
864 buf,
865 theme,
866 );
867 dock.paint(regions.workbar, buf, theme);
868 }
869
870 fn decisions(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
871 if self.state.approval_open {
872 self.approval(area, buf, theme);
873 return;
874 }
875 title(band(area, 1, 1), buf, theme, "Review changes");
876 const PATCH: &str = "--- a/workspace.rs\n+++ b/workspace.rs\n@@ -1,3 +1,4 @@\n fn draw() {\n- draw_all();\n+ composer.paint(area, buffer, theme);\n+ workbar.paint(dock, buffer, theme);\n }\n";
877 Diff::new(parse_unified(PATCH))
878 .gutter(DiffGutter::Both)
879 .wrap(DiffWrap::Wrap)
880 .paint(band(area, 4, area.height.saturating_sub(7)), buf, theme);
881 self.marker(band(area, area.height.saturating_sub(2), 1), buf, theme);
882 caption(
883 band(area, area.height.saturating_sub(1), 1),
884 buf,
885 theme,
886 "F1 conversation / F10 continue",
887 Role::Hint,
888 );
889 }
890
891 fn approval(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
892 // A full reading surface at narrow widths; no creature or decoration
893 // can enter even the blank space of this decision.
894 let width = area.width.min(72);
895 let modal = Rect::new(
896 area.x + area.width.saturating_sub(width) / 2,
897 area.y,
898 width,
899 area.height,
900 );
901 buf.set_style(
902 modal,
903 theme.bg(Role::Surface).patch(theme.fg(Role::Foreground)),
904 );
905 for y in modal.top()..modal.bottom() {
906 for x in modal.left()..modal.right() {
907 buf[(x, y)].set_symbol(" ");
908 }
909 }
910 title(band(modal, 0, 1), buf, theme, "Run a command");
911 let subject = ApprovalSubject::new(ApprovalKind::Command, "git diff --check")
912 .cwd("codewhale-ratatui")
913 .scope(ApprovalScope::Inside)
914 .agent("Verifier", false)
915 .risk_note("Checks whitespace in the current changes.");
916 ApprovalCard::new(&subject, &self.state.approval).paint(
917 band(modal, 2, modal.height.saturating_sub(3)),
918 buf,
919 theme,
920 );
921 caption(
922 band(modal, modal.height.saturating_sub(1), 1),
923 buf,
924 theme,
925 "y advance / n wait / Esc preserve draft",
926 Role::Attention,
927 );
928 }
929 fn controls(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
930 let (main, side) = self.split(area, 34);
931 title(band(main, 0, 1), buf, theme, "Settings");
932 caption(
933 band(main, 2, 2),
934 buf,
935 theme,
936 if self.state.editing {
937 "Editing / Tab moves between fields; Enter validates"
938 } else {
939 "Enter edits the form. F7-F9 change appearance."
940 },
941 Role::Muted,
942 );
943 Form::new(&self.state.form)
944 .focused(self.state.editing)
945 .paint(band(main, 5, 11), buf, theme);
946 Toggle::new("Workflow progress", self.state.details)
947 .focused(!self.state.editing && self.state.focus == 0)
948 .paint(band(main, 17, 1), buf, theme);
949 Toggle::new("Workbar", self.state.readouts)
950 .focused(!self.state.editing && self.state.focus == 1)
951 .paint(band(main, 19, 1), buf, theme);
952 caption(band(main, 21, 2), buf, theme, &self.state.note, Role::Live);
953 if let Some(side) = side {
954 self.settings(side, buf, theme);
955 }
956 }
957 fn settings(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
958 PaneHeader::new("Appearance").paint(band(area, 0, 2), buf, theme);
959 let rows = [
960 SettingRow::new("Palette", self.state.palette.name()).source("your choice"),
961 SettingRow::new("Profile", self.state.profile.name()).source("terminal"),
962 SettingRow::new("Motion", mode_word(self.state.motion)).source("your choice"),
963 ];
964 let mut top = 3;
965 for setting in rows {
966 let h = setting.height(area.width, theme);
967 setting.paint(band(area, top, h), buf, theme);
968 top += h;
969 }
970 SettingDetail::new("Appearance", "Palette changes the atmosphere. State marks, action ink and decision scope keep their meaning.").default_value("Ocean / Full").source("workspace").paint(band(area, top + 2, area.height.saturating_sub(top + 2)), buf, theme);
971 }
972 fn colors(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
973 title(band(area, 0, 1), buf, theme, "Appearance");
974 let palette = WaterPalette::ALL
975 .iter()
976 .position(|p| *p == self.state.palette)
977 .unwrap_or(0);
978 Segmented::new(
979 WaterPalette::ALL.map(WaterPalette::name),
980 SegmentedState::new(palette),
981 )
982 .focused(self.state.focus == 0)
983 .paint(band(area, 2, 1), buf, theme);
984 Segmented::new(
985 ["Full", "Reduced", "Still"],
986 SegmentedState::new(match self.state.motion {
987 MotionMode::Full => 0,
988 MotionMode::Reduced => 1,
989 MotionMode::Still => 2,
990 }),
991 )
992 .focused(self.state.focus == 1)
993 .paint(band(area, 4, 1), buf, theme);
994 caption(
995 band(area, 6, 1),
996 buf,
997 theme,
998 &format!("Profile {} / Left, Right", self.state.profile.name()),
999 if self.state.focus == 2 {
1000 Role::Primary
1001 } else {
1002 Role::Muted
1003 },
1004 );
1005 Segmented::new(
1006 ["Vertical", "Diagonal"],
1007 SegmentedState::new(usize::from(
1008 self.state.direction == OmbreDirection::Diagonal,
1009 )),
1010 )
1011 .focused(self.state.focus == 3)
1012 .paint(band(area, 8, 1), buf, theme);
1013 let columns = if area.width >= 72 { 3 } else { 2 };
1014 let cell_width = area.width / columns;
1015 for (index, role) in Role::ALL.into_iter().enumerate() {
1016 let x = area.x + (index as u16 % columns) * cell_width;
1017 let y = area.y + 11 + index as u16 / columns;
1018 let cell = Rect::new(x, y, cell_width.saturating_sub(1), 1).intersection(area);
1019 if cell.is_empty() {
1020 continue;
1021 }
1022 let label = format!("{:?}", role);
1023 let sample = if role.is_ground() {
1024 theme.bg(role).patch(theme.fg(Role::Foreground))
1025 } else if role == Role::PrimaryForeground {
1026 theme.bg(Role::Primary).patch(theme.fg(role))
1027 } else {
1028 theme.fg(role)
1029 };
1030 let label_width = cell.width.saturating_sub(3);
1031 row(
1032 cell,
1033 buf,
1034 Line::from(vec![
1035 Span::styled(
1036 text::pad(&label, usize::from(label_width), theme.ascii()),
1037 theme.fg(Role::Foreground),
1038 ),
1039 Span::styled(" Aa", sample),
1040 ]),
1041 );
1042 }
1043 let start = if columns == 3 { 19 } else { 22 };
1044 if area.height > start + 2 {
1045 let width = area.width / 4;
1046 for (index, depth) in [Depth::Deep, Depth::Stage, Depth::Raised, Depth::Overlay]
1047 .into_iter()
1048 .enumerate()
1049 {
1050 let cell = Rect::new(
1051 area.x + index as u16 * width,
1052 area.y + start,
1053 width.saturating_sub(1),
1054 2,
1055 )
1056 .intersection(area);
1057 buf.set_style(cell, theme.bg(depth.ground()));
1058 caption(cell, buf, theme, &format!("{depth:?}"), Role::Foreground);
1059 }
1060 HorizonRule::new().label("One spatial wash").paint(
1061 band(area, start + 3, 1),
1062 buf,
1063 theme,
1064 );
1065 }
1066 }
1067 fn life_art(area: Rect) -> Rect {
1068 let water = band(area, 4, area.height.saturating_sub(4));
1069 let art_width = water.width.min(40);
1070 Rect::new(
1071 water.x + water.width.saturating_sub(art_width) / 2,
1072 water.y + u16::from(water.height >= 19) * 2,
1073 art_width,
1074 water.height.min(17),
1075 )
1076 }
1077 fn life(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
1078 let action = WhaleState::ALL[self.state.action.min(WhaleState::ALL.len() - 1)];
1079 title(
1080 band(area, 0, 1),
1081 buf,
1082 theme,
1083 "One whale. Seventeen native actions.",
1084 );
1085 caption(
1086 band(area, 2, 1),
1087 buf,
1088 theme,
1089 &format!(
1090 "Action study / {} / {} of 17 / Left, Right / Space play",
1091 action.words(),
1092 self.state.action.min(16) + 1
1093 ),
1094 Role::Primary,
1095 );
1096 let water = band(area, 4, area.height.saturating_sub(4));
1097 let art = Self::life_art(area);
1098 if !self.state.approval_open {
1099 Habitat::new(
1100 self.elapsed,
1101 if self.state.action_playing {
1102 self.state.motion
1103 } else {
1104 MotionMode::Still
1105 },
1106 )
1107 .density(HabitatDensity::Rich)
1108 .hold_jellyfish_visit(true)
1109 .protected(vec![art])
1110 .paint(water, buf, theme);
1111 }
1112 if water.height >= 20 {
1113 caption(
1114 band(water, water.height - 2, 1),
1115 buf,
1116 theme,
1117 "Native dots, colored ink, fish, one jellyfish and bubbles",
1118 Role::Muted,
1119 );
1120 caption(
1121 band(water, water.height - 1, 1),
1122 buf,
1123 theme,
1124 if self.state.active_motion() {
1125 "Space pause / Left, Right action / F8 motion"
1126 } else {
1127 "Space play / Left, Right action / F8 motion"
1128 },
1129 Role::Muted,
1130 );
1131 }
1132 }
1133 fn components(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
1134 let (entries, items, matches) = self.state.catalogue();
1135 let narrow = area.width < 76;
1136 let list_width = if narrow { area.width } else { 28 };
1137 let list_height = if narrow {
1138 area.height.min(8)
1139 } else {
1140 area.height
1141 };
1142 let list = Rect::new(area.x, area.y, list_width, list_height);
1143 let picker = Picker::new(&items, self.state.picker)
1144 .query(
1145 self.state.query.text(),
1146 text::width(self.state.query.text()),
1147 )
1148 .matches(&matches);
1149 picker.paint(list, buf, theme);
1150 let Some(selected) = matches
1151 .get(self.state.picker.selected)
1152 .and_then(|hit| entries.get(hit.index))
1153 else {
1154 return;
1155 };
1156 let preview = if narrow {
1157 band(
1158 area,
1159 list_height + 1,
1160 area.height.saturating_sub(list_height + 1),
1161 )
1162 } else {
1163 Rect::new(
1164 area.x + 31,
1165 area.y,
1166 area.width.saturating_sub(31),
1167 area.height,
1168 )
1169 };
1170 PaneHeader::new(selected.name)
1171 .meta(format!(
1172 "{} x {} / PgUp, PgDn",
1173 selected.width, selected.height
1174 ))
1175 .paint(band(preview, 0, 2), buf, theme);
1176 let body = band(preview, 3, preview.height.saturating_sub(3));
1177 if body.is_empty() {
1178 return;
1179 }
1180 // Tall components scroll, rather than being silently clipped to the
1181 // viewport. Width belongs to the current terminal specimen.
1182 let source_height = selected.height.max(body.height);
1183 let mut source = Buffer::empty(Rect::new(0, 0, body.width, source_height));
1184 source.set_style(source.area, theme.bg(Role::Background));
1185 (selected.draw)(source.area, &mut source, theme);
1186 let top = self
1187 .state
1188 .preview_scroll
1189 .min(source_height.saturating_sub(body.height));
1190 for y in 0..body.height {
1191 for x in 0..body.width {
1192 buf[(body.x + x, body.y + y)] = source[(x, y + top)].clone();
1193 }
1194 }
1195 }
1196 }
1197
1198 fn fixture(
1199 section: ShowcaseSection,
1200 phase: ShowcasePhase,
1201 area: Rect,
1202 buf: &mut Buffer,
1203 theme: &Theme,
1204 ) {
1205 let mut state = ShowcaseState::new();
1206 state.section = section;
1207 state.phase = phase;
1208 state.approval_open = section == ShowcaseSection::Decisions;
1209 state.action = 8;
1210 state.action_playing = true;
1211 let frame = ShowcaseFrame::new(&state, Duration::from_millis(6_800));
1212 if let Some((cols, rows)) = frame.whale_size(area) {
1213 let action = if section == ShowcaseSection::Life {
1214 WhaleState::ALL[state.action]
1215 } else {
1216 phase.whale()
1217 };
1218 let mut stage = Stage::new();
1219 stage.observe(
1220 Some("gallery-action-study"),
1221 showcase_inputs(action, "gallery-action-study"),
1222 theme.ascii(),
1223 );
1224 let base = std::time::Instant::now();
1225 // Fixed caller-supplied steps sample a living native pose. The pure
1226 // renderer merely consumes this one fixture-owned Stage's output.
1227 for index in 0..=8 {
1228 stage.advance(base + Duration::from_millis(index * 200));
1229 }
1230 let native = colored_braille(
1231 stage.director(),
1232 cols,
1233 rows,
1234 theme.caps().appearance != crate::detect::Appearance::Light,
1235 );
1236 frame.whale(&native).paint(area, buf, theme);
1237 } else {
1238 frame.paint(area, buf, theme);
1239 }
1240 }
1241 pub(crate) fn entries() -> Vec<Entry> {
1242 vec![
1243 Entry {
1244 name: "showcase-work",
1245 width: 104,
1246 height: 30,
1247 draw: |a, b, t| fixture(ShowcaseSection::Work, ShowcasePhase::Working, a, b, t),
1248 },
1249 Entry {
1250 name: "showcase-decision",
1251 width: 104,
1252 height: 30,
1253 draw: |a, b, t| fixture(ShowcaseSection::Decisions, ShowcasePhase::NeedsYou, a, b, t),
1254 },
1255 Entry {
1256 name: "showcase-color",
1257 width: 112,
1258 height: 38,
1259 draw: |a, b, t| fixture(ShowcaseSection::Color, ShowcasePhase::Done, a, b, t),
1260 },
1261 Entry {
1262 name: "showcase-life",
1263 width: 112,
1264 height: 38,
1265 draw: |a, b, t| fixture(ShowcaseSection::Life, ShowcasePhase::Working, a, b, t),
1266 },
1267 Entry {
1268 name: "showcase-narrow",
1269 width: 40,
1270 height: 28,
1271 draw: |a, b, t| fixture(ShowcaseSection::Work, ShowcasePhase::Done, a, b, t),
1272 },
1273 ]
1274 }
1275
1275 lines RUST