1use presentar_core::{
12 Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
13 FontWeight, Key, LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
14};
15use std::any::Any;
16use std::time::Duration;
17
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub enum TitleBarPosition {
21 #[default]
22 Top,
23 Bottom,
24}
25
26#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub enum TitleBarStyle {
29 #[default]
30 Standard,
31 Minimal,
32 Detailed,
33}
34
35#[derive(Debug, Clone)]
45pub struct TitleBar {
46 app_name: String,
48 version: Option<String>,
50 search_text: String,
52 search_placeholder: String,
54 search_active: bool,
56 keybinds: Vec<(String, String)>,
58 primary_color: Color,
60 secondary_color: Color,
62 position: TitleBarPosition,
64 style: TitleBarStyle,
66 status_text: Option<String>,
68 status_color: Option<Color>,
70 mode_indicator: Option<String>,
72 bounds: Rect,
74}
75
76impl Default for TitleBar {
77 fn default() -> Self {
78 Self {
79 app_name: String::from("TUI"),
80 version: None,
81 search_text: String::new(),
82 search_placeholder: String::from("Search..."),
83 search_active: false,
84 keybinds: Vec::new(),
85 primary_color: Color {
86 r: 0.4,
87 g: 0.7,
88 b: 1.0,
89 a: 1.0,
90 },
91 secondary_color: Color {
92 r: 0.5,
93 g: 0.5,
94 b: 0.6,
95 a: 1.0,
96 },
97 position: TitleBarPosition::Top,
98 style: TitleBarStyle::Standard,
99 status_text: None,
100 status_color: None,
101 mode_indicator: None,
102 bounds: Rect::default(),
103 }
104 }
105}
106
107impl TitleBar {
108 #[must_use]
110 pub fn new(app_name: impl Into<String>) -> Self {
111 Self {
112 app_name: app_name.into(),
113 ..Default::default()
114 }
115 }
116
117 #[must_use]
119 pub fn with_version(mut self, version: impl Into<String>) -> Self {
120 self.version = Some(version.into());
121 self
122 }
123
124 #[must_use]
126 pub fn with_search_placeholder(mut self, placeholder: impl Into<String>) -> Self {
127 self.search_placeholder = placeholder.into();
128 self
129 }
130
131 #[must_use]
133 pub fn with_search_text(mut self, text: impl Into<String>) -> Self {
134 self.search_text = text.into();
135 self
136 }
137
138 #[must_use]
140 pub fn with_search_active(mut self, active: bool) -> Self {
141 self.search_active = active;
142 self
143 }
144
145 #[must_use]
147 pub fn with_keybinds(mut self, binds: &[(&str, &str)]) -> Self {
148 self.keybinds = binds
149 .iter()
150 .map(|(k, d)| ((*k).to_string(), (*d).to_string()))
151 .collect();
152 self
153 }
154
155 #[must_use]
157 pub fn with_primary_color(mut self, color: Color) -> Self {
158 self.primary_color = color;
159 self
160 }
161
162 #[must_use]
164 pub fn with_secondary_color(mut self, color: Color) -> Self {
165 self.secondary_color = color;
166 self
167 }
168
169 #[must_use]
171 pub fn with_mode_indicator(mut self, indicator: impl Into<String>) -> Self {
172 self.mode_indicator = Some(indicator.into());
173 self
174 }
175
176 #[must_use]
178 pub fn with_position(mut self, position: TitleBarPosition) -> Self {
179 self.position = position;
180 self
181 }
182
183 #[must_use]
185 pub fn with_style(mut self, style: TitleBarStyle) -> Self {
186 self.style = style;
187 self
188 }
189
190 #[must_use]
192 pub fn with_status(mut self, text: impl Into<String>, color: Color) -> Self {
193 self.status_text = Some(text.into());
194 self.status_color = Some(color);
195 self
196 }
197
198 pub fn set_search_text(&mut self, text: impl Into<String>) {
200 self.search_text = text.into();
201 }
202
203 #[must_use]
205 pub fn search_text(&self) -> &str {
206 &self.search_text
207 }
208
209 pub fn toggle_search(&mut self) {
211 self.search_active = !self.search_active;
212 }
213
214 #[must_use]
216 pub fn is_search_active(&self) -> bool {
217 self.search_active
218 }
219}
220
221impl Brick for TitleBar {
222 fn brick_name(&self) -> &'static str {
223 "title_bar"
224 }
225
226 fn assertions(&self) -> &[BrickAssertion] {
227 static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(8)];
228 ASSERTIONS
229 }
230
231 fn budget(&self) -> BrickBudget {
232 BrickBudget::uniform(8)
233 }
234
235 fn verify(&self) -> BrickVerification {
236 BrickVerification {
237 passed: self.assertions().to_vec(),
238 failed: vec![],
239 verification_time: Duration::from_micros(5),
240 }
241 }
242
243 fn to_html(&self) -> String {
244 format!(
245 r#"<div class="title-bar"><span class="app-name">{}</span><input class="search" placeholder="{}" value="{}"/></div>"#,
246 self.app_name, self.search_placeholder, self.search_text
247 )
248 }
249
250 fn to_css(&self) -> String {
251 format!(
252 ".title-bar {{ app: \"{}\"; search: \"{}\"; }}",
253 self.app_name, self.search_text
254 )
255 }
256}
257
258impl Widget for TitleBar {
259 fn type_id(&self) -> TypeId {
260 TypeId::of::<Self>()
261 }
262
263 fn measure(&self, constraints: Constraints) -> Size {
264 constraints.constrain(Size::new(constraints.max_width, 1.0))
265 }
266
267 fn layout(&mut self, bounds: Rect) -> LayoutResult {
268 self.bounds = bounds;
269 LayoutResult {
270 size: Size::new(bounds.width, 1.0),
271 }
272 }
273
274 fn paint(&self, canvas: &mut dyn Canvas) {
275 if self.bounds.width < 10.0 || self.bounds.height < 1.0 {
276 return;
277 }
278
279 let y = self.bounds.y;
280 let width = self.bounds.width as usize;
281 let mut x = self.bounds.x;
282
283 let (show_version, show_search, show_keybinds) = match self.style {
285 TitleBarStyle::Minimal => (false, false, false),
286 TitleBarStyle::Standard => (true, true, true),
287 TitleBarStyle::Detailed => (true, true, true),
288 };
289
290 let name_style = TextStyle {
292 color: self.primary_color,
293 weight: FontWeight::Bold,
294 ..Default::default()
295 };
296
297 canvas.draw_text(&self.app_name, Point::new(x, y), &name_style);
298 x += self.app_name.len() as f32;
299
300 if show_version {
301 if let Some(ref ver) = self.version {
302 let ver_text = format!(" v{ver}");
303 canvas.draw_text(
304 &ver_text,
305 Point::new(x, y),
306 &TextStyle {
307 color: self.secondary_color,
308 ..Default::default()
309 },
310 );
311 x += ver_text.len() as f32;
312 }
313 }
314
315 if show_search {
317 let search_start = (width as f32 * 0.25).max(x + 2.0);
318 let search_width = (width as f32 * 0.3).min(40.0).max(15.0) as usize;
319
320 let search_border_color = if self.search_active {
322 self.primary_color
323 } else {
324 self.secondary_color
325 };
326
327 let search_display = if self.search_text.is_empty() {
328 if self.search_active {
329 "_".to_string()
330 } else {
331 self.search_placeholder.clone()
332 }
333 } else {
334 let visible_len = search_width.saturating_sub(4);
335 if self.search_text.len() > visible_len {
336 format!("{}...", &self.search_text[..visible_len.saturating_sub(3)])
337 } else {
338 self.search_text.clone()
339 }
340 };
341
342 let prefix = if self.search_active { "[/] " } else { " / " };
344 canvas.draw_text(
345 prefix,
346 Point::new(search_start, y),
347 &TextStyle {
348 color: search_border_color,
349 ..Default::default()
350 },
351 );
352
353 let text_color = if self.search_text.is_empty() && !self.search_active {
354 self.secondary_color
355 } else {
356 Color::WHITE
357 };
358
359 canvas.draw_text(
360 &search_display,
361 Point::new(search_start + 4.0, y),
362 &TextStyle {
363 color: text_color,
364 ..Default::default()
365 },
366 );
367 }
368
369 let right_section_start = width as f32 * 0.55;
371 let mut right_x = right_section_start;
372
373 if let Some(ref indicator) = self.mode_indicator {
375 canvas.draw_text(
376 indicator,
377 Point::new(right_x, y),
378 &TextStyle {
379 color: Color {
380 r: 0.9,
381 g: 0.7,
382 b: 0.2,
383 a: 1.0,
384 }, weight: FontWeight::Bold,
386 ..Default::default()
387 },
388 );
389 right_x += indicator.len() as f32 + 2.0;
390 }
391
392 if let (Some(ref status), Some(color)) = (&self.status_text, self.status_color) {
394 canvas.draw_text(
395 status,
396 Point::new(right_x, y),
397 &TextStyle {
398 color,
399 ..Default::default()
400 },
401 );
402 }
403
404 if show_keybinds && !self.keybinds.is_empty() {
406 let keybind_str: String = self
407 .keybinds
408 .iter()
409 .map(|(k, d)| format!("[{k}]{d}"))
410 .collect::<Vec<_>>()
411 .join(" ");
412
413 let keybind_x =
414 (width as f32 - keybind_str.len() as f32 - 1.0).max(right_section_start);
415
416 canvas.draw_text(
417 &keybind_str,
418 Point::new(keybind_x, y),
419 &TextStyle {
420 color: self.secondary_color,
421 ..Default::default()
422 },
423 );
424 }
425 }
426
427 fn event(&mut self, event: &Event) -> Option<Box<dyn Any + Send>> {
428 match event {
429 Event::KeyDown {
430 key: Key::Slash, ..
431 } if !self.search_active => {
432 self.search_active = true;
433 None
434 }
435 Event::KeyDown {
436 key: Key::Escape, ..
437 } if self.search_active => {
438 self.search_active = false;
439 self.search_text.clear();
440 None
441 }
442 Event::KeyDown {
443 key: Key::Enter, ..
444 } if self.search_active => {
445 self.search_active = false;
446 None
447 }
448 Event::KeyDown {
449 key: Key::Backspace,
450 ..
451 } if self.search_active && !self.search_text.is_empty() => {
452 self.search_text.pop();
453 None
454 }
455 Event::TextInput { text } if self.search_active => {
456 self.search_text.push_str(text);
457 None
458 }
459 _ => None,
460 }
461 }
462
463 fn children(&self) -> &[Box<dyn Widget>] {
464 &[]
465 }
466
467 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
468 &mut []
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::direct::{CellBuffer, DirectTerminalCanvas};
476
477 #[test]
482 fn test_title_bar_creation() {
483 let bar = TitleBar::new("ptop")
484 .with_version("1.0.0")
485 .with_search_placeholder("Filter...");
486
487 assert_eq!(bar.app_name, "ptop");
488 assert_eq!(bar.version, Some("1.0.0".to_string()));
489 assert_eq!(bar.search_placeholder, "Filter...");
490 }
491
492 #[test]
493 fn test_title_bar_default() {
494 let bar = TitleBar::default();
495 assert_eq!(bar.app_name, "TUI");
496 assert!(bar.version.is_none());
497 assert!(!bar.search_active);
498 assert!(bar.keybinds.is_empty());
499 assert_eq!(bar.position, TitleBarPosition::Top);
500 assert_eq!(bar.style, TitleBarStyle::Standard);
501 }
502
503 #[test]
504 fn test_title_bar_with_search_text() {
505 let bar = TitleBar::new("test").with_search_text("filter");
506 assert_eq!(bar.search_text, "filter");
507 }
508
509 #[test]
510 fn test_title_bar_with_search_active() {
511 let bar = TitleBar::new("test").with_search_active(true);
512 assert!(bar.search_active);
513 }
514
515 #[test]
516 fn test_title_bar_with_primary_color() {
517 let color = Color::new(1.0, 0.0, 0.0, 1.0);
518 let bar = TitleBar::new("test").with_primary_color(color);
519 assert_eq!(bar.primary_color, color);
520 }
521
522 #[test]
523 fn test_title_bar_with_secondary_color() {
524 let color = Color::new(0.0, 1.0, 0.0, 1.0);
525 let bar = TitleBar::new("test").with_secondary_color(color);
526 assert_eq!(bar.secondary_color, color);
527 }
528
529 #[test]
530 fn test_title_bar_with_mode_indicator() {
531 let bar = TitleBar::new("test").with_mode_indicator("[FULLSCREEN]");
532 assert_eq!(bar.mode_indicator, Some("[FULLSCREEN]".to_string()));
533 }
534
535 #[test]
536 fn test_title_bar_with_position() {
537 let bar = TitleBar::new("test").with_position(TitleBarPosition::Bottom);
538 assert_eq!(bar.position, TitleBarPosition::Bottom);
539 }
540
541 #[test]
546 fn test_title_bar_search() {
547 let mut bar = TitleBar::new("test");
548 assert!(!bar.is_search_active());
549
550 bar.toggle_search();
551 assert!(bar.is_search_active());
552
553 bar.set_search_text("hello");
554 assert_eq!(bar.search_text(), "hello");
555 }
556
557 #[test]
558 fn test_title_bar_toggle_search_twice() {
559 let mut bar = TitleBar::new("test");
560 bar.toggle_search();
561 assert!(bar.is_search_active());
562 bar.toggle_search();
563 assert!(!bar.is_search_active());
564 }
565
566 #[test]
571 fn test_title_bar_keybinds() {
572 let bar = TitleBar::new("test").with_keybinds(&[("q", "Quit"), ("?", "Help")]);
573
574 assert_eq!(bar.keybinds.len(), 2);
575 assert_eq!(bar.keybinds[0], ("q".to_string(), "Quit".to_string()));
576 }
577
578 #[test]
579 fn test_title_bar_keybinds_empty() {
580 let bar = TitleBar::new("test").with_keybinds(&[]);
581 assert!(bar.keybinds.is_empty());
582 }
583
584 #[test]
589 fn test_title_bar_styles() {
590 let minimal = TitleBar::new("test").with_style(TitleBarStyle::Minimal);
591 let standard = TitleBar::new("test").with_style(TitleBarStyle::Standard);
592 let detailed = TitleBar::new("test").with_style(TitleBarStyle::Detailed);
593
594 assert_eq!(minimal.style, TitleBarStyle::Minimal);
595 assert_eq!(standard.style, TitleBarStyle::Standard);
596 assert_eq!(detailed.style, TitleBarStyle::Detailed);
597 }
598
599 #[test]
600 fn test_title_bar_position_default() {
601 assert_eq!(TitleBarPosition::default(), TitleBarPosition::Top);
602 }
603
604 #[test]
605 fn test_title_bar_style_default() {
606 assert_eq!(TitleBarStyle::default(), TitleBarStyle::Standard);
607 }
608
609 #[test]
614 fn test_title_bar_status() {
615 let bar = TitleBar::new("test").with_status(
616 "Connected",
617 Color {
618 r: 0.0,
619 g: 1.0,
620 b: 0.0,
621 a: 1.0,
622 },
623 );
624
625 assert_eq!(bar.status_text, Some("Connected".to_string()));
626 assert!(bar.status_color.is_some());
627 }
628
629 #[test]
634 fn test_title_bar_brick_name() {
635 let bar = TitleBar::new("test");
636 assert_eq!(bar.brick_name(), "title_bar");
637 }
638
639 #[test]
640 fn test_title_bar_assertions() {
641 let bar = TitleBar::new("test");
642 let assertions = bar.assertions();
643 assert!(!assertions.is_empty());
644 }
645
646 #[test]
647 fn test_title_bar_budget() {
648 let bar = TitleBar::new("test");
649 let budget = bar.budget();
650 assert!(budget.total_ms > 0);
651 }
652
653 #[test]
654 fn test_title_bar_verify() {
655 let bar = TitleBar::new("test");
656 let verification = bar.verify();
657 assert!(!verification.passed.is_empty());
658 assert!(verification.failed.is_empty());
659 }
660
661 #[test]
662 fn test_title_bar_to_html() {
663 let bar = TitleBar::new("ptop").with_search_text("filter");
664 let html = bar.to_html();
665 assert!(html.contains("ptop"));
666 assert!(html.contains("filter"));
667 assert!(html.contains("title-bar"));
668 }
669
670 #[test]
671 fn test_title_bar_to_css() {
672 let bar = TitleBar::new("ptop").with_search_text("filter");
673 let css = bar.to_css();
674 assert!(css.contains("ptop"));
675 assert!(css.contains("filter"));
676 }
677
678 #[test]
683 fn test_title_bar_type_id() {
684 let bar = TitleBar::new("test");
685 let id = Widget::type_id(&bar);
686 assert_eq!(id, TypeId::of::<TitleBar>());
687 }
688
689 #[test]
690 fn test_title_bar_measure() {
691 let bar = TitleBar::new("test");
692 let constraints = Constraints::loose(Size::new(100.0, 50.0));
693 let size = bar.measure(constraints);
694 assert_eq!(size.width, 100.0);
695 assert_eq!(size.height, 1.0);
696 }
697
698 #[test]
699 fn test_title_bar_layout() {
700 let mut bar = TitleBar::new("test");
701 let bounds = Rect::new(0.0, 0.0, 100.0, 1.0);
702 let result = bar.layout(bounds);
703 assert_eq!(result.size.width, 100.0);
704 assert_eq!(result.size.height, 1.0);
705 assert_eq!(bar.bounds, bounds);
706 }
707
708 #[test]
709 fn test_title_bar_paint_standard() {
710 let mut buffer = CellBuffer::new(100, 5);
711 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
712
713 let mut bar = TitleBar::new("ptop")
714 .with_version("1.0.0")
715 .with_keybinds(&[("q", "Quit")]);
716 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
717 bar.paint(&mut canvas);
718 }
719
720 #[test]
721 fn test_title_bar_paint_minimal() {
722 let mut buffer = CellBuffer::new(100, 5);
723 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
724
725 let mut bar = TitleBar::new("ptop").with_style(TitleBarStyle::Minimal);
726 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
727 bar.paint(&mut canvas);
728 }
729
730 #[test]
731 fn test_title_bar_paint_with_search_active() {
732 let mut buffer = CellBuffer::new(100, 5);
733 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
734
735 let mut bar = TitleBar::new("ptop").with_search_active(true);
736 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
737 bar.paint(&mut canvas);
738 }
739
740 #[test]
741 fn test_title_bar_paint_with_search_text() {
742 let mut buffer = CellBuffer::new(100, 5);
743 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
744
745 let mut bar = TitleBar::new("ptop").with_search_text("filter text");
746 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
747 bar.paint(&mut canvas);
748 }
749
750 #[test]
751 fn test_title_bar_paint_with_long_search_text() {
752 let mut buffer = CellBuffer::new(100, 5);
753 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
754
755 let mut bar = TitleBar::new("ptop")
756 .with_search_text("this is a very long search text that should be truncated");
757 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
758 bar.paint(&mut canvas);
759 }
760
761 #[test]
762 fn test_title_bar_paint_with_mode_indicator() {
763 let mut buffer = CellBuffer::new(100, 5);
764 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
765
766 let mut bar = TitleBar::new("ptop").with_mode_indicator("[FULLSCREEN]");
767 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
768 bar.paint(&mut canvas);
769 }
770
771 #[test]
772 fn test_title_bar_paint_with_status() {
773 let mut buffer = CellBuffer::new(100, 5);
774 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
775
776 let mut bar =
777 TitleBar::new("ptop").with_status("Connected", Color::new(0.0, 1.0, 0.0, 1.0));
778 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
779 bar.paint(&mut canvas);
780 }
781
782 #[test]
783 fn test_title_bar_paint_too_small() {
784 let mut buffer = CellBuffer::new(10, 5);
785 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
786
787 let mut bar = TitleBar::new("ptop");
788 bar.layout(Rect::new(0.0, 0.0, 5.0, 1.0)); bar.paint(&mut canvas); }
791
792 #[test]
797 fn test_title_bar_event_slash_activates_search() {
798 let mut bar = TitleBar::new("test");
799 assert!(!bar.search_active);
800
801 let event = Event::key_down(Key::Slash);
802 bar.event(&event);
803 assert!(bar.search_active);
804 }
805
806 #[test]
807 fn test_title_bar_event_slash_ignored_when_active() {
808 let mut bar = TitleBar::new("test").with_search_active(true);
809
810 let event = Event::key_down(Key::Slash);
811 bar.event(&event);
812 assert!(bar.search_active); }
814
815 #[test]
816 fn test_title_bar_event_escape_deactivates_search() {
817 let mut bar = TitleBar::new("test")
818 .with_search_active(true)
819 .with_search_text("filter");
820
821 let event = Event::key_down(Key::Escape);
822 bar.event(&event);
823 assert!(!bar.search_active);
824 assert!(bar.search_text.is_empty()); }
826
827 #[test]
828 fn test_title_bar_event_escape_ignored_when_inactive() {
829 let mut bar = TitleBar::new("test");
830
831 let event = Event::key_down(Key::Escape);
832 bar.event(&event);
833 assert!(!bar.search_active);
834 }
835
836 #[test]
837 fn test_title_bar_event_enter_deactivates_search() {
838 let mut bar = TitleBar::new("test")
839 .with_search_active(true)
840 .with_search_text("filter");
841
842 let event = Event::key_down(Key::Enter);
843 bar.event(&event);
844 assert!(!bar.search_active);
845 assert_eq!(bar.search_text, "filter"); }
847
848 #[test]
849 fn test_title_bar_event_backspace_deletes_char() {
850 let mut bar = TitleBar::new("test")
851 .with_search_active(true)
852 .with_search_text("filter");
853
854 let event = Event::key_down(Key::Backspace);
855 bar.event(&event);
856 assert_eq!(bar.search_text, "filte");
857 }
858
859 #[test]
860 fn test_title_bar_event_backspace_on_empty() {
861 let mut bar = TitleBar::new("test").with_search_active(true);
862
863 let event = Event::key_down(Key::Backspace);
864 bar.event(&event);
865 assert!(bar.search_text.is_empty());
866 }
867
868 #[test]
869 fn test_title_bar_event_text_input() {
870 let mut bar = TitleBar::new("test").with_search_active(true);
871
872 let event = Event::TextInput {
873 text: "hello".to_string(),
874 };
875 bar.event(&event);
876 assert_eq!(bar.search_text, "hello");
877 }
878
879 #[test]
880 fn test_title_bar_event_text_input_ignored_when_inactive() {
881 let mut bar = TitleBar::new("test");
882
883 let event = Event::TextInput {
884 text: "hello".to_string(),
885 };
886 bar.event(&event);
887 assert!(bar.search_text.is_empty());
888 }
889
890 #[test]
891 fn test_title_bar_event_unhandled() {
892 let mut bar = TitleBar::new("test");
893
894 let event = Event::key_down(Key::Tab);
895 let result = bar.event(&event);
896 assert!(result.is_none());
897 }
898
899 #[test]
900 fn test_title_bar_children() {
901 let bar = TitleBar::new("test");
902 assert!(bar.children().is_empty());
903 }
904
905 #[test]
906 fn test_title_bar_children_mut() {
907 let mut bar = TitleBar::new("test");
908 assert!(bar.children_mut().is_empty());
909 }
910}