Skip to main content

presentar_terminal/widgets/
flex_cell.rs

1//! `FlexCell` atomic widget.
2//!
3//! A bounded text container that enforces strict no-bleed rendering.
4//! Reference: SPEC-024 Appendix I (Atomic Widget Mandate).
5//!
6//! # Falsification Criteria
7//! - F-ATOM-FLEX-001: Text length > bounds width MUST NOT render overflow.
8//! - F-ATOM-FLEX-002: Ellipsis MUST appear when truncation occurs.
9
10use presentar_core::{
11    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
12    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
13};
14use std::any::Any;
15use std::borrow::Cow;
16use std::time::Duration;
17
18/// Text overflow behavior.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum Overflow {
21    /// Clip text at bounds (no indicator).
22    #[default]
23    Clip,
24    /// Show ellipsis (…) when truncated.
25    Ellipsis,
26    /// Truncate from middle, preserving start/end (for paths).
27    EllipsisMiddle,
28}
29
30/// Text alignment within the cell.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum Alignment {
33    #[default]
34    Left,
35    Center,
36    Right,
37}
38
39/// `FlexCell` - bounded text atom.
40///
41/// Guarantees that text NEVER bleeds outside its allocated bounds.
42/// This is the fundamental building block for all text rendering in the TUI.
43#[derive(Debug, Clone)]
44pub struct FlexCell {
45    /// Text content.
46    text: String,
47    /// Text style (color, etc.).
48    style: TextStyle,
49    /// Overflow behavior.
50    overflow: Overflow,
51    /// Horizontal alignment.
52    alignment: Alignment,
53    /// Minimum width (in characters).
54    min_width: Option<usize>,
55    /// Cached bounds.
56    bounds: Rect,
57}
58
59impl Default for FlexCell {
60    fn default() -> Self {
61        Self::new("")
62    }
63}
64
65impl FlexCell {
66    /// Create a new `FlexCell` with text.
67    #[must_use]
68    pub fn new(text: impl Into<String>) -> Self {
69        Self {
70            text: text.into(),
71            style: TextStyle::default(),
72            overflow: Overflow::Ellipsis, // Safe default
73            alignment: Alignment::Left,
74            min_width: None,
75            bounds: Rect::default(),
76        }
77    }
78
79    /// Set text color.
80    #[must_use]
81    pub fn with_color(mut self, color: Color) -> Self {
82        self.style.color = color;
83        self
84    }
85
86    /// Set full text style.
87    #[must_use]
88    pub fn with_style(mut self, style: TextStyle) -> Self {
89        self.style = style;
90        self
91    }
92
93    /// Set overflow behavior.
94    #[must_use]
95    pub fn with_overflow(mut self, overflow: Overflow) -> Self {
96        self.overflow = overflow;
97        self
98    }
99
100    /// Set text alignment.
101    #[must_use]
102    pub fn with_alignment(mut self, alignment: Alignment) -> Self {
103        self.alignment = alignment;
104        self
105    }
106
107    /// Set minimum width.
108    #[must_use]
109    pub fn with_min_width(mut self, width: usize) -> Self {
110        self.min_width = Some(width);
111        self
112    }
113
114    /// Get the text content.
115    #[must_use]
116    pub fn text(&self) -> &str {
117        &self.text
118    }
119
120    /// Set text content.
121    pub fn set_text(&mut self, text: impl Into<String>) {
122        self.text = text.into();
123    }
124
125    /// Truncate text to fit width based on overflow mode.
126    fn truncate_to_fit(&self, max_chars: usize) -> Cow<'_, str> {
127        let char_count = self.text.chars().count();
128        if char_count <= max_chars {
129            Cow::Borrowed(&self.text)
130        } else {
131            match self.overflow {
132                Overflow::Clip => Cow::Owned(self.text.chars().take(max_chars).collect()),
133                Overflow::Ellipsis => {
134                    if max_chars == 0 {
135                        Cow::Borrowed("")
136                    } else if max_chars == 1 {
137                        Cow::Borrowed("…")
138                    } else {
139                        let truncated: String = self.text.chars().take(max_chars - 1).collect();
140                        Cow::Owned(format!("{truncated}…"))
141                    }
142                }
143                Overflow::EllipsisMiddle => {
144                    if max_chars <= 3 {
145                        // Fall back to end ellipsis for very short
146                        if max_chars == 0 {
147                            Cow::Borrowed("")
148                        } else if max_chars == 1 {
149                            Cow::Borrowed("…")
150                        } else {
151                            let truncated: String = self.text.chars().take(max_chars - 1).collect();
152                            Cow::Owned(format!("{truncated}…"))
153                        }
154                    } else {
155                        let start_len = (max_chars - 1) / 3;
156                        let end_len = max_chars - 1 - start_len;
157                        let start: String = self.text.chars().take(start_len).collect();
158                        let end: String = self.text.chars().skip(char_count - end_len).collect();
159                        Cow::Owned(format!("{start}…{end}"))
160                    }
161                }
162            }
163        }
164    }
165
166    /// Calculate x offset for alignment.
167    fn alignment_offset(&self, text_width: usize, cell_width: usize) -> f32 {
168        if text_width >= cell_width {
169            return 0.0;
170        }
171        let space = cell_width - text_width;
172        match self.alignment {
173            Alignment::Left => 0.0,
174            Alignment::Center => (space / 2) as f32,
175            Alignment::Right => space as f32,
176        }
177    }
178}
179
180impl Widget for FlexCell {
181    fn type_id(&self) -> TypeId {
182        TypeId::of::<Self>()
183    }
184
185    fn measure(&self, constraints: Constraints) -> Size {
186        let text_width = self.text.chars().count() as f32;
187        let min_w = self.min_width.map_or(0.0, |w| w as f32);
188        let width = text_width.max(min_w).min(constraints.max_width);
189        constraints.constrain(Size::new(width, 1.0))
190    }
191
192    fn layout(&mut self, bounds: Rect) -> LayoutResult {
193        self.bounds = bounds;
194        LayoutResult {
195            size: Size::new(bounds.width, bounds.height),
196        }
197    }
198
199    fn paint(&self, canvas: &mut dyn Canvas) {
200        if self.bounds.width < 1.0 || self.bounds.height < 1.0 {
201            return;
202        }
203
204        let max_chars = self.bounds.width as usize;
205
206        // F-ATOM-FLEX-001: Enforce bounds - truncate to fit
207        let display_text = self.truncate_to_fit(max_chars);
208        let text_width = display_text.chars().count();
209
210        // Calculate alignment offset
211        let x_offset = self.alignment_offset(text_width, max_chars);
212
213        // Render text
214        canvas.draw_text(
215            &display_text,
216            Point::new(self.bounds.x + x_offset, self.bounds.y),
217            &self.style,
218        );
219    }
220
221    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
222        None
223    }
224
225    fn children(&self) -> &[Box<dyn Widget>] {
226        &[]
227    }
228
229    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
230        &mut []
231    }
232}
233
234impl Brick for FlexCell {
235    fn brick_name(&self) -> &'static str {
236        "flex_cell"
237    }
238
239    fn assertions(&self) -> &[BrickAssertion] {
240        static ASSERTIONS: &[BrickAssertion] = &[
241            BrickAssertion::max_latency_ms(1), // Text rendering is fast
242        ];
243        ASSERTIONS
244    }
245
246    fn budget(&self) -> BrickBudget {
247        BrickBudget::uniform(1)
248    }
249
250    fn verify(&self) -> BrickVerification {
251        // F-ATOM-FLEX-001: No bleed verification
252        // The truncate_to_fit function guarantees output <= max_chars
253        let max_chars = self.bounds.width as usize;
254        let display_text = self.truncate_to_fit(max_chars);
255        let no_bleed = display_text.chars().count() <= max_chars;
256
257        if no_bleed {
258            BrickVerification {
259                passed: self.assertions().to_vec(),
260                failed: vec![],
261                verification_time: Duration::from_micros(1),
262            }
263        } else {
264            BrickVerification {
265                passed: vec![],
266                failed: self
267                    .assertions()
268                    .iter()
269                    .map(|a| (a.clone(), "Text exceeds bounds".to_string()))
270                    .collect(),
271                verification_time: Duration::from_micros(1),
272            }
273        }
274    }
275
276    fn to_html(&self) -> String {
277        format!("<span class=\"flex-cell\">{}</span>", self.text)
278    }
279
280    fn to_css(&self) -> String {
281        String::new()
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::direct::{CellBuffer, DirectTerminalCanvas};
289
290    // =========================================================================
291    // ENUM DEFAULT TESTS
292    // =========================================================================
293
294    #[test]
295    fn test_overflow_default() {
296        assert_eq!(Overflow::default(), Overflow::Clip);
297    }
298
299    #[test]
300    fn test_alignment_default() {
301        assert_eq!(Alignment::default(), Alignment::Left);
302    }
303
304    // =========================================================================
305    // FLEX CELL CREATION TESTS
306    // =========================================================================
307
308    #[test]
309    fn test_flex_cell_new() {
310        let cell = FlexCell::new("Hello");
311        assert_eq!(cell.text(), "Hello");
312    }
313
314    #[test]
315    fn test_flex_cell_default() {
316        let cell = FlexCell::default();
317        assert_eq!(cell.text(), "");
318    }
319
320    #[test]
321    fn test_flex_cell_with_color() {
322        let cell = FlexCell::new("Text").with_color(Color::new(1.0, 0.0, 0.0, 1.0));
323        assert_eq!(cell.style.color.r, 1.0);
324    }
325
326    #[test]
327    fn test_flex_cell_with_style() {
328        let style = TextStyle {
329            color: Color::new(0.0, 1.0, 0.0, 1.0),
330            ..Default::default()
331        };
332        let cell = FlexCell::new("Text").with_style(style);
333        assert_eq!(cell.style.color.g, 1.0);
334    }
335
336    #[test]
337    fn test_flex_cell_with_overflow() {
338        let cell = FlexCell::new("Text").with_overflow(Overflow::Clip);
339        assert_eq!(cell.overflow, Overflow::Clip);
340    }
341
342    #[test]
343    fn test_flex_cell_with_alignment() {
344        let cell = FlexCell::new("Text").with_alignment(Alignment::Center);
345        assert_eq!(cell.alignment, Alignment::Center);
346    }
347
348    #[test]
349    fn test_flex_cell_with_min_width() {
350        let cell = FlexCell::new("Hi").with_min_width(10);
351        assert_eq!(cell.min_width, Some(10));
352    }
353
354    #[test]
355    fn test_flex_cell_text_getter() {
356        let cell = FlexCell::new("Content");
357        assert_eq!(cell.text(), "Content");
358    }
359
360    #[test]
361    fn test_flex_cell_set_text() {
362        let mut cell = FlexCell::new("Old");
363        cell.set_text("New");
364        assert_eq!(cell.text(), "New");
365    }
366
367    // =========================================================================
368    // TRUNCATION TESTS
369    // =========================================================================
370
371    // F-ATOM-FLEX-001: Text never exceeds bounds
372    #[test]
373    fn test_no_bleed_long_text() {
374        let mut cell = FlexCell::new("This is a very long text that should be truncated");
375        cell.layout(Rect::new(0.0, 0.0, 10.0, 1.0));
376
377        let truncated = cell.truncate_to_fit(10);
378        assert_eq!(truncated.chars().count(), 10);
379        assert!(truncated.ends_with('…'));
380    }
381
382    // F-ATOM-FLEX-001: Short text unchanged
383    #[test]
384    fn test_no_bleed_short_text() {
385        let mut cell = FlexCell::new("Short");
386        cell.layout(Rect::new(0.0, 0.0, 10.0, 1.0));
387
388        let truncated = cell.truncate_to_fit(10);
389        assert_eq!(truncated, "Short");
390    }
391
392    // F-ATOM-FLEX-002: Ellipsis appears on truncation
393    #[test]
394    fn test_ellipsis_on_truncation() {
395        let cell = FlexCell::new("LongText").with_overflow(Overflow::Ellipsis);
396        let truncated = cell.truncate_to_fit(5);
397        assert_eq!(truncated, "Long…");
398    }
399
400    #[test]
401    fn test_ellipsis_zero_max() {
402        let cell = FlexCell::new("LongText").with_overflow(Overflow::Ellipsis);
403        let truncated = cell.truncate_to_fit(0);
404        assert_eq!(truncated, "");
405    }
406
407    #[test]
408    fn test_ellipsis_one_max() {
409        let cell = FlexCell::new("LongText").with_overflow(Overflow::Ellipsis);
410        let truncated = cell.truncate_to_fit(1);
411        assert_eq!(truncated, "…");
412    }
413
414    #[test]
415    fn test_clip_truncation() {
416        let cell = FlexCell::new("LongText").with_overflow(Overflow::Clip);
417        let truncated = cell.truncate_to_fit(4);
418        assert_eq!(truncated, "Long");
419    }
420
421    // Middle ellipsis for paths
422    #[test]
423    fn test_middle_ellipsis() {
424        let cell =
425            FlexCell::new("/home/user/projects/myapp").with_overflow(Overflow::EllipsisMiddle);
426        let truncated = cell.truncate_to_fit(15);
427        assert!(truncated.contains('…'));
428        assert_eq!(truncated.chars().count(), 15);
429    }
430
431    #[test]
432    fn test_middle_ellipsis_short_max() {
433        let cell = FlexCell::new("LongText").with_overflow(Overflow::EllipsisMiddle);
434        // For max_chars <= 3, falls back to end ellipsis
435        let truncated = cell.truncate_to_fit(3);
436        assert_eq!(truncated.chars().count(), 3);
437    }
438
439    #[test]
440    fn test_middle_ellipsis_zero_max() {
441        let cell = FlexCell::new("LongText").with_overflow(Overflow::EllipsisMiddle);
442        let truncated = cell.truncate_to_fit(0);
443        assert_eq!(truncated, "");
444    }
445
446    #[test]
447    fn test_middle_ellipsis_one_max() {
448        let cell = FlexCell::new("LongText").with_overflow(Overflow::EllipsisMiddle);
449        let truncated = cell.truncate_to_fit(1);
450        assert_eq!(truncated, "…");
451    }
452
453    // =========================================================================
454    // ALIGNMENT TESTS
455    // =========================================================================
456
457    #[test]
458    fn test_alignment_left() {
459        let cell = FlexCell::new("Hi").with_alignment(Alignment::Left);
460        assert_eq!(cell.alignment_offset(2, 10), 0.0);
461    }
462
463    #[test]
464    fn test_alignment_right() {
465        let cell = FlexCell::new("Hi").with_alignment(Alignment::Right);
466        assert_eq!(cell.alignment_offset(2, 10), 8.0);
467    }
468
469    #[test]
470    fn test_alignment_center() {
471        let cell = FlexCell::new("Hi").with_alignment(Alignment::Center);
472        assert_eq!(cell.alignment_offset(2, 10), 4.0);
473    }
474
475    #[test]
476    fn test_alignment_text_wider_than_cell() {
477        let cell = FlexCell::new("Very long text").with_alignment(Alignment::Right);
478        // Text wider than cell should return 0 offset
479        assert_eq!(cell.alignment_offset(15, 10), 0.0);
480    }
481
482    // =========================================================================
483    // WIDGET TRAIT TESTS
484    // =========================================================================
485
486    #[test]
487    fn test_flex_cell_type_id() {
488        let cell = FlexCell::new("Test");
489        let id = Widget::type_id(&cell);
490        assert_eq!(id, TypeId::of::<FlexCell>());
491    }
492
493    #[test]
494    fn test_flex_cell_measure() {
495        let cell = FlexCell::new("Hello");
496        let constraints = Constraints::loose(Size::new(100.0, 50.0));
497        let size = cell.measure(constraints);
498        assert_eq!(size.width, 5.0); // "Hello" is 5 chars
499        assert_eq!(size.height, 1.0);
500    }
501
502    #[test]
503    fn test_flex_cell_measure_with_min_width() {
504        let cell = FlexCell::new("Hi").with_min_width(10);
505        let constraints = Constraints::loose(Size::new(100.0, 50.0));
506        let size = cell.measure(constraints);
507        assert_eq!(size.width, 10.0);
508    }
509
510    #[test]
511    fn test_flex_cell_layout() {
512        let mut cell = FlexCell::new("Test");
513        let bounds = Rect::new(0.0, 0.0, 20.0, 1.0);
514        let result = cell.layout(bounds);
515        assert_eq!(result.size.width, 20.0);
516        assert_eq!(cell.bounds, bounds);
517    }
518
519    #[test]
520    fn test_flex_cell_paint() {
521        let mut buffer = CellBuffer::new(30, 5);
522        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
523
524        let mut cell = FlexCell::new("Hello");
525        cell.layout(Rect::new(0.0, 0.0, 20.0, 1.0));
526        cell.paint(&mut canvas);
527    }
528
529    #[test]
530    fn test_flex_cell_paint_with_alignment() {
531        let mut buffer = CellBuffer::new(30, 5);
532        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
533
534        let mut cell = FlexCell::new("Hi").with_alignment(Alignment::Right);
535        cell.layout(Rect::new(0.0, 0.0, 20.0, 1.0));
536        cell.paint(&mut canvas);
537    }
538
539    #[test]
540    fn test_flex_cell_event() {
541        let mut cell = FlexCell::new("Test");
542        let event = Event::key_down(presentar_core::Key::Enter);
543        let result = cell.event(&event);
544        assert!(result.is_none());
545    }
546
547    #[test]
548    fn test_flex_cell_children() {
549        let cell = FlexCell::new("Test");
550        assert!(cell.children().is_empty());
551    }
552
553    #[test]
554    fn test_flex_cell_children_mut() {
555        let mut cell = FlexCell::new("Test");
556        assert!(cell.children_mut().is_empty());
557    }
558
559    // Render doesn't panic with zero width
560    #[test]
561    fn test_zero_width_no_panic() {
562        let mut cell = FlexCell::new("Test");
563        cell.layout(Rect::new(0.0, 0.0, 0.0, 1.0));
564
565        let mut buffer = CellBuffer::new(10, 1);
566        let mut canvas = DirectTerminalCanvas::new(&mut buffer);
567        cell.paint(&mut canvas); // Should not panic
568    }
569
570    // Edge case: exactly fitting text
571    #[test]
572    fn test_exact_fit() {
573        let cell = FlexCell::new("12345");
574        let truncated = cell.truncate_to_fit(5);
575        assert_eq!(truncated, "12345");
576    }
577
578    // =========================================================================
579    // BRICK TRAIT TESTS
580    // =========================================================================
581
582    #[test]
583    fn test_flex_cell_brick_name() {
584        let cell = FlexCell::new("Test");
585        assert_eq!(cell.brick_name(), "flex_cell");
586    }
587
588    #[test]
589    fn test_flex_cell_assertions() {
590        let cell = FlexCell::new("Test");
591        let assertions = cell.assertions();
592        assert!(!assertions.is_empty());
593    }
594
595    #[test]
596    fn test_flex_cell_budget() {
597        let cell = FlexCell::new("Test");
598        let budget = cell.budget();
599        assert!(budget.total_ms > 0);
600    }
601
602    #[test]
603    fn test_brick_verification_passes() {
604        let mut cell = FlexCell::new("Test");
605        cell.layout(Rect::new(0.0, 0.0, 10.0, 1.0));
606        let v = cell.verify();
607        assert!(v.failed.is_empty());
608    }
609
610    #[test]
611    fn test_flex_cell_to_html() {
612        let cell = FlexCell::new("Content");
613        let html = cell.to_html();
614        assert!(html.contains("Content"));
615        assert!(html.contains("flex-cell"));
616    }
617
618    #[test]
619    fn test_flex_cell_to_css() {
620        let cell = FlexCell::new("Test");
621        let css = cell.to_css();
622        assert!(css.is_empty());
623    }
624}