返回 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. Reduced motion freezes the field but does not
6 //! remove it, so choosing an underwater treatment always has a visible result.
7
8 use ratatui::{buffer::Buffer, layout::Rect, style::Color};
9
10 use crate::palette::{PaletteMode, UiTheme};
11 use crate::tui::underwater::ShellPhase;
12
13 /// Appearance treatment for the underwater shell.
14 ///
15 /// Parsed once from persisted settings so rendering and scheduling code can
16 /// branch on typed state instead of scattered string comparisons. Treatment
17 /// is appearance only: ambient life belongs to every underwater treatment,
18 /// while motion is governed separately by `low_motion`/`fancy_animations`.
19 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20 pub enum OceanTreatment {
21 /// State-reactive water column painted from the theme's [`OceanRamp`].
22 #[default]
23 Ombre,
24 /// Plain theme surface with the same state grammar and ambient life.
25 Flat,
26 }
27
28 impl OceanTreatment {
29 #[must_use]
30 pub fn parse(value: &str) -> Self {
31 let value = value.trim();
32 if value.eq_ignore_ascii_case("flat") {
33 Self::Flat
34 } else {
35 // Migration shim: the legacy "classic" shell was removed in 0.9.4;
36 // persisted settings carrying it load as the default ombre.
37 Self::Ombre
38 }
39 }
40
41 #[must_use]
42 pub fn is_ombre(self) -> bool {
43 self == Self::Ombre
44 }
45
46 #[must_use]
47 pub fn is_flat(self) -> bool {
48 self == Self::Flat
49 }
50 }
51
52 /// Minimum empty-water size that earns decorative ambient life. Below this,
53 /// content and controls own every cell. Shared by the renderer and the idle
54 /// animation scheduler so redraws are never scheduled for invisible life.
55 /// Lowered in v0.9.1 so smaller windows still retain some life.
56 pub const AMBIENT_MIN_WIDTH: u16 = 40;
57 pub const AMBIENT_MIN_HEIGHT: u16 = 10;
58
59 /// Ambient-life inks for a theme, independent of the ombre ramp. Fish use two
60 /// sunk sky-blue shades so seafoam remains reserved for live work.
61 #[must_use]
62 pub fn ambient_inks(theme: &UiTheme) -> (Color, Color) {
63 let sky = rgb(theme.info).unwrap_or((106, 174, 242));
64 match rgb(theme.surface_bg) {
65 Some(base) => (color(mix(sky, base, 0.42)), color(mix(sky, base, 0.28))),
66 None => (theme.info, theme.info),
67 }
68 }
69
70 /// Length of the completion breath (the column's settle flourish), ms.
71 pub const COMPLETION_BREATH_MS: u128 = 800;
72
73 /// Extra ms after the breath during which ambient life eases out of view.
74 pub const SETTLE_MS: u128 = 600;
75 pub(crate) const COMPLETION_SETTLE_MS: u128 = COMPLETION_BREATH_MS + SETTLE_MS;
76
77 /// Ms over which animated life ramps in when a working phase begins.
78 pub const RAMP_MS: u128 = 450;
79
80 /// Smoothstep easing: 0 at t=0, 1 at t=1, zero velocity at both ends.
81 #[must_use]
82 pub fn smoothstep(t: f32) -> f32 {
83 let t = t.clamp(0.0, 1.0);
84 t * t * (3.0 - 2.0 * t)
85 }
86
87 /// Life presence (0..=1) as a pure function of the monotonic clocks. There is
88 /// deliberately NO per-frame mutable state here: the same inputs always yield
89 /// the same output, which keeps ambient-life renders deterministic.
90 ///
91 /// Rules:
92 /// - A turn just ended (`completion_elapsed_ms` within the breath) holds full
93 /// presence so ambient life keeps swimming through the settle flourish.
94 /// - After the breath, presence eases out over [`SETTLE_MS`] so the water
95 /// settles instead of snapping from animated to frozen.
96 /// - Browsing history or the pristine empty state is user-driven: full
97 /// presence immediately.
98 /// - A Working/Verifying phase ramps in from `turn_elapsed_ms` over
99 /// [`RAMP_MS`], giving bursty fast streams a calm, bounded onset.
100 /// - Everything else is fully static.
101 #[must_use]
102 pub fn life_presence(
103 completion_elapsed_ms: Option<u128>,
104 turn_elapsed_ms: Option<u128>,
105 animated: bool,
106 browsing_history: bool,
107 empty_state: bool,
108 ) -> f32 {
109 if let Some(elapsed) = completion_elapsed_ms {
110 if elapsed < COMPLETION_BREATH_MS {
111 return 1.0;
112 }
113 let t = (elapsed - COMPLETION_BREATH_MS) as f32 / SETTLE_MS as f32;
114 return 1.0 - smoothstep(t);
115 }
116 if !animated {
117 return 0.0;
118 }
119 if browsing_history || empty_state {
120 return 1.0;
121 }
122 match turn_elapsed_ms {
123 Some(elapsed) => smoothstep(elapsed as f32 / RAMP_MS as f32),
124 None => 1.0,
125 }
126 }
127
128 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
129 pub struct OceanRamp {
130 pub surface: Color,
131 pub middle: Color,
132 pub deep: Color,
133 pub ambient: Color,
134 }
135
136 /// One continuous water column shared by every shell band in a frame.
137 ///
138 /// Individual widgets still own their foreground and semantic surfaces, but
139 /// ordinary shell backgrounds sample this column with their absolute row.
140 /// That keeps the header, work strip, transcript, phase line, and composer
141 /// from each restarting the same miniature gradient.
142 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
143 pub struct OceanColumn {
144 ramp: OceanRamp,
145 top: u16,
146 height: u16,
147 elapsed_ms: u128,
148 completion_elapsed_ms: Option<u128>,
149 phase: ShellPhase,
150 animated: bool,
151 /// Fixed-point (0..=1000) life presence; keeps `Eq` derivable.
152 presence: u16,
153 }
154
155 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
156 struct OceanRampCacheIdentity {
157 ramp: OceanRamp,
158 top: u16,
159 height: u16,
160 phase_tag: u8,
161 animated: bool,
162 completion_active: bool,
163 presence: u16,
164 }
165
166 impl OceanRampCacheIdentity {
167 fn fingerprint(self) -> u64 {
168 const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
169 const PRIME: u64 = 0x0000_0100_0000_01b3;
170
171 [
172 color_cache_code(self.ramp.surface),
173 color_cache_code(self.ramp.middle),
174 color_cache_code(self.ramp.deep),
175 color_cache_code(self.ramp.ambient),
176 u32::from(self.top),
177 u32::from(self.height),
178 u32::from(self.phase_tag),
179 u32::from(self.animated),
180 u32::from(self.completion_active),
181 u32::from(self.presence),
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 #[must_use]
217 pub fn new(
218 ramp: OceanRamp,
219 viewport: Rect,
220 elapsed_ms: u128,
221 completion_elapsed_ms: Option<u128>,
222 phase: ShellPhase,
223 animated: bool,
224 presence: u16,
225 ) -> Self {
226 Self {
227 ramp,
228 top: viewport.y,
229 height: viewport.height.max(1),
230 elapsed_ms,
231 completion_elapsed_ms,
232 phase,
233 animated,
234 presence,
235 }
236 }
237
238 #[must_use]
239 pub fn color_at_y(self, y: u16) -> Color {
240 let row = y.saturating_sub(self.top).min(self.height - 1);
241 if let Some(elapsed) = self.completion_elapsed_ms {
242 self.ramp.color_at_completion(row, self.height, elapsed)
243 } else {
244 // Ease between the static gradient and the phase treatment by
245 // life presence, so mood/activity changes blend instead of snap.
246 let static_color = self.ramp.color_at(row, self.height);
247 if self.animated || self.presence > 0 {
248 let phase_color =
249 self.ramp
250 .color_at_phase(row, self.height, self.elapsed_ms, self.phase);
251 mix_colors(static_color, phase_color, self.presence_f32())
252 } else {
253 static_color
254 }
255 }
256 }
257
258 /// Life presence as a 0..=1 fraction of the fixed-point field.
259 #[must_use]
260 fn presence_f32(self) -> f32 {
261 (f32::from(self.presence) / 1000.0).clamp(0.0, 1.0)
262 }
263
264 /// Elapsed milliseconds of the completion breath, when active. Ambient
265 /// life uses this to time the rare whale cameo on successful turns.
266 #[must_use]
267 pub fn completion_elapsed_ms(self) -> Option<u128> {
268 self.completion_elapsed_ms
269 }
270
271 /// Compact phase discriminator for [`crate::tui::ambient_life::OceanRampCache`].
272 #[must_use]
273 pub fn phase_tag(self) -> u8 {
274 match self.phase {
275 ShellPhase::Idle => 0,
276 ShellPhase::Typing => 1,
277 ShellPhase::Working => 2,
278 ShellPhase::Verifying => 3,
279 ShellPhase::Waiting => 4,
280 ShellPhase::Approval => 5,
281 ShellPhase::Done => 6,
282 ShellPhase::Failed => 7,
283 }
284 }
285
286 fn ramp_cache_identity(self) -> OceanRampCacheIdentity {
287 OceanRampCacheIdentity {
288 ramp: self.ramp,
289 top: self.top,
290 height: self.height,
291 phase_tag: self.phase_tag(),
292 animated: self.animated,
293 completion_active: self.completion_elapsed_ms.is_some(),
294 presence: self.presence,
295 }
296 }
297
298 /// Deterministic fingerprint of every column input owned by the ramp cache.
299 /// Actual colors are encoded explicitly; this never depends on randomized
300 /// hashing or debug formatting.
301 #[must_use]
302 pub fn ramp_fingerprint(self) -> u64 {
303 self.ramp_cache_identity().fingerprint()
304 }
305
306 #[must_use]
307 pub fn with_viewport(mut self, viewport: Rect) -> Self {
308 self.top = viewport.y;
309 self.height = viewport.height.max(1);
310 self
311 }
312
313 /// Continue the shared column through a shell-owned surface without
314 /// flattening semantic highlights (selection, hover, error, code blocks).
315 pub fn paint_matching(self, area: Rect, buf: &mut Buffer, background: Color) {
316 for y in area.top()..area.bottom() {
317 let row_bg = self.color_at_y(y);
318 for x in area.left()..area.right() {
319 let cell = &mut buf[(x, y)];
320 if cell.bg == background {
321 cell.set_bg(row_bg);
322 }
323 }
324 }
325 }
326 }
327
328 impl OceanRamp {
329 #[must_use]
330 pub fn for_theme(theme: &UiTheme) -> Option<Self> {
331 // Solarized Light's canonical Base3 (#fdf6e3) background is part of
332 // the named palette's contract. Tinting it with the underwater field
333 // turns the shell green-grey and no longer renders Solarized Light
334 // (#4457). A non-canonical user-supplied background is a separate
335 // contract and must keep the configured ombre treatment.
336 if theme.mode == PaletteMode::SolarizedLight
337 && theme.surface_bg == crate::palette::SOLARIZED_LIGHT_UI_THEME.surface_bg
338 {
339 return None;
340 }
341
342 // The canonical Whale pair gets the authored Codewhale water column.
343 // Match both name and surface so a user-supplied `background_color`
344 // remains the source of truth and still receives the generic ramp.
345 if theme.name == crate::palette::UI_THEME.name
346 && theme.surface_bg == crate::palette::UI_THEME.surface_bg
347 {
348 return Some(Self {
349 surface: Color::Rgb(0x0e, 0x17, 0x29),
350 middle: Color::Rgb(0x08, 0x11, 0x1c),
351 deep: Color::Rgb(0x03, 0x07, 0x0d),
352 ambient: Color::Rgb(0x26, 0x48, 0x66),
353 });
354 }
355 if theme.name == crate::palette::LIGHT_UI_THEME.name
356 && theme.surface_bg == crate::palette::LIGHT_UI_THEME.surface_bg
357 {
358 return Some(Self {
359 surface: Color::Rgb(0xff, 0xfd, 0xf8),
360 middle: Color::Rgb(0xf4, 0xf7, 0xfb),
361 deep: Color::Rgb(0xf0, 0xf4, 0xf9),
362 ambient: Color::Rgb(0x9a, 0xb8, 0xe0),
363 });
364 }
365
366 let base = rgb(theme.surface_bg)?;
367 let seafoam = rgb(theme.accent_secondary).unwrap_or((79, 209, 197));
368
369 let (surface, middle, deep) = match theme.mode {
370 PaletteMode::Light | PaletteMode::SolarizedLight => (
371 mix(base, seafoam, 0.07),
372 mix(base, seafoam, 0.13),
373 mix(base, (70, 139, 196), 0.18),
374 ),
375 PaletteMode::Dark | PaletteMode::Grayscale => (
376 mix(base, (30, 71, 103), 0.24),
377 mix(base, (7, 30, 54), 0.40),
378 mix(base, (2, 9, 24), 0.64),
379 ),
380 };
381
382 Some(Self {
383 surface: color(surface),
384 middle: color(middle),
385 deep: color(deep),
386 ambient: color(mix(seafoam, base, 0.42)),
387 })
388 }
389
390 #[must_use]
391 pub fn color_at(self, row: u16, height: u16) -> Color {
392 if height <= 1 {
393 return self.surface;
394 }
395 let position = f32::from(row.min(height - 1)) / f32::from(height - 1);
396 if position <= 0.42 {
397 mix_colors(self.surface, self.middle, position / 0.42)
398 } else {
399 mix_colors(self.middle, self.deep, (position - 0.42) / 0.58)
400 }
401 }
402
403 #[must_use]
404 pub fn color_at_phase(
405 self,
406 row: u16,
407 height: u16,
408 elapsed_ms: u128,
409 phase: ShellPhase,
410 ) -> Color {
411 let base = self.color_at(row, height);
412 let depth = if height <= 1 {
413 0.0
414 } else {
415 f32::from(row.min(height - 1)) / f32::from(height - 1)
416 };
417 if matches!(
418 phase,
419 ShellPhase::Waiting | ShellPhase::Approval | ShellPhase::Failed
420 ) {
421 return base;
422 }
423 let cycle = (elapsed_ms % 90_000) as f32 / 90_000.0;
424 let breath = (cycle * std::f32::consts::TAU).sin() * 0.5 + 0.5;
425 let (phase_bias, phase_depth) = match phase {
426 ShellPhase::Idle => (0.035, 1.0 - depth),
427 ShellPhase::Typing => (0.025, 1.0 - depth),
428 ShellPhase::Working => (0.045, 0.35 + depth * 0.65),
429 ShellPhase::Verifying => (0.055, 0.65 + (1.0 - depth) * 0.35),
430 ShellPhase::Done => (0.018, 1.0 - depth),
431 ShellPhase::Waiting | ShellPhase::Approval | ShellPhase::Failed => unreachable!(),
432 };
433 mix_colors(base, self.ambient, breath * phase_bias * phase_depth)
434 }
435
436 #[must_use]
437 pub fn color_at_completion(self, row: u16, height: u16, elapsed_ms: u128) -> Color {
438 let base = self.color_at(row, height);
439 let elapsed = elapsed_ms.min(800) as f32 / 800.0;
440 let brightness = if elapsed <= 0.4 {
441 0.88 + (1.12 - 0.88) * (elapsed / 0.4)
442 } else {
443 1.12 + (1.0 - 1.12) * ((elapsed - 0.4) / 0.6)
444 };
445 scale_color(base, brightness)
446 }
447 }
448
449 #[must_use]
450 fn rgb(value: Color) -> Option<(u8, u8, u8)> {
451 match value {
452 Color::Rgb(r, g, b) => Some((r, g, b)),
453 _ => None,
454 }
455 }
456
457 #[must_use]
458 fn color((r, g, b): (u8, u8, u8)) -> Color {
459 Color::Rgb(r, g, b)
460 }
461
462 #[must_use]
463 pub fn mix_colors(from: Color, to: Color, amount: f32) -> Color {
464 match (rgb(from), rgb(to)) {
465 (Some(from), Some(to)) => color(mix(from, to, amount)),
466 _ => from,
467 }
468 }
469
470 #[must_use]
471 pub fn scale_color(value: Color, brightness: f32) -> Color {
472 let Some((r, g, b)) = rgb(value) else {
473 return value;
474 };
475 color((
476 (f32::from(r) * brightness).round().clamp(0.0, 255.0) as u8,
477 (f32::from(g) * brightness).round().clamp(0.0, 255.0) as u8,
478 (f32::from(b) * brightness).round().clamp(0.0, 255.0) as u8,
479 ))
480 }
481
482 #[must_use]
483 fn mix(from: (u8, u8, u8), to: (u8, u8, u8), amount: f32) -> (u8, u8, u8) {
484 let amount = amount.clamp(0.0, 1.0);
485 let channel = |a: u8, b: u8| {
486 (f32::from(a) + (f32::from(b) - f32::from(a)) * amount)
487 .round()
488 .clamp(0.0, 255.0) as u8
489 };
490 (
491 channel(from.0, to.0),
492 channel(from.1, to.1),
493 channel(from.2, to.2),
494 )
495 }
496
497 #[cfg(test)]
498 #[path = "ocean/tests.rs"]
499 mod tests;
500
500 lines RUST