返回 CodeWhale
ocean.rs
根目录 / crates / tui / src / tui / ocean.rs
1 //! Terminal-native underwater field for the Codewhale transcript.
2 //!
3 //! The field is atmosphere, never content: ordinary shell cells share its
4 //! water column while semantic surfaces such as selections, errors, and code
5 //! keep their own backgrounds. It belongs to the `underwater` theme alone
6 //! (`ThemeId::Underwater`); every other theme leaves the terminal's ground
7 //! untouched. Motion inside the field remains governed separately by
8 //! `low_motion`/`fancy_animations`. The native kit owns the complete ramp and
9 //! column sampling kernel; host semantic finishing and backend capability/
10 //! contrast policy remain separate until their differences are reconciled.
11
12 use std::time::Duration;
13
14 use codewhale_ratatui::{
15 MotionMode, OceanColumn as NativeColumn, OceanPhase, OceanRamp as NativeRamp,
16 };
17 use ratatui::{buffer::Buffer, layout::Rect, style::Color};
18
19 use crate::tui::underwater::ShellPhase;
20 use codewhale_palette::UiTheme;
21
22 /// Minimum empty-water size that earns decorative ambient life when the
23 /// underwater theme is selected. Below this, content and controls own
24 /// every cell. Shared by the renderer and idle animation scheduler so redraws
25 /// are never scheduled for invisible life.
26 pub const AMBIENT_MIN_WIDTH: u16 = 40;
27 pub const AMBIENT_MIN_HEIGHT: u16 = 10;
28
29 /// Ambient-life ink pair, independent of the Deepsea ramp and shaped by what
30 /// the agent is doing so the marks themselves carry the state at a glance:
31 /// reasoning dims toward the deep, tool work brightens like a faster current,
32 /// and a sub-agent pod swims in seafoam — the hue reserved for orchestration.
33 #[must_use]
34 pub fn ambient_inks_for_activity(
35 theme: &UiTheme,
36 activity: crate::tui::ambient_life::AmbientActivity,
37 ) -> (Color, Color) {
38 use crate::tui::ambient_life::AmbientActivity;
39 let sky = match activity {
40 AmbientActivity::Subagents => rgb(theme.accent_secondary).unwrap_or((79, 209, 197)),
41 _ => rgb(theme.info).unwrap_or((106, 174, 242)),
42 };
43 // `mix(sky, base, t)`: larger `t` sits closer to the background — dimmer.
44 let (toward_base_a, toward_base_b) = match activity {
45 AmbientActivity::Reasoning => (0.58, 0.44),
46 AmbientActivity::Reading => (0.50, 0.36),
47 AmbientActivity::Tools => (0.30, 0.18),
48 AmbientActivity::Subagents => (0.34, 0.22),
49 AmbientActivity::Verifying | AmbientActivity::Baseline => (0.42, 0.28),
50 };
51 // Only the underwater theme owns a painted base column; everywhere else
52 // the terminal's own ground (Color::Reset) is the base and the inks fall
53 // back to the theme's info lane.
54 let mix_base = rgb(theme.surface_bg)
55 .or_else(|| OceanRamp::for_theme(theme).and_then(|ramp| rgb(ramp.middle)));
56 match mix_base {
57 Some(base) => (
58 color(mix(sky, base, toward_base_a)),
59 color(mix(sky, base, toward_base_b)),
60 ),
61 None => (theme.info, theme.info),
62 }
63 }
64
65 /// Length of the completion breath (the column's settle flourish), ms.
66 pub const COMPLETION_BREATH_MS: u128 = NativeRamp::COMPLETION_BREATH.as_millis();
67
68 /// Extra ms after the breath during which ambient life eases out of view.
69 pub const SETTLE_MS: u128 = 600;
70 pub(crate) const COMPLETION_SETTLE_MS: u128 = COMPLETION_BREATH_MS + SETTLE_MS;
71
72 /// Ms over which animated life ramps in when a working phase begins.
73 pub const RAMP_MS: u128 = 450;
74
75 /// Smoothstep easing: 0 at t=0, 1 at t=1, zero velocity at both ends.
76 #[must_use]
77 pub fn smoothstep(t: f32) -> f32 {
78 let t = t.clamp(0.0, 1.0);
79 t * t * (3.0 - 2.0 * t)
80 }
81
82 /// Life presence (0..=1) as a pure function of the monotonic clocks. There is
83 /// deliberately NO per-frame mutable state here: the same inputs always yield
84 /// the same output, which keeps ambient-life renders deterministic.
85 ///
86 /// Rules:
87 /// - A turn just ended (`completion_elapsed_ms` within the breath) holds full
88 /// presence so ambient life keeps swimming through the settle flourish.
89 /// - After the breath, presence eases out over [`SETTLE_MS`] so the water
90 /// settles instead of snapping from animated to frozen.
91 /// - Browsing history or the pristine empty state is user-driven: full
92 /// presence immediately.
93 /// - A Working/Verifying phase ramps in from `turn_elapsed_ms` over
94 /// [`RAMP_MS`], giving bursty fast streams a calm, bounded onset.
95 /// - Everything else is fully static.
96 #[must_use]
97 pub fn life_presence(
98 completion_elapsed_ms: Option<u128>,
99 turn_elapsed_ms: Option<u128>,
100 animated: bool,
101 browsing_history: bool,
102 empty_state: bool,
103 ) -> f32 {
104 if let Some(elapsed) = completion_elapsed_ms {
105 if elapsed < COMPLETION_BREATH_MS {
106 return 1.0;
107 }
108 let t = (elapsed - COMPLETION_BREATH_MS) as f32 / SETTLE_MS as f32;
109 return 1.0 - smoothstep(t);
110 }
111 if !animated {
112 return 0.0;
113 }
114 if browsing_history || empty_state {
115 return 1.0;
116 }
117 match turn_elapsed_ms {
118 Some(elapsed) => smoothstep(elapsed as f32 / RAMP_MS as f32),
119 None => 1.0,
120 }
121 }
122
123 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
124 pub struct OceanRamp {
125 pub surface: Color,
126 pub middle: Color,
127 pub deep: Color,
128 pub ambient: Color,
129 /// Tint for phases that are blocked on the user (Waiting / Approval).
130 /// The whole water field warms toward this so "needs you" is legible
131 /// from across the room, not only in the phase strip.
132 pub attention: Color,
133 /// Tint for the Failed outcome: a steady cast, not a pulse — it reports,
134 /// it does not ask.
135 pub failure: Color,
136 }
137
138 /// One continuous water column shared by every shell band in a frame.
139 ///
140 /// Individual widgets still own their foreground and semantic surfaces, but
141 /// ordinary shell backgrounds sample this column with their absolute row.
142 /// That keeps the header, work strip, transcript, phase line, and composer
143 /// from each restarting the same miniature gradient.
144 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
145 pub struct OceanColumn {
146 ramp: OceanRamp,
147 top: u16,
148 height: u16,
149 elapsed_ms: u128,
150 completion_elapsed_ms: Option<u128>,
151 phase: ShellPhase,
152 animated: bool,
153 /// Fixed-point (0..=1000) life presence; keeps `Eq` derivable.
154 presence: u16,
155 context_percent: u8,
156 paint_caps: Option<codewhale_ratatui::Caps>,
157 }
158
159 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
160 struct OceanRampCacheIdentity {
161 ramp: OceanRamp,
162 top: u16,
163 height: u16,
164 phase_tag: u8,
165 animated: bool,
166 completion_active: bool,
167 presence: u16,
168 context_percent: u8,
169 }
170
171 impl OceanRampCacheIdentity {
172 fn fingerprint(self) -> u64 {
173 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
174 const PRIME: u64 = 0x0000_0100_0000_01b3;
175
176 [
177 color_cache_code(self.ramp.surface),
178 color_cache_code(self.ramp.middle),
179 color_cache_code(self.ramp.deep),
180 color_cache_code(self.ramp.ambient),
181 color_cache_code(self.ramp.attention),
182 color_cache_code(self.ramp.failure),
183 u32::from(self.top),
184 u32::from(self.height),
185 u32::from(self.phase_tag),
186 u32::from(self.animated),
187 u32::from(self.completion_active),
188 u32::from(self.presence),
189 u32::from(self.context_percent),
190 ]
191 .into_iter()
192 .flat_map(u32::to_le_bytes)
193 .fold(OFFSET_BASIS, |state, byte| {
194 (state ^ u64::from(byte)).wrapping_mul(PRIME)
195 })
196 }
197 }
198
199 fn color_cache_code(value: Color) -> u32 {
200 match value {
201 Color::Reset => 0,
202 Color::Black => 1,
203 Color::Red => 2,
204 Color::Green => 3,
205 Color::Yellow => 4,
206 Color::Blue => 5,
207 Color::Magenta => 6,
208 Color::Cyan => 7,
209 Color::Gray => 8,
210 Color::DarkGray => 9,
211 Color::LightRed => 10,
212 Color::LightGreen => 11,
213 Color::LightYellow => 12,
214 Color::LightBlue => 13,
215 Color::LightMagenta => 14,
216 Color::LightCyan => 15,
217 Color::White => 16,
218 Color::Indexed(index) => 0x0100_0000 | u32::from(index),
219 Color::Rgb(red, green, blue) => 0x0200_0000 | u32::from_be_bytes([0, red, green, blue]),
220 }
221 }
222
223 impl OceanColumn {
224 // Eight args mirroring the eight column fields; a params struct would
225 // only rename the call sites without removing a single decision.
226 #[allow(clippy::too_many_arguments)]
227 #[must_use]
228 pub fn new(
229 ramp: OceanRamp,
230 viewport: Rect,
231 elapsed_ms: u128,
232 completion_elapsed_ms: Option<u128>,
233 phase: ShellPhase,
234 animated: bool,
235 presence: u16,
236 context_percent: u8,
237 ) -> Self {
238 Self {
239 ramp,
240 top: viewport.y,
241 height: viewport.height.max(1),
242 elapsed_ms,
243 completion_elapsed_ms,
244 phase,
245 animated,
246 presence,
247 context_percent: context_percent.min(100),
248 paint_caps: None,
249 }
250 }
251
252 #[must_use]
253 pub fn color_at_y(self, y: u16) -> Color {
254 let viewport = Rect::new(0, self.top, 0, self.height);
255 self.native()
256 .color_at_y_with_ramp(y, viewport, self.ramp.native())
257 }
258
259 fn native(self) -> NativeColumn {
260 // The host samples and gates these monotonic clocks once. The pure
261 // kit path owns the complete phase/context/presence/settle kernel;
262 // ColorCompatBackend remains the current terminal paint authority.
263 let motion = if self.animated || self.presence > 0 {
264 MotionMode::Full
265 } else {
266 MotionMode::Still
267 };
268 let mut column = NativeColumn::new(phase_duration(self.elapsed_ms), motion)
269 .phase(native_phase(self.phase))
270 .context_percent(self.context_percent)
271 .presence(self.presence);
272 if let Some(elapsed) = self.completion_elapsed_ms {
273 column = column.completion_elapsed(completion_duration(elapsed));
274 }
275 column
276 }
277
278 fn completion_active(self) -> bool {
279 self.phase == ShellPhase::Done
280 && (self.animated || self.presence > 0)
281 && self
282 .completion_elapsed_ms
283 .is_some_and(|elapsed| elapsed < COMPLETION_BREATH_MS)
284 }
285
286 /// Elapsed milliseconds of the completion breath, when active. Ambient
287 /// life uses this to time the rare whale cameo on successful turns.
288 #[must_use]
289 pub fn completion_elapsed_ms(self) -> Option<u128> {
290 self.completion_elapsed_ms
291 }
292
293 /// Compact phase discriminator for [`crate::tui::ambient_life::OceanRampCache`].
294 #[must_use]
295 pub fn phase_tag(self) -> u8 {
296 match self.phase {
297 ShellPhase::Idle => 0,
298 ShellPhase::Typing => 1,
299 ShellPhase::Working => 2,
300 ShellPhase::Verifying => 3,
301 ShellPhase::Waiting => 4,
302 ShellPhase::Approval => 5,
303 ShellPhase::Done => 6,
304 ShellPhase::Failed => 7,
305 }
306 }
307
308 fn ramp_cache_identity(self) -> OceanRampCacheIdentity {
309 OceanRampCacheIdentity {
310 ramp: self.ramp,
311 top: self.top,
312 height: self.height,
313 phase_tag: self.phase_tag(),
314 animated: self.animated,
315 completion_active: self.completion_active(),
316 presence: self.presence,
317 context_percent: self.context_percent,
318 }
319 }
320
321 /// Deterministic fingerprint of every column input owned by the ramp cache.
322 /// Actual colors are encoded explicitly; this never depends on randomized
323 /// hashing or debug formatting.
324 #[must_use]
325 pub fn ramp_fingerprint(self) -> u64 {
326 self.ramp_cache_identity().fingerprint()
327 }
328
329 #[must_use]
330 pub fn with_viewport(mut self, viewport: Rect) -> Self {
331 self.top = viewport.y;
332 self.height = viewport.height.max(1);
333 self
334 }
335
336 /// Borrowed backend facts for this render snapshot; no detector or store.
337 #[must_use]
338 pub fn with_paint_caps(mut self, caps: Option<codewhale_ratatui::Caps>) -> Self {
339 self.paint_caps = caps;
340 self
341 }
342
343 fn paint_theme(self, ground: Color) -> Option<codewhale_ratatui::Theme> {
344 let mut caps = self.paint_caps?;
345 // Equality-matched opaque ground is actual pane evidence. A terminal
346 // Reset/named ink supplies no RGB evidence; an OS hint cannot fill it.
347 caps.appearance = codewhale_ratatui::detect::appearance_for_background(ground)?;
348 Some(
349 codewhale_ratatui::Theme::new(caps)
350 .ground(codewhale_ratatui::Ground::Ocean)
351 .tui_palette(codewhale_ratatui::TuiPalette::Underwater),
352 )
353 }
354
355 /// Complete matching facade. The kit owns cell iteration and all guards;
356 /// the same backend adapter reports exact visible custom-theme ink.
357 pub fn paint_matching_native(
358 self,
359 area: Rect,
360 buf: &mut Buffer,
361 background: Color,
362 ui_theme: &UiTheme,
363 protected: &[Rect],
364 ) {
365 let facts = codewhale_ratatui::ocean::OceanPaintFacts {
366 protected,
367 ..codewhale_ratatui::ocean::OceanPaintFacts::new(background)
368 };
369 self.paint_native(area, buf, ui_theme, &facts);
370 }
371
372 pub(crate) fn paint_native(
373 self,
374 area: Rect,
375 buf: &mut Buffer,
376 ui_theme: &UiTheme,
377 facts: &codewhale_ratatui::ocean::OceanPaintFacts<'_>,
378 ) {
379 if ui_theme.name != codewhale_palette::UNDERWATER_UI_THEME.name {
380 return;
381 }
382 let Some(theme) = self.paint_theme(facts.ground) else {
383 return;
384 };
385 let inks = codewhale_ratatui::ocean::OceanContrastInks {
386 border: Some(ui_theme.border),
387 border_strong: Some(ui_theme.text_hint),
388 dim: Some(ui_theme.text_dim),
389 };
390 self.native()
391 .ramp(self.ramp.native())
392 .viewport(Rect::new(0, self.top, 0, self.height))
393 .contrast_inks(inks)
394 .apply_native(area, buf, &theme, facts, |cell, water| {
395 crate::tui::color_compat::project_ocean_ink(cell, water, theme.depth(), ui_theme)
396 });
397 }
398
399 pub(crate) fn paint_caustics(
400 self,
401 area: Rect,
402 buf: &mut Buffer,
403 facts: &codewhale_ratatui::ocean::OceanCausticFacts<'_>,
404 ) {
405 let Some(theme) = self.paint_theme(facts.paint.ground) else {
406 return;
407 };
408 self.native()
409 .ramp(self.ramp.native())
410 .viewport(Rect::new(0, self.top, 0, self.height))
411 .apply_caustics(area, buf, &theme, facts);
412 }
413
414 // Existing pure fixture facade explicitly supplies a synthetic backend.
415 // Runtime callers all use the live fact-bearing native facade above.
416 #[cfg(test)]
417 fn paint_matching(self, area: Rect, buf: &mut Buffer, background: Color) {
418 let caps = crate::tui::color_compat::ColorCompatBackend::new(
419 std::io::sink(),
420 codewhale_palette::ColorDepth::TrueColor,
421 codewhale_palette::PaletteMode::Dark,
422 )
423 .native_ocean_caps();
424 self.with_paint_caps(Some(caps)).paint_matching_native(
425 area,
426 buf,
427 background,
428 &codewhale_palette::UNDERWATER_UI_THEME,
429 &[],
430 );
431 }
432 }
433
434 impl OceanRamp {
435 #[must_use]
436 pub fn for_theme(theme: &UiTheme) -> Option<Self> {
437 // The painted field exists only under the underwater theme; every
438 // other theme leaves the terminal's ground alone. A user-supplied
439 // `background_color` rewrites the underwater surfaces through
440 // `with_background_color` and remains the source of truth there.
441 if theme.name != codewhale_palette::UNDERWATER_UI_THEME.name {
442 return None;
443 }
444
445 Some(Self {
446 // The authored Codewhale water column: unmistakably blue all the
447 // way to the floor. These restrained ocean shades sit between the
448 // shell's ink surfaces and its ambient blue, so the field gains
449 // depth without becoming a saturated blue panel.
450 surface: NativeRamp::SURFACE,
451 middle: NativeRamp::MIDDLE,
452 deep: NativeRamp::DEEP,
453 ambient: NativeRamp::AMBIENT,
454 attention: theme.warning,
455 failure: theme.error_fg,
456 })
457 }
458
459 fn native(self) -> NativeRamp {
460 NativeRamp::new(
461 self.surface,
462 self.middle,
463 self.deep,
464 self.ambient,
465 self.attention,
466 self.failure,
467 )
468 }
469
470 #[cfg(test)]
471 #[must_use]
472 pub fn color_at_context(self, row: u16, height: u16, context_percent: u8) -> Color {
473 self.native().color_at_context(row, height, context_percent)
474 }
475
476 #[cfg(test)]
477 #[must_use]
478 pub fn color_at_phase_context(
479 self,
480 row: u16,
481 height: u16,
482 elapsed_ms: u128,
483 phase: ShellPhase,
484 context_percent: u8,
485 ) -> Color {
486 self.native().color_at_phase_context(
487 row,
488 height,
489 phase_duration(elapsed_ms),
490 native_phase(phase),
491 context_percent,
492 )
493 }
494
495 #[cfg(test)]
496 #[must_use]
497 pub fn color_at_completion_context(
498 self,
499 row: u16,
500 height: u16,
501 elapsed_ms: u128,
502 context_percent: u8,
503 ) -> Color {
504 self.native().color_at_completion_context(
505 row,
506 height,
507 completion_duration(elapsed_ms),
508 context_percent,
509 )
510 }
511 }
512
513 fn native_phase(phase: ShellPhase) -> OceanPhase {
514 match phase {
515 ShellPhase::Idle => OceanPhase::Idle,
516 ShellPhase::Typing => OceanPhase::Typing,
517 ShellPhase::Working => OceanPhase::Working,
518 ShellPhase::Verifying => OceanPhase::Verifying,
519 ShellPhase::Waiting => OceanPhase::Waiting,
520 ShellPhase::Approval => OceanPhase::Approval,
521 ShellPhase::Done => OceanPhase::Done,
522 ShellPhase::Failed => OceanPhase::Failed,
523 }
524 }
525
526 fn phase_duration(elapsed_ms: u128) -> Duration {
527 Duration::from_millis((elapsed_ms % 90_000) as u64)
528 }
529
530 fn completion_duration(elapsed_ms: u128) -> Duration {
531 Duration::from_millis(elapsed_ms.min(COMPLETION_BREATH_MS) as u64)
532 }
533
534 #[must_use]
535 fn rgb(value: Color) -> Option<(u8, u8, u8)> {
536 match value {
537 Color::Rgb(r, g, b) => Some((r, g, b)),
538 _ => None,
539 }
540 }
541
542 #[must_use]
543 fn color((r, g, b): (u8, u8, u8)) -> Color {
544 Color::Rgb(r, g, b)
545 }
546
547 #[must_use]
548 pub fn mix_colors(from: Color, to: Color, amount: f32) -> Color {
549 match (rgb(from), rgb(to)) {
550 (Some(from), Some(to)) => color(mix(from, to, amount)),
551 _ => from,
552 }
553 }
554
555 #[must_use]
556 pub fn scale_color(value: Color, brightness: f32) -> Color {
557 let Some((r, g, b)) = rgb(value) else {
558 return value;
559 };
560 color((
561 (f32::from(r) * brightness).round().clamp(0.0, 255.0) as u8,
562 (f32::from(g) * brightness).round().clamp(0.0, 255.0) as u8,
563 (f32::from(b) * brightness).round().clamp(0.0, 255.0) as u8,
564 ))
565 }
566
567 #[must_use]
568 fn mix(from: (u8, u8, u8), to: (u8, u8, u8), amount: f32) -> (u8, u8, u8) {
569 let amount = amount.clamp(0.0, 1.0);
570 let channel = |a: u8, b: u8| {
571 (f32::from(a) + (f32::from(b) - f32::from(a)) * amount)
572 .round()
573 .clamp(0.0, 255.0) as u8
574 };
575 (
576 channel(from.0, to.0),
577 channel(from.1, to.1),
578 channel(from.2, to.2),
579 )
580 }
581
582 #[cfg(test)]
583 #[path = "ocean/tests.rs"]
584 mod tests;
585
586 #[cfg(test)]
587 #[path = "ocean/guarded_legacy.rs"]
588 mod guarded_legacy;
589 #[cfg(test)]
590 #[path = "ocean/guarded_tests.rs"]
591 mod guarded_tests;
592
592 lines RUST