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 { key: Key::Slash } if !self.search_active => {
430 self.search_active = true;
431 None
432 }
433 Event::KeyDown { key: Key::Escape } if self.search_active => {
434 self.search_active = false;
435 self.search_text.clear();
436 None
437 }
438 Event::KeyDown { key: Key::Enter } if self.search_active => {
439 self.search_active = false;
440 None
441 }
442 Event::KeyDown {
443 key: Key::Backspace,
444 } if self.search_active && !self.search_text.is_empty() => {
445 self.search_text.pop();
446 None
447 }
448 Event::TextInput { text } if self.search_active => {
449 self.search_text.push_str(text);
450 None
451 }
452 _ => None,
453 }
454 }
455
456 fn children(&self) -> &[Box<dyn Widget>] {
457 &[]
458 }
459
460 fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
461 &mut []
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use crate::direct::{CellBuffer, DirectTerminalCanvas};
469
470 #[test]
475 fn test_title_bar_creation() {
476 let bar = TitleBar::new("ptop")
477 .with_version("1.0.0")
478 .with_search_placeholder("Filter...");
479
480 assert_eq!(bar.app_name, "ptop");
481 assert_eq!(bar.version, Some("1.0.0".to_string()));
482 assert_eq!(bar.search_placeholder, "Filter...");
483 }
484
485 #[test]
486 fn test_title_bar_default() {
487 let bar = TitleBar::default();
488 assert_eq!(bar.app_name, "TUI");
489 assert!(bar.version.is_none());
490 assert!(!bar.search_active);
491 assert!(bar.keybinds.is_empty());
492 assert_eq!(bar.position, TitleBarPosition::Top);
493 assert_eq!(bar.style, TitleBarStyle::Standard);
494 }
495
496 #[test]
497 fn test_title_bar_with_search_text() {
498 let bar = TitleBar::new("test").with_search_text("filter");
499 assert_eq!(bar.search_text, "filter");
500 }
501
502 #[test]
503 fn test_title_bar_with_search_active() {
504 let bar = TitleBar::new("test").with_search_active(true);
505 assert!(bar.search_active);
506 }
507
508 #[test]
509 fn test_title_bar_with_primary_color() {
510 let color = Color::new(1.0, 0.0, 0.0, 1.0);
511 let bar = TitleBar::new("test").with_primary_color(color);
512 assert_eq!(bar.primary_color, color);
513 }
514
515 #[test]
516 fn test_title_bar_with_secondary_color() {
517 let color = Color::new(0.0, 1.0, 0.0, 1.0);
518 let bar = TitleBar::new("test").with_secondary_color(color);
519 assert_eq!(bar.secondary_color, color);
520 }
521
522 #[test]
523 fn test_title_bar_with_mode_indicator() {
524 let bar = TitleBar::new("test").with_mode_indicator("[FULLSCREEN]");
525 assert_eq!(bar.mode_indicator, Some("[FULLSCREEN]".to_string()));
526 }
527
528 #[test]
529 fn test_title_bar_with_position() {
530 let bar = TitleBar::new("test").with_position(TitleBarPosition::Bottom);
531 assert_eq!(bar.position, TitleBarPosition::Bottom);
532 }
533
534 #[test]
539 fn test_title_bar_search() {
540 let mut bar = TitleBar::new("test");
541 assert!(!bar.is_search_active());
542
543 bar.toggle_search();
544 assert!(bar.is_search_active());
545
546 bar.set_search_text("hello");
547 assert_eq!(bar.search_text(), "hello");
548 }
549
550 #[test]
551 fn test_title_bar_toggle_search_twice() {
552 let mut bar = TitleBar::new("test");
553 bar.toggle_search();
554 assert!(bar.is_search_active());
555 bar.toggle_search();
556 assert!(!bar.is_search_active());
557 }
558
559 #[test]
564 fn test_title_bar_keybinds() {
565 let bar = TitleBar::new("test").with_keybinds(&[("q", "Quit"), ("?", "Help")]);
566
567 assert_eq!(bar.keybinds.len(), 2);
568 assert_eq!(bar.keybinds[0], ("q".to_string(), "Quit".to_string()));
569 }
570
571 #[test]
572 fn test_title_bar_keybinds_empty() {
573 let bar = TitleBar::new("test").with_keybinds(&[]);
574 assert!(bar.keybinds.is_empty());
575 }
576
577 #[test]
582 fn test_title_bar_styles() {
583 let minimal = TitleBar::new("test").with_style(TitleBarStyle::Minimal);
584 let standard = TitleBar::new("test").with_style(TitleBarStyle::Standard);
585 let detailed = TitleBar::new("test").with_style(TitleBarStyle::Detailed);
586
587 assert_eq!(minimal.style, TitleBarStyle::Minimal);
588 assert_eq!(standard.style, TitleBarStyle::Standard);
589 assert_eq!(detailed.style, TitleBarStyle::Detailed);
590 }
591
592 #[test]
593 fn test_title_bar_position_default() {
594 assert_eq!(TitleBarPosition::default(), TitleBarPosition::Top);
595 }
596
597 #[test]
598 fn test_title_bar_style_default() {
599 assert_eq!(TitleBarStyle::default(), TitleBarStyle::Standard);
600 }
601
602 #[test]
607 fn test_title_bar_status() {
608 let bar = TitleBar::new("test").with_status(
609 "Connected",
610 Color {
611 r: 0.0,
612 g: 1.0,
613 b: 0.0,
614 a: 1.0,
615 },
616 );
617
618 assert_eq!(bar.status_text, Some("Connected".to_string()));
619 assert!(bar.status_color.is_some());
620 }
621
622 #[test]
627 fn test_title_bar_brick_name() {
628 let bar = TitleBar::new("test");
629 assert_eq!(bar.brick_name(), "title_bar");
630 }
631
632 #[test]
633 fn test_title_bar_assertions() {
634 let bar = TitleBar::new("test");
635 let assertions = bar.assertions();
636 assert!(!assertions.is_empty());
637 }
638
639 #[test]
640 fn test_title_bar_budget() {
641 let bar = TitleBar::new("test");
642 let budget = bar.budget();
643 assert!(budget.total_ms > 0);
644 }
645
646 #[test]
647 fn test_title_bar_verify() {
648 let bar = TitleBar::new("test");
649 let verification = bar.verify();
650 assert!(!verification.passed.is_empty());
651 assert!(verification.failed.is_empty());
652 }
653
654 #[test]
655 fn test_title_bar_to_html() {
656 let bar = TitleBar::new("ptop").with_search_text("filter");
657 let html = bar.to_html();
658 assert!(html.contains("ptop"));
659 assert!(html.contains("filter"));
660 assert!(html.contains("title-bar"));
661 }
662
663 #[test]
664 fn test_title_bar_to_css() {
665 let bar = TitleBar::new("ptop").with_search_text("filter");
666 let css = bar.to_css();
667 assert!(css.contains("ptop"));
668 assert!(css.contains("filter"));
669 }
670
671 #[test]
676 fn test_title_bar_type_id() {
677 let bar = TitleBar::new("test");
678 let id = Widget::type_id(&bar);
679 assert_eq!(id, TypeId::of::<TitleBar>());
680 }
681
682 #[test]
683 fn test_title_bar_measure() {
684 let bar = TitleBar::new("test");
685 let constraints = Constraints::loose(Size::new(100.0, 50.0));
686 let size = bar.measure(constraints);
687 assert_eq!(size.width, 100.0);
688 assert_eq!(size.height, 1.0);
689 }
690
691 #[test]
692 fn test_title_bar_layout() {
693 let mut bar = TitleBar::new("test");
694 let bounds = Rect::new(0.0, 0.0, 100.0, 1.0);
695 let result = bar.layout(bounds);
696 assert_eq!(result.size.width, 100.0);
697 assert_eq!(result.size.height, 1.0);
698 assert_eq!(bar.bounds, bounds);
699 }
700
701 #[test]
702 fn test_title_bar_paint_standard() {
703 let mut buffer = CellBuffer::new(100, 5);
704 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
705
706 let mut bar = TitleBar::new("ptop")
707 .with_version("1.0.0")
708 .with_keybinds(&[("q", "Quit")]);
709 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
710 bar.paint(&mut canvas);
711 }
712
713 #[test]
714 fn test_title_bar_paint_minimal() {
715 let mut buffer = CellBuffer::new(100, 5);
716 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
717
718 let mut bar = TitleBar::new("ptop").with_style(TitleBarStyle::Minimal);
719 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
720 bar.paint(&mut canvas);
721 }
722
723 #[test]
724 fn test_title_bar_paint_with_search_active() {
725 let mut buffer = CellBuffer::new(100, 5);
726 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
727
728 let mut bar = TitleBar::new("ptop").with_search_active(true);
729 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
730 bar.paint(&mut canvas);
731 }
732
733 #[test]
734 fn test_title_bar_paint_with_search_text() {
735 let mut buffer = CellBuffer::new(100, 5);
736 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
737
738 let mut bar = TitleBar::new("ptop").with_search_text("filter text");
739 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
740 bar.paint(&mut canvas);
741 }
742
743 #[test]
744 fn test_title_bar_paint_with_long_search_text() {
745 let mut buffer = CellBuffer::new(100, 5);
746 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
747
748 let mut bar = TitleBar::new("ptop")
749 .with_search_text("this is a very long search text that should be truncated");
750 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
751 bar.paint(&mut canvas);
752 }
753
754 #[test]
755 fn test_title_bar_paint_with_mode_indicator() {
756 let mut buffer = CellBuffer::new(100, 5);
757 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
758
759 let mut bar = TitleBar::new("ptop").with_mode_indicator("[FULLSCREEN]");
760 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
761 bar.paint(&mut canvas);
762 }
763
764 #[test]
765 fn test_title_bar_paint_with_status() {
766 let mut buffer = CellBuffer::new(100, 5);
767 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
768
769 let mut bar =
770 TitleBar::new("ptop").with_status("Connected", Color::new(0.0, 1.0, 0.0, 1.0));
771 bar.layout(Rect::new(0.0, 0.0, 100.0, 1.0));
772 bar.paint(&mut canvas);
773 }
774
775 #[test]
776 fn test_title_bar_paint_too_small() {
777 let mut buffer = CellBuffer::new(10, 5);
778 let mut canvas = DirectTerminalCanvas::new(&mut buffer);
779
780 let mut bar = TitleBar::new("ptop");
781 bar.layout(Rect::new(0.0, 0.0, 5.0, 1.0)); bar.paint(&mut canvas); }
784
785 #[test]
790 fn test_title_bar_event_slash_activates_search() {
791 let mut bar = TitleBar::new("test");
792 assert!(!bar.search_active);
793
794 let event = Event::KeyDown { key: Key::Slash };
795 bar.event(&event);
796 assert!(bar.search_active);
797 }
798
799 #[test]
800 fn test_title_bar_event_slash_ignored_when_active() {
801 let mut bar = TitleBar::new("test").with_search_active(true);
802
803 let event = Event::KeyDown { key: Key::Slash };
804 bar.event(&event);
805 assert!(bar.search_active); }
807
808 #[test]
809 fn test_title_bar_event_escape_deactivates_search() {
810 let mut bar = TitleBar::new("test")
811 .with_search_active(true)
812 .with_search_text("filter");
813
814 let event = Event::KeyDown { key: Key::Escape };
815 bar.event(&event);
816 assert!(!bar.search_active);
817 assert!(bar.search_text.is_empty()); }
819
820 #[test]
821 fn test_title_bar_event_escape_ignored_when_inactive() {
822 let mut bar = TitleBar::new("test");
823
824 let event = Event::KeyDown { key: Key::Escape };
825 bar.event(&event);
826 assert!(!bar.search_active);
827 }
828
829 #[test]
830 fn test_title_bar_event_enter_deactivates_search() {
831 let mut bar = TitleBar::new("test")
832 .with_search_active(true)
833 .with_search_text("filter");
834
835 let event = Event::KeyDown { key: Key::Enter };
836 bar.event(&event);
837 assert!(!bar.search_active);
838 assert_eq!(bar.search_text, "filter"); }
840
841 #[test]
842 fn test_title_bar_event_backspace_deletes_char() {
843 let mut bar = TitleBar::new("test")
844 .with_search_active(true)
845 .with_search_text("filter");
846
847 let event = Event::KeyDown {
848 key: Key::Backspace,
849 };
850 bar.event(&event);
851 assert_eq!(bar.search_text, "filte");
852 }
853
854 #[test]
855 fn test_title_bar_event_backspace_on_empty() {
856 let mut bar = TitleBar::new("test").with_search_active(true);
857
858 let event = Event::KeyDown {
859 key: Key::Backspace,
860 };
861 bar.event(&event);
862 assert!(bar.search_text.is_empty());
863 }
864
865 #[test]
866 fn test_title_bar_event_text_input() {
867 let mut bar = TitleBar::new("test").with_search_active(true);
868
869 let event = Event::TextInput {
870 text: "hello".to_string(),
871 };
872 bar.event(&event);
873 assert_eq!(bar.search_text, "hello");
874 }
875
876 #[test]
877 fn test_title_bar_event_text_input_ignored_when_inactive() {
878 let mut bar = TitleBar::new("test");
879
880 let event = Event::TextInput {
881 text: "hello".to_string(),
882 };
883 bar.event(&event);
884 assert!(bar.search_text.is_empty());
885 }
886
887 #[test]
888 fn test_title_bar_event_unhandled() {
889 let mut bar = TitleBar::new("test");
890
891 let event = Event::KeyDown { key: Key::Tab };
892 let result = bar.event(&event);
893 assert!(result.is_none());
894 }
895
896 #[test]
897 fn test_title_bar_children() {
898 let bar = TitleBar::new("test");
899 assert!(bar.children().is_empty());
900 }
901
902 #[test]
903 fn test_title_bar_children_mut() {
904 let mut bar = TitleBar::new("test");
905 assert!(bar.children_mut().is_empty());
906 }
907}