返回 CodeWhale
ocean.rs
1 //! Codewhale's terminal-native underwater column, as a finishing pass.
2 //!
3 //! Adapted from `Hmbown/CodeWhale`'s `crates/tui/src/tui/ocean.rs` at
4 //! `a79ce5c4d5ed1a5f7032185710c27343a900351c`: the authored three stops,
5 //! quadratic depth curve, context rise, 90-second phase breath, steady
6 //! attention/failure tint and 800-millisecond completion breath. The host
7 //! supplies time and phase; this module owns no clock, event loop or theme.
8 //!
9 //! Paint ordinary components first, then apply the column to their ordinary
10 //! `Background` and `Sidebar` cells. Raised surfaces, selections, diff/code
11 //! grounds, custom fills, symbols, inks and modifiers remain theirs. The
12 //! authored dark field is available only on known dark truecolor Ocean
13 //! grounds. Graphite, light, unknown grounds and lower depths keep the theme.
14 //! Native chrome can join the same column with [`OceanColumn::apply_matching`]
15 //! over its own region and explicit base ground. Native cached-row painting,
16 //! semantic-surface projection and sparse caustics are adapted from the same
17 //! MIT-licensed Engine at `e7de150f3621740c982e7e6c90d69ac48ccc80d5`;
18 //! capability facts, palette adaptation, cache and redraw lifecycle stay host-owned.
19
20 use std::time::Duration;
21
22 use ratatui::{
23 buffer::{Buffer, Cell},
24 layout::{Alignment, Rect},
25 style::{Color, Modifier},
26 text::Line,
27 };
28
29 use unicode_segmentation::UnicodeSegmentation;
30
31 use crate::{
32 Ground, MotionMode, Paint, Role, Theme,
33 color::{ColorDepth, blend, contrast_ratio, rgb},
34 detect::Appearance,
35 };
36
37 /// A caller-reported phase, matching the native terminal's `ShellPhase`.
38 /// It is visual input, never a state machine or evidence of work completing.
39 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
40 pub enum OceanPhase {
41 #[default]
42 Idle,
43 Typing,
44 Working,
45 Verifying,
46 Waiting,
47 Approval,
48 Done,
49 Failed,
50 }
51
52 impl OceanPhase {
53 const fn needs_attention(self) -> bool {
54 matches!(self, Self::Waiting | Self::Approval | Self::Failed)
55 }
56 }
57
58 /// The native dark underwater ramp. Semantic tints come from [`Theme`].
59 /// [`Self::for_theme`] resolves the guarded default; [`Self::new`] supplies
60 /// exact caller colors for pure math. Neither replaces a theme table.
61 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
62 pub struct OceanRamp {
63 surface: Color,
64 middle: Color,
65 deep: Color,
66 ambient: Color,
67 attention: Color,
68 failure: Color,
69 }
70
71 impl OceanRamp {
72 /// The native sunlit stop, `#102a45`.
73 pub const SURFACE: Color = rgb(0x102a45);
74 /// The native middle control point, `#0a1e33`.
75 pub const MIDDLE: Color = rgb(0x0a1e33);
76 /// The native deep stop, `#061320`.
77 pub const DEEP: Color = rgb(0x061320);
78 /// The native ambient light, `#264866`.
79 pub const AMBIENT: Color = rgb(0x264866);
80 /// The authored completion pulse ends after 800 milliseconds.
81 pub const COMPLETION_BREATH: Duration = Duration::from_millis(800);
82
83 /// An explicit caller-owned ramp. Pure sampling may use these exact
84 /// colors; guarded column painting still requires the normal theme and
85 /// capability gates and preserves its contrast policy.
86 #[must_use]
87 pub const fn new(
88 surface: Color,
89 middle: Color,
90 deep: Color,
91 ambient: Color,
92 attention: Color,
93 failure: Color,
94 ) -> Self {
95 Self {
96 surface,
97 middle,
98 deep,
99 ambient,
100 attention,
101 failure,
102 }
103 }
104
105 /// Resolve the native field only where the host selected known dark
106 /// truecolor Ocean grounds. A terminal-owned base is never overwritten.
107 #[must_use]
108 pub fn for_theme(theme: &Theme) -> Option<Self> {
109 if theme.depth() != ColorDepth::TrueColor
110 || theme.caps().appearance != Appearance::Dark
111 || theme.ground_kind() != Ground::Ocean
112 || theme
113 .native_palette()
114 .is_some_and(|palette| palette != crate::TuiPalette::Underwater)
115 || !theme.paints_base_ground()
116 {
117 return None;
118 }
119 Some(Self::new(
120 Self::SURFACE,
121 Self::MIDDLE,
122 Self::DEEP,
123 Self::AMBIENT,
124 theme.color(Role::Attention)?,
125 theme.color(Role::Danger)?,
126 ))
127 }
128
129 /// The native quadratic surface → middle → deep curve. Explicit context
130 /// fullness (0–100) raises the abyss; omit it at the component level when
131 /// the host has no measured context value. Rows outside the column clamp.
132 #[must_use]
133 pub fn color_at_context(self, row: u16, height: u16, context_percent: u8) -> Color {
134 let rise = f32::from(context_percent.min(100)) / 100.0;
135 if height <= 1 {
136 return mix_toward(self.surface, self.deep, rise);
137 }
138 let position = (f32::from(row.min(height - 1)) / f32::from(height - 1) + rise).min(1.0);
139 let toward_middle = mix_toward(self.surface, self.middle, position);
140 let toward_deep = mix_toward(self.middle, self.deep, position);
141 mix_toward(toward_middle, toward_deep, position)
142 }
143
144 /// The native phase treatment at caller time. Waiting/approval are warm
145 /// and failure is steady; neither depends on elapsed time.
146 #[must_use]
147 pub fn color_at_phase_context(
148 self,
149 row: u16,
150 height: u16,
151 elapsed: Duration,
152 phase: OceanPhase,
153 context_percent: u8,
154 ) -> Color {
155 if phase.needs_attention() {
156 return self.color_at_attention_context(row, height, phase, context_percent);
157 }
158 let base = self.color_at_context(row, height, context_percent);
159 let depth = depth_at(row, height, context_percent);
160 let cycle = (elapsed.as_millis() % 90_000) as f32 / 90_000.0;
161 let breath = (cycle * std::f32::consts::TAU).sin() * 0.5 + 0.5;
162 let (bias, phase_depth) = match phase {
163 OceanPhase::Idle => (0.035, 1.0 - depth),
164 OceanPhase::Typing => (0.025, 1.0 - depth),
165 OceanPhase::Working => (0.045, 0.35 + depth * 0.65),
166 OceanPhase::Verifying => (0.055, 0.65 + (1.0 - depth) * 0.35),
167 OceanPhase::Done => (0.018, 1.0 - depth),
168 OceanPhase::Waiting | OceanPhase::Approval | OceanPhase::Failed => unreachable!(),
169 };
170 mix_toward(base, self.ambient, breath * bias * phase_depth)
171 }
172
173 /// The native steady attention/failure tint, also used in reduced motion.
174 #[must_use]
175 pub fn color_at_attention_context(
176 self,
177 row: u16,
178 height: u16,
179 phase: OceanPhase,
180 context_percent: u8,
181 ) -> Color {
182 let base = self.color_at_context(row, height, context_percent);
183 match phase {
184 OceanPhase::Waiting | OceanPhase::Approval => mix_toward(
185 base,
186 self.attention,
187 0.10 * (0.6 + 0.4 * (1.0 - depth_at(row, height, context_percent))),
188 ),
189 OceanPhase::Failed => mix_toward(base, self.failure, 0.09),
190 _ => base,
191 }
192 }
193
194 /// The native completion brightness: 88% → 112% at 320 ms → 100% at
195 /// 800 ms. The component uses it only for a reported `Done` phase in
196 /// [`MotionMode::Full`]; a stale completion clock cannot mask failure.
197 #[must_use]
198 pub fn color_at_completion_context(
199 self,
200 row: u16,
201 height: u16,
202 elapsed: Duration,
203 context_percent: u8,
204 ) -> Color {
205 let base = self.color_at_context(row, height, context_percent);
206 let t = elapsed.as_millis().min(800) as f32 / 800.0;
207 let brightness = if t <= 0.4 {
208 0.88 + (1.12 - 0.88) * (t / 0.4)
209 } else {
210 1.12 + (1.0 - 1.12) * ((t - 0.4) / 0.6)
211 };
212 let Color::Rgb(r, g, b) = base else {
213 return base;
214 };
215 let scale = |c| (f32::from(c) * brightness).round().clamp(0.0, 255.0) as u8;
216 Color::Rgb(scale(r), scale(g), scale(b))
217 }
218 }
219
220 // Explicit native ramps may carry a terminal-owned or indexed tint. There
221 // is no RGB interpolation evidence in that case; retain the source color.
222 fn mix_toward(from: Color, to: Color, amount: f32) -> Color {
223 if matches!((from, to), (Color::Rgb(..), Color::Rgb(..))) {
224 blend(to, from, amount)
225 } else {
226 from
227 }
228 }
229
230 fn depth_at(row: u16, height: u16, context_percent: u8) -> f32 {
231 if height <= 1 {
232 0.0
233 } else {
234 (f32::from(row.min(height - 1)) / f32::from(height - 1)
235 + f32::from(context_percent.min(100)) / 100.0)
236 .min(1.0)
237 }
238 }
239
240 /// One shared native water column over caller-owned components.
241 ///
242 /// The host supplies elapsed time and decides when a visible field deserves
243 /// another redraw. [`MotionMode::Reduced`] and [`MotionMode::Still`] ignore
244 /// both clocks and keep steady attention/failure tint. ASCII changes no text
245 /// here: this finishing pass paints backgrounds only.
246 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
247 pub struct OceanColumn {
248 ramp: Option<OceanRamp>,
249 elapsed: Duration,
250 motion: MotionMode,
251 phase: OceanPhase,
252 completion_elapsed: Option<Duration>,
253 viewport: Option<Rect>,
254 context_percent: u8,
255 presence: u16,
256 contrast_inks: Option<OceanContrastInks>,
257 }
258
259 impl OceanColumn {
260 #[must_use]
261 pub const fn new(elapsed: Duration, motion: MotionMode) -> Self {
262 Self {
263 ramp: None,
264 elapsed,
265 motion,
266 phase: OceanPhase::Idle,
267 completion_elapsed: None,
268 viewport: None,
269 context_percent: 0,
270 presence: 1000,
271 contrast_inks: None,
272 }
273 }
274
275 /// Supply the host's actual ramp without granting paint capability.
276 /// `color_at_y`, `apply` and `apply_matching` keep their existing theme,
277 /// terminal, semantic-surface and contrast guards.
278 #[must_use]
279 pub const fn ramp(mut self, ramp: OceanRamp) -> Self {
280 self.ramp = Some(ramp);
281 self
282 }
283
284 #[must_use]
285 pub const fn phase(mut self, phase: OceanPhase) -> Self {
286 self.phase = phase;
287 self
288 }
289
290 /// Caller-measured context fullness, clamped to 100. The default leaves
291 /// the authored column at its normal depth and asserts no usage fact.
292 #[must_use]
293 pub const fn context_percent(mut self, context_percent: u8) -> Self {
294 self.context_percent = if context_percent > 100 {
295 100
296 } else {
297 context_percent
298 };
299 self
300 }
301
302 /// Caller time since an actual successful completion. Honored only in
303 /// `Done` with Full motion, and only before the native 800 ms boundary.
304 #[must_use]
305 pub const fn completion_elapsed(mut self, elapsed: Duration) -> Self {
306 self.completion_elapsed = Some(elapsed);
307 self
308 }
309
310 /// Share this viewport across separate bands so the gradient continues
311 /// through them. Without it, the paint area is the complete column.
312 #[must_use]
313 pub const fn viewport(mut self, viewport: Rect) -> Self {
314 self.viewport = Some(viewport);
315 self
316 }
317
318 /// Host-sampled ambient presence, 0–1000 as in the native renderer.
319 /// This eases only phase breathing; attention/failure remain steady and
320 /// the successful completion pulse keeps its own gated clock.
321 #[must_use]
322 pub const fn presence(mut self, presence: u16) -> Self {
323 self.presence = if presence > 1000 { 1000 } else { presence };
324 self
325 }
326
327 /// Pure sampling at an absolute row. `viewport` supplies the default
328 /// column bounds unless the builder already specifies shared bounds.
329 /// Returns `None` where the native authored field is unavailable.
330 #[must_use]
331 pub fn color_at_y(&self, y: u16, viewport: Rect, theme: &Theme) -> Option<Color> {
332 let ramp = OceanRamp::for_theme(theme)?;
333 Some(self.color_at_y_with_ramp(y, viewport, ramp))
334 }
335
336 /// Pure sampling under a caller-owned rendering policy.
337 /// Like the ramp's math helpers, this paints nothing and has no terminal
338 /// capability gate; guarded sampling and painting keep their own gates.
339 /// A builder-supplied `ramp` overrides this fallback ramp; `viewport`
340 /// likewise retains the shared absolute column. Motion, completion,
341 /// phase, context and presence follow the same kernel as guarded paint.
342 /// Hosts must keep their existing paint/terminal guards around the result.
343 #[must_use]
344 pub fn color_at_y_with_ramp(&self, y: u16, viewport: Rect, ramp: OceanRamp) -> Color {
345 let viewport = self.viewport.unwrap_or(viewport);
346 let ramp = self.ramp.unwrap_or(ramp);
347 let height = viewport.height.max(1);
348 let row = y.saturating_sub(viewport.y).min(height - 1);
349 if self.motion.animates()
350 && self.phase == OceanPhase::Done
351 && let Some(elapsed) = self
352 .completion_elapsed
353 .filter(|t| *t < OceanRamp::COMPLETION_BREATH)
354 {
355 return ramp.color_at_completion_context(row, height, elapsed, self.context_percent);
356 }
357 if self.phase.needs_attention() {
358 return ramp.color_at_attention_context(row, height, self.phase, self.context_percent);
359 }
360 let base = ramp.color_at_context(row, height, self.context_percent);
361 if !self.motion.animates() || self.presence == 0 {
362 return base;
363 }
364 let phase = ramp.color_at_phase_context(
365 row,
366 height,
367 self.elapsed,
368 self.phase,
369 self.context_percent,
370 );
371 mix_toward(base, phase, f32::from(self.presence) / 1000.0)
372 }
373
374 /// Finish ordinary `Background` and `Sidebar` cells, clipped to the
375 /// buffer. Other fills remain exact. Visible inks must retain their
376 /// contrast floor; unknown terminal inks and reversed cells are spared.
377 pub fn apply(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
378 self.apply_grounds(
379 area,
380 buf,
381 theme,
382 &[theme.bg(Role::Background).bg, theme.bg(Role::Sidebar).bg],
383 );
384 }
385
386 /// Continue the column through cells with the caller's explicit base
387 /// `ground`, restricted to `area`. Use [`Self::viewport`] to share one
388 /// absolute water column across conversation, composer and footer bands.
389 ///
390 /// Other grounds, symbols, inks and modifiers remain untouched. The
391 /// existing theme, clipping and contrast policy still applies, including
392 /// protection for unknown inks and reversed cells. The caller chooses a
393 /// region containing ordinary chrome: a semantic surface using this same
394 /// base color must be kept outside that region.
395 pub fn apply_matching(&self, area: Rect, buf: &mut Buffer, theme: &Theme, ground: Color) {
396 self.apply_grounds(area, buf, theme, &[Some(ground)]);
397 }
398
399 /// Native finishing inputs: cached samples and semantic rectangles are
400 /// caller facts, while capability, clipping, reverse and contrast guards
401 /// are the same guard kernel used by `apply` and `apply_matching`.
402 /// `project_ink` returns the host backend's actual proposed visible ink;
403 /// it never mutates the source cell, symbols or modifiers.
404 pub fn apply_native(
405 &self,
406 area: Rect,
407 buf: &mut Buffer,
408 theme: &Theme,
409 facts: &OceanPaintFacts<'_>,
410 project_ink: impl Fn(&Cell, Color) -> Color,
411 ) {
412 self.apply_grounds_with(area, buf, theme, &[Some(facts.ground)], facts, project_ink);
413 }
414
415 /// Map the three decorative/supporting floors to exact live host colors.
416 /// Other inks keep the text floor, including unrecognized custom colors.
417 #[must_use]
418 pub const fn contrast_inks(mut self, inks: OceanContrastInks) -> Self {
419 self.contrast_inks = Some(inks);
420 self
421 }
422
423 fn apply_grounds(
424 &self,
425 area: Rect,
426 buf: &mut Buffer,
427 theme: &Theme,
428 grounds: &[Option<Color>],
429 ) {
430 self.apply_grounds_with(
431 area,
432 buf,
433 theme,
434 grounds,
435 &OceanPaintFacts::new(Color::Reset),
436 |cell, _| cell.fg,
437 );
438 }
439
440 fn apply_grounds_with(
441 &self,
442 area: Rect,
443 buf: &mut Buffer,
444 theme: &Theme,
445 grounds: &[Option<Color>],
446 facts: &OceanPaintFacts<'_>,
447 project_ink: impl Fn(&Cell, Color) -> Color,
448 ) {
449 let Some(ramp) = OceanRamp::for_theme(theme) else {
450 return;
451 };
452 let viewport = self.viewport.unwrap_or(area);
453 let area = area.intersection(buf.area);
454 for y in area.top()..area.bottom() {
455 let water = facts
456 .samples
457 .get(usize::from(y.saturating_sub(facts.sample_top)))
458 .filter(|_| y >= facts.sample_top)
459 .copied()
460 .unwrap_or_else(|| self.color_at_y_with_ramp(y, viewport, ramp));
461 let mut previous_ink = None;
462 for x in area.left()..area.right() {
463 if facts
464 .protected
465 .iter()
466 .any(|rect| rect.contains((x, y).into()))
467 {
468 continue;
469 }
470 let cell = &mut buf[(x, y)];
471 if grounds.contains(&Some(cell.bg)) && !cell.modifier.contains(Modifier::REVERSED) {
472 let safe = cell.symbol() == " " || {
473 let ink = project_ink(cell, water);
474 if let Some((previous, safe)) = previous_ink
475 && previous == ink
476 {
477 safe
478 } else {
479 let safe = ink_is_safe(ink, water, theme, self.contrast_inks);
480 previous_ink = Some((ink, safe));
481 safe
482 }
483 };
484 if safe {
485 cell.set_bg(water);
486 }
487 }
488 }
489 }
490 }
491
492 /// Sparse caustics can finish only already painted ordinary water.
493 /// They share the same capability/motion guard and never cross a semantic
494 /// rectangle, a different ground, visible ink or a reversed cell.
495 pub fn apply_caustics(
496 &self,
497 area: Rect,
498 buf: &mut Buffer,
499 theme: &Theme,
500 facts: &OceanCausticFacts<'_>,
501 ) {
502 if !self.motion.animates() || area.width < 40 || area.height < 10 {
503 return;
504 }
505 let Some(ramp) = OceanRamp::for_theme(theme) else {
506 return;
507 };
508 let viewport = self.viewport.unwrap_or(area);
509 let clipped = area.intersection(buf.area);
510 let band = facts.band_rows.min(area.height);
511 for y in clipped.y..clipped.bottom().min(area.y.saturating_add(band)) {
512 let local_y = y.saturating_sub(area.y);
513 let water = facts
514 .paint
515 .samples
516 .get(usize::from(y.saturating_sub(facts.paint.sample_top)))
517 .filter(|_| y >= facts.paint.sample_top)
518 .copied()
519 .unwrap_or_else(|| self.color_at_y_with_ramp(y, viewport, ramp));
520 let depth = 1.0 - f32::from(local_y) / f32::from(band.max(1));
521 for x in clipped.x..clipped.right() {
522 let local_x = x.saturating_sub(area.x);
523 if local_x % 3 != 0
524 || facts
525 .paint
526 .protected
527 .iter()
528 .any(|r| r.contains((x, y).into()))
529 {
530 continue;
531 }
532 let cell = &mut buf[(x, y)];
533 if cell.bg != water
534 || cell.modifier.contains(Modifier::REVERSED)
535 || !(cell.symbol() == " " || cell.symbol().is_empty())
536 {
537 continue;
538 }
539 let brightness =
540 ocean_caustic_brightness(facts.elapsed, local_x, local_y, depth * depth);
541 if let Color::Rgb(r, g, b) = water {
542 let scale =
543 |value| (f32::from(value) * brightness).round().clamp(0.0, 255.0) as u8;
544 cell.set_bg(Color::Rgb(scale(r), scale(g), scale(b)));
545 }
546 }
547 }
548 }
549 }
550
551 impl Paint for OceanColumn {
552 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
553 self.apply(area, buf, theme);
554 }
555 }
556
557 fn ink_is_safe(ink: Color, water: Color, theme: &Theme, live: Option<OceanContrastInks>) -> bool {
558 if let Some(live) = live {
559 let floor = if live.border == Some(ink) {
560 1.0
561 } else if live.border_strong == Some(ink) || live.dim == Some(ink) {
562 3.0
563 } else {
564 4.5
565 };
566 return contrast_ratio(ink, water).is_some_and(|ratio| ratio >= floor);
567 }
568 let floor = if [Role::Border, Role::BorderStrong, Role::Dim]
569 .into_iter()
570 .any(|role| theme.color(role) == Some(ink))
571 {
572 // Border is purely decorative and deliberately has no text floor.
573 if theme.color(Role::Border) == Some(ink) {
574 1.0
575 } else {
576 3.0
577 }
578 } else {
579 4.5
580 };
581 contrast_ratio(ink, water).is_some_and(|ratio| ratio >= floor)
582 }
583
584 /// Actual live host colors for the existing decorative/supporting floors.
585 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
586 pub struct OceanContrastInks {
587 pub border: Option<Color>,
588 pub border_strong: Option<Color>,
589 pub dim: Option<Color>,
590 }
591
592 /// Bounded frame-derived presentation facts; no cache or surface store.
593 #[derive(Clone, Copy, Debug)]
594 pub struct OceanPaintFacts<'a> {
595 pub ground: Color,
596 pub sample_top: u16,
597 pub samples: &'a [Color],
598 pub protected: &'a [Rect],
599 }
600 impl OceanPaintFacts<'_> {
601 #[must_use]
602 pub const fn new(ground: Color) -> Self {
603 Self {
604 ground,
605 sample_top: 0,
606 samples: &[],
607 protected: &[],
608 }
609 }
610 }
611
612 /// The host reports elapsed time and its unoccupied surface-band height.
613 #[derive(Clone, Copy, Debug)]
614 pub struct OceanCausticFacts<'a> {
615 pub paint: OceanPaintFacts<'a>,
616 pub elapsed: Duration,
617 pub band_rows: u16,
618 }
619
620 /// The native continuous 960ms travelling crest; owns no clock or loop.
621 #[must_use]
622 pub fn ocean_caustic_brightness(
623 elapsed: Duration,
624 local_x: u16,
625 local_y: u16,
626 depth_fade: f32,
627 ) -> f32 {
628 let time = (elapsed.as_millis() % 960) as f64 / 960.0;
629 let slot = (u32::from(local_x / 3) + u32::from(local_y)) % 4;
630 let phase = (time + f64::from(slot) / 4.0) * std::f64::consts::TAU;
631 let crest = ((phase.cos() + 1.0) * 0.5).powi(8);
632 1.0 + 0.08 * (crest as f32) * depth_fade.clamp(0.0, 1.0)
633 }
634
635 /// Explicit styled grounds are semantic even when a custom theme aliases
636 /// their RGB to an ordinary pane. Project guarded prewrapped source once;
637 /// callers pass these frame-only rectangles to native finishing.
638 #[must_use]
639 pub fn ocean_semantic_surfaces(
640 rows: &[Line<'static>],
641 area: Rect,
642 measure_grapheme: impl Fn(&str) -> usize,
643 ) -> Vec<Rect> {
644 let measure = |value: &str| {
645 crate::text::display_safe(value)
646 .graphemes(true)
647 .fold(0usize, |width, g| width.saturating_add(measure_grapheme(g)))
648 };
649 let mut regions = Vec::new();
650 for (index, line) in rows.iter().take(usize::from(area.height)).enumerate() {
651 let y = area
652 .y
653 .saturating_add(u16::try_from(index).unwrap_or(u16::MAX));
654 if line.style.bg.is_some() {
655 if area.width > 0 {
656 regions.push(Rect::new(area.x, y, area.width, 1));
657 }
658 continue;
659 }
660 let width = line.spans.iter().fold(0usize, |width, span| {
661 width.saturating_add(measure(&span.content))
662 });
663 let padding = usize::from(area.width).saturating_sub(width);
664 let mut column = match line.alignment.unwrap_or(Alignment::Left) {
665 Alignment::Left => 0,
666 Alignment::Center => padding / 2,
667 Alignment::Right => padding,
668 };
669 for span in &line.spans {
670 let end = column.saturating_add(measure(&span.content));
671 let clipped_end = end.min(usize::from(area.width));
672 if span.style.bg.is_some() && column < clipped_end {
673 regions.push(Rect::new(
674 area.x.saturating_add(column as u16),
675 y,
676 (clipped_end - column) as u16,
677 1,
678 ));
679 }
680 column = end;
681 }
682 }
683 regions
684 }
685
685 lines RUST