返回 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`.
9
10 use ratatui::{buffer::Buffer, layout::Rect, style::Color};
11
12 use crate::tui::underwater::ShellPhase;
13 use codewhale_palette::UiTheme;
14
15 /// Minimum empty-water size that earns decorative ambient life when the
16 /// underwater theme is selected. Below this, content and controls own
17 /// every cell. Shared by the renderer and idle animation scheduler so redraws
18 /// are never scheduled for invisible life.
19 pub const AMBIENT_MIN_WIDTH: u16 = 40;
20 pub const AMBIENT_MIN_HEIGHT: u16 = 10;
21
22 /// Ambient-life ink pair, independent of the Deepsea ramp and shaped by what
23 /// the agent is doing so the marks themselves carry the state at a glance:
24 /// reasoning dims toward the deep, tool work brightens like a faster current,
25 /// and a sub-agent pod swims in seafoam — the hue reserved for orchestration.
26 #[must_use]
27 pub fn ambient_inks_for_activity(
28 theme: &UiTheme,
29 activity: crate::tui::ambient_life::AmbientActivity,
30 ) -> (Color, Color) {
31 use crate::tui::ambient_life::AmbientActivity;
32 let sky = match activity {
33 AmbientActivity::Subagents => rgb(theme.accent_secondary).unwrap_or((79, 209, 197)),
34 _ => rgb(theme.info).unwrap_or((106, 174, 242)),
35 };
36 // `mix(sky, base, t)`: larger `t` sits closer to the background — dimmer.
37 let (toward_base_a, toward_base_b) = match activity {
38 AmbientActivity::Reasoning => (0.58, 0.44),
39 AmbientActivity::Reading => (0.50, 0.36),
40 AmbientActivity::Tools => (0.30, 0.18),
41 AmbientActivity::Subagents => (0.34, 0.22),
42 AmbientActivity::Verifying | AmbientActivity::Baseline => (0.42, 0.28),
43 };
44 // Only the underwater theme owns a painted base column; everywhere else
45 // the terminal's own ground (Color::Reset) is the base and the inks fall
46 // back to the theme's info lane.
47 let mix_base = rgb(theme.surface_bg)
48 .or_else(|| OceanRamp::for_theme(theme).and_then(|ramp| rgb(ramp.middle)));
49 match mix_base {
50 Some(base) => (
51 color(mix(sky, base, toward_base_a)),
52 color(mix(sky, base, toward_base_b)),
53 ),
54 None => (theme.info, theme.info),
55 }
56 }
57
58 /// Length of the completion breath (the column's settle flourish), ms.
59 pub const COMPLETION_BREATH_MS: u128 = 800;
60
61 /// Extra ms after the breath during which ambient life eases out of view.
62 pub const SETTLE_MS: u128 = 600;
63 pub(crate) const COMPLETION_SETTLE_MS: u128 = COMPLETION_BREATH_MS + SETTLE_MS;
64
65 /// Ms over which animated life ramps in when a working phase begins.
66 pub const RAMP_MS: u128 = 450;
67
68 /// Smoothstep easing: 0 at t=0, 1 at t=1, zero velocity at both ends.
69 #[must_use]
70 pub fn smoothstep(t: f32) -> f32 {
71 let t = t.clamp(0.0, 1.0);
72 t * t * (3.0 - 2.0 * t)
73 }
74
75 /// Life presence (0..=1) as a pure function of the monotonic clocks. There is
76 /// deliberately NO per-frame mutable state here: the same inputs always yield
77 /// the same output, which keeps ambient-life renders deterministic.
78 ///
79 /// Rules:
80 /// - A turn just ended (`completion_elapsed_ms` within the breath) holds full
81 /// presence so ambient life keeps swimming through the settle flourish.
82 /// - After the breath, presence eases out over [`SETTLE_MS`] so the water
83 /// settles instead of snapping from animated to frozen.
84 /// - Browsing history or the pristine empty state is user-driven: full
85 /// presence immediately.
86 /// - A Working/Verifying phase ramps in from `turn_elapsed_ms` over
87 /// [`RAMP_MS`], giving bursty fast streams a calm, bounded onset.
88 /// - Everything else is fully static.
89 #[must_use]
90 pub fn life_presence(
91 completion_elapsed_ms: Option<u128>,
92 turn_elapsed_ms: Option<u128>,
93 animated: bool,
94 browsing_history: bool,
95 empty_state: bool,
96 ) -> f32 {
97 if let Some(elapsed) = completion_elapsed_ms {
98 if elapsed < COMPLETION_BREATH_MS {
99 return 1.0;
100 }
101 let t = (elapsed - COMPLETION_BREATH_MS) as f32 / SETTLE_MS as f32;
102 return 1.0 - smoothstep(t);
103 }
104 if !animated {
105 return 0.0;
106 }
107 if browsing_history || empty_state {
108 return 1.0;
109 }
110 match turn_elapsed_ms {
111 Some(elapsed) => smoothstep(elapsed as f32 / RAMP_MS as f32),
112 None => 1.0,
113 }
114 }
115
116 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
117 pub struct OceanRamp {
118 pub surface: Color,
119 pub middle: Color,
120 pub deep: Color,
121 pub ambient: Color,
122 /// Tint for phases that are blocked on the user (Waiting / Approval).
123 /// The whole water field warms toward this so "needs you" is legible
124 /// from across the room, not only in the phase strip.
125 pub attention: Color,
126 /// Tint for the Failed outcome: a steady cast, not a pulse — it reports,
127 /// it does not ask.
128 pub failure: Color,
129 }
130
131 /// One continuous water column shared by every shell band in a frame.
132 ///
133 /// Individual widgets still own their foreground and semantic surfaces, but
134 /// ordinary shell backgrounds sample this column with their absolute row.
135 /// That keeps the header, work strip, transcript, phase line, and composer
136 /// from each restarting the same miniature gradient.
137 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
138 pub struct OceanColumn {
139 ramp: OceanRamp,
140 top: u16,
141 height: u16,
142 elapsed_ms: u128,
143 completion_elapsed_ms: Option<u128>,
144 phase: ShellPhase,
145 animated: bool,
146 /// Fixed-point (0..=1000) life presence; keeps `Eq` derivable.
147 presence: u16,
148 context_percent: u8,
149 }
150
151 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
152 struct OceanRampCacheIdentity {
153 ramp: OceanRamp,
154 top: u16,
155 height: u16,
156 phase_tag: u8,
157 animated: bool,
158 completion_active: bool,
159 presence: u16,
160 context_percent: u8,
161 }
162
163 impl OceanRampCacheIdentity {
164 fn fingerprint(self) -> u64 {
165 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
166 const PRIME: u64 = 0x0000_0100_0000_01b3;
167
168 [
169 color_cache_code(self.ramp.surface),
170 color_cache_code(self.ramp.middle),
171 color_cache_code(self.ramp.deep),
172 color_cache_code(self.ramp.ambient),
173 color_cache_code(self.ramp.attention),
174 color_cache_code(self.ramp.failure),
175 u32::from(self.top),
176 u32::from(self.height),
177 u32::from(self.phase_tag),
178 u32::from(self.animated),
179 u32::from(self.completion_active),
180 u32::from(self.presence),
181 u32::from(self.context_percent),
182 ]
183 .into_iter()
184 .flat_map(u32::to_le_bytes)
185 .fold(OFFSET_BASIS, |state, byte| {
186 (state ^ u64::from(byte)).wrapping_mul(PRIME)
187 })
188 }
189 }
190
191 fn color_cache_code(value: Color) -> u32 {
192 match value {
193 Color::Reset => 0,
194 Color::Black => 1,
195 Color::Red => 2,
196 Color::Green => 3,
197 Color::Yellow => 4,
198 Color::Blue => 5,
199 Color::Magenta => 6,
200 Color::Cyan => 7,
201 Color::Gray => 8,
202 Color::DarkGray => 9,
203 Color::LightRed => 10,
204 Color::LightGreen => 11,
205 Color::LightYellow => 12,
206 Color::LightBlue => 13,
207 Color::LightMagenta => 14,
208 Color::LightCyan => 15,
209 Color::White => 16,
210 Color::Indexed(index) => 0x0100_0000 | u32::from(index),
211 Color::Rgb(red, green, blue) => 0x0200_0000 | u32::from_be_bytes([0, red, green, blue]),
212 }
213 }
214
215 impl OceanColumn {
216 // Eight args mirroring the eight column fields; a params struct would
217 // only rename the call sites without removing a single decision.
218 #[allow(clippy::too_many_arguments)]
219 #[must_use]
220 pub fn new(
221 ramp: OceanRamp,
222 viewport: Rect,
223 elapsed_ms: u128,
224 completion_elapsed_ms: Option<u128>,
225 phase: ShellPhase,
226 animated: bool,
227 presence: u16,
228 context_percent: u8,
229 ) -> Self {
230 Self {
231 ramp,
232 top: viewport.y,
233 height: viewport.height.max(1),
234 elapsed_ms,
235 completion_elapsed_ms,
236 phase,
237 animated,
238 presence,
239 context_percent: context_percent.min(100),
240 }
241 }
242
243 #[must_use]
244 pub fn color_at_y(self, y: u16) -> Color {
245 let row = y.saturating_sub(self.top).min(self.height - 1);
246 if let Some(elapsed) = self
247 .completion_elapsed_ms
248 .filter(|elapsed| *elapsed < COMPLETION_BREATH_MS)
249 {
250 self.ramp
251 .color_at_completion_context(row, self.height, elapsed, self.context_percent)
252 } else {
253 // Attention states tint the water itself, independent of life
254 // presence: a session blocked on approval or ended in failure
255 // must stay legible from across the room even after ambient life
256 // has fully settled, and under reduced motion (where the tint is
257 // steady instead of breathing).
258 if matches!(
259 self.phase,
260 ShellPhase::Waiting | ShellPhase::Approval | ShellPhase::Failed
261 ) {
262 return self.ramp.color_at_attention_context(
263 row,
264 self.height,
265 self.phase,
266 self.context_percent,
267 );
268 }
269 // Ease between the static gradient and the phase treatment by
270 // life presence, so mood/activity changes blend instead of snap.
271 let static_color = self
272 .ramp
273 .color_at_context(row, self.height, self.context_percent);
274 if self.animated || self.presence > 0 {
275 let phase_color = self.ramp.color_at_phase_context(
276 row,
277 self.height,
278 self.elapsed_ms,
279 self.phase,
280 self.context_percent,
281 );
282 mix_colors(static_color, phase_color, self.presence_f32())
283 } else {
284 static_color
285 }
286 }
287 }
288
289 /// Life presence as a 0..=1 fraction of the fixed-point field.
290 #[must_use]
291 fn presence_f32(self) -> f32 {
292 (f32::from(self.presence) / 1000.0).clamp(0.0, 1.0)
293 }
294
295 /// Elapsed milliseconds of the completion breath, when active. Ambient
296 /// life uses this to time the rare whale cameo on successful turns.
297 #[must_use]
298 pub fn completion_elapsed_ms(self) -> Option<u128> {
299 self.completion_elapsed_ms
300 }
301
302 /// Compact phase discriminator for [`crate::tui::ambient_life::OceanRampCache`].
303 #[must_use]
304 pub fn phase_tag(self) -> u8 {
305 match self.phase {
306 ShellPhase::Idle => 0,
307 ShellPhase::Typing => 1,
308 ShellPhase::Working => 2,
309 ShellPhase::Verifying => 3,
310 ShellPhase::Waiting => 4,
311 ShellPhase::Approval => 5,
312 ShellPhase::Done => 6,
313 ShellPhase::Failed => 7,
314 }
315 }
316
317 fn ramp_cache_identity(self) -> OceanRampCacheIdentity {
318 OceanRampCacheIdentity {
319 ramp: self.ramp,
320 top: self.top,
321 height: self.height,
322 phase_tag: self.phase_tag(),
323 animated: self.animated,
324 completion_active: self
325 .completion_elapsed_ms
326 .is_some_and(|elapsed| elapsed < COMPLETION_BREATH_MS),
327 presence: self.presence,
328 context_percent: self.context_percent,
329 }
330 }
331
332 /// Deterministic fingerprint of every column input owned by the ramp cache.
333 /// Actual colors are encoded explicitly; this never depends on randomized
334 /// hashing or debug formatting.
335 #[must_use]
336 pub fn ramp_fingerprint(self) -> u64 {
337 self.ramp_cache_identity().fingerprint()
338 }
339
340 #[must_use]
341 pub fn with_viewport(mut self, viewport: Rect) -> Self {
342 self.top = viewport.y;
343 self.height = viewport.height.max(1);
344 self
345 }
346
347 /// Continue the shared column through a shell-owned surface without
348 /// flattening semantic highlights (selection, hover, error, code blocks).
349 pub fn paint_matching(self, area: Rect, buf: &mut Buffer, background: Color) {
350 for y in area.top()..area.bottom() {
351 let row_bg = self.color_at_y(y);
352 for x in area.left()..area.right() {
353 let cell = &mut buf[(x, y)];
354 if cell.bg == background {
355 cell.set_bg(row_bg);
356 }
357 }
358 }
359 }
360 }
361
362 impl OceanRamp {
363 #[must_use]
364 pub fn for_theme(theme: &UiTheme) -> Option<Self> {
365 // The painted field exists only under the underwater theme; every
366 // other theme leaves the terminal's ground alone. A user-supplied
367 // `background_color` rewrites the underwater surfaces through
368 // `with_background_color` and remains the source of truth there.
369 if theme.name != codewhale_palette::UNDERWATER_UI_THEME.name {
370 return None;
371 }
372
373 Some(Self {
374 // The authored Codewhale water column: unmistakably blue all the
375 // way to the floor. These restrained ocean shades sit between the
376 // shell's ink surfaces and its ambient blue, so the field gains
377 // depth without becoming a saturated blue panel.
378 surface: Color::Rgb(0x10, 0x2a, 0x45),
379 middle: Color::Rgb(0x0a, 0x1e, 0x33),
380 deep: Color::Rgb(0x06, 0x13, 0x20),
381 ambient: Color::Rgb(0x26, 0x48, 0x66),
382 attention: theme.warning,
383 failure: theme.error_fg,
384 })
385 }
386
387 /// Abyss Depth effect: wires context fullness (0..=100) into the water
388 /// column gradient calculation so that as context fills up, the dark
389 /// abyssal deep rises up to consume the sunlit surface gradient.
390 #[must_use]
391 pub fn color_at_context(self, row: u16, height: u16, context_percent: u8) -> Color {
392 if height <= 1 {
393 let abyss = f32::from(context_percent.min(100)) / 100.0;
394 return mix_colors(self.surface, self.deep, abyss);
395 }
396 let base_position = f32::from(row.min(height - 1)) / f32::from(height - 1);
397 let abyss_rise = f32::from(context_percent.min(100)) / 100.0;
398 let position = (base_position + abyss_rise).min(1.0);
399 // One continuous darkening curve (quadratic Bézier through
400 // surface → middle → deep, via de Casteljau).
401 let toward_middle = mix_colors(self.surface, self.middle, position);
402 let toward_deep = mix_colors(self.middle, self.deep, position);
403 mix_colors(toward_middle, toward_deep, position)
404 }
405
406 #[must_use]
407 pub fn color_at_phase_context(
408 self,
409 row: u16,
410 height: u16,
411 elapsed_ms: u128,
412 phase: ShellPhase,
413 context_percent: u8,
414 ) -> Color {
415 let base = self.color_at_context(row, height, context_percent);
416 let depth = if height <= 1 {
417 0.0
418 } else {
419 let base_depth = f32::from(row.min(height - 1)) / f32::from(height - 1);
420 let abyss_rise = f32::from(context_percent.min(100)) / 100.0;
421 (base_depth + abyss_rise).min(1.0)
422 };
423 if matches!(
424 phase,
425 ShellPhase::Waiting | ShellPhase::Approval | ShellPhase::Failed
426 ) {
427 return self.color_at_attention_context(row, height, phase, context_percent);
428 }
429 let cycle = (elapsed_ms % 90_000) as f32 / 90_000.0;
430 let breath = (cycle * std::f32::consts::TAU).sin() * 0.5 + 0.5;
431 let (phase_bias, phase_depth) = match phase {
432 ShellPhase::Idle => (0.035, 1.0 - depth),
433 ShellPhase::Typing => (0.025, 1.0 - depth),
434 ShellPhase::Working => (0.045, 0.35 + depth * 0.65),
435 ShellPhase::Verifying => (0.055, 0.65 + (1.0 - depth) * 0.35),
436 ShellPhase::Done => (0.018, 1.0 - depth),
437 ShellPhase::Waiting | ShellPhase::Approval | ShellPhase::Failed => unreachable!(),
438 };
439 mix_colors(base, self.ambient, breath * phase_bias * phase_depth)
440 }
441
442 /// Water tint for the states that need to read from across the room.
443 #[must_use]
444 pub fn color_at_attention_context(
445 self,
446 row: u16,
447 height: u16,
448 phase: ShellPhase,
449 context_percent: u8,
450 ) -> Color {
451 let base = self.color_at_context(row, height, context_percent);
452 let depth = if height <= 1 {
453 0.0
454 } else {
455 let base_depth = f32::from(row.min(height - 1)) / f32::from(height - 1);
456 let abyss_rise = f32::from(context_percent.min(100)) / 100.0;
457 (base_depth + abyss_rise).min(1.0)
458 };
459 match phase {
460 ShellPhase::Waiting | ShellPhase::Approval => {
461 mix_colors(base, self.attention, 0.10 * (0.6 + 0.4 * (1.0 - depth)))
462 }
463 ShellPhase::Failed => mix_colors(base, self.failure, 0.09),
464 _ => base,
465 }
466 }
467
468 #[must_use]
469 pub fn color_at_completion_context(
470 self,
471 row: u16,
472 height: u16,
473 elapsed_ms: u128,
474 context_percent: u8,
475 ) -> Color {
476 let base = self.color_at_context(row, height, context_percent);
477 let elapsed = elapsed_ms.min(800) as f32 / 800.0;
478 let brightness = if elapsed <= 0.4 {
479 0.88 + (1.12 - 0.88) * (elapsed / 0.4)
480 } else {
481 1.12 + (1.0 - 1.12) * ((elapsed - 0.4) / 0.6)
482 };
483 scale_color(base, brightness)
484 }
485 }
486
487 #[must_use]
488 fn rgb(value: Color) -> Option<(u8, u8, u8)> {
489 match value {
490 Color::Rgb(r, g, b) => Some((r, g, b)),
491 _ => None,
492 }
493 }
494
495 #[must_use]
496 fn color((r, g, b): (u8, u8, u8)) -> Color {
497 Color::Rgb(r, g, b)
498 }
499
500 #[must_use]
501 pub fn mix_colors(from: Color, to: Color, amount: f32) -> Color {
502 match (rgb(from), rgb(to)) {
503 (Some(from), Some(to)) => color(mix(from, to, amount)),
504 _ => from,
505 }
506 }
507
508 #[must_use]
509 pub fn scale_color(value: Color, brightness: f32) -> Color {
510 let Some((r, g, b)) = rgb(value) else {
511 return value;
512 };
513 color((
514 (f32::from(r) * brightness).round().clamp(0.0, 255.0) as u8,
515 (f32::from(g) * brightness).round().clamp(0.0, 255.0) as u8,
516 (f32::from(b) * brightness).round().clamp(0.0, 255.0) as u8,
517 ))
518 }
519
520 #[must_use]
521 fn mix(from: (u8, u8, u8), to: (u8, u8, u8), amount: f32) -> (u8, u8, u8) {
522 let amount = amount.clamp(0.0, 1.0);
523 let channel = |a: u8, b: u8| {
524 (f32::from(a) + (f32::from(b) - f32::from(a)) * amount)
525 .round()
526 .clamp(0.0, 255.0) as u8
527 };
528 (
529 channel(from.0, to.0),
530 channel(from.1, to.1),
531 channel(from.2, to.2),
532 )
533 }
534
535 #[cfg(test)]
536 #[path = "ocean/tests.rs"]
537 mod tests;
538
538 lines RUST