presentar_terminal/widgets/
flex_cell.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub enum Overflow {
21 #[default]
23 Clip,
24 Ellipsis,
26 EllipsisMiddle,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum Alignment {
33 #[default]
34 Left,
35 Center,
36 Right,
37}
38
39#[derive(Debug, Clone)]
44pub struct FlexCell {
45 text: String,
47 style: TextStyle,
49 overflow: Overflow,
51 alignment: Alignment,
53 min_width: Option<usize>,
55 bounds: Rect,
57}
58
59impl Default for FlexCell {
60 fn default() -> Self {
61 Self::new("")
62 }
63}
64
65impl FlexCell {
66 #[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, alignment: Alignment::Left,
74 min_width: None,
75 bounds: Rect::default(),
76 }
77 }
78
79 #[must_use]
81 pub fn with_color(mut self, color: Color) -> Self {
82 self.style.color = color;
83 self
84 }
85
86 #[must_use]
88 pub fn with_style(mut self, style: TextStyle) -> Self {
89 self.style = style;
90 self
91 }
92
93 #[must_use]
95 pub fn with_overflow(mut self, overflow: Overflow) -> Self {
96 self.overflow = overflow;
97 self
98 }
99
100 #[must_use]
102 pub fn with_alignment(mut self, alignment: Alignment) -> Self {
103 self.alignment = alignment;
104 self
105 }
106
107 #[must_use]
109 pub fn with_min_width(mut self, width: usize) -> Self {
110 self.min_width = Some(width);
111 self
112 }
113
114 #[must_use]
116 pub fn text(&self) -> &str {
117 &self.text
118 }
119
120 pub fn set_text(&mut self, text: impl Into<String>) {
122 self.text = text.into();
123 }
124
125 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 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 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 let display_text = self.truncate_to_fit(max_chars);
208 let text_width = display_text.chars().count();
209
210 let x_offset = self.alignment_offset(text_width, max_chars);
212
213 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), ];
243 ASSERTIONS
244 }
245
246 fn budget(&self) -> BrickBudget {
247 BrickBudget::uniform(1)
248 }
249
250 fn verify(&self) -> BrickVerification {
251 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 assert_eq!(cell.alignment_offset(15, 10), 0.0);
480 }
481
482 #[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); 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 #[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); }
569
570 #[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 #[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}