Skip to main content

presentar_terminal/widgets/
title_bar.rs

1//! `TitleBar` widget - Standard header for all TUI applications.
2//!
3//! Grammar of Graphics construct: Every TUI MUST have a title bar with:
4//! - App name/logo
5//! - Search/filter input
6//! - Key bindings hint
7//! - Optional status indicators
8//!
9//! Implements SPEC-024 Section 27.8 - Framework-First pattern.
10
11use 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/// Title bar position
19#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20pub enum TitleBarPosition {
21    #[default]
22    Top,
23    Bottom,
24}
25
26/// Title bar style preset
27#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
28pub enum TitleBarStyle {
29    #[default]
30    Standard,
31    Minimal,
32    Detailed,
33}
34
35/// Standard title bar widget for TUI applications.
36///
37/// # Example
38/// ```ignore
39/// let title_bar = TitleBar::new("ptop")
40///     .with_version("1.0.0")
41///     .with_search_placeholder("Filter processes...")
42///     .with_keybinds(&[("q", "Quit"), ("?", "Help"), ("/", "Search")]);
43/// ```
44#[derive(Debug, Clone)]
45pub struct TitleBar {
46    /// Application name
47    app_name: String,
48    /// Version string (optional)
49    version: Option<String>,
50    /// Current search/filter text
51    search_text: String,
52    /// Search placeholder text
53    search_placeholder: String,
54    /// Whether search is active (focused)
55    search_active: bool,
56    /// Key binding hints [(key, description)]
57    keybinds: Vec<(String, String)>,
58    /// Primary color for app name
59    primary_color: Color,
60    /// Secondary color for hints
61    secondary_color: Color,
62    /// Position (top or bottom)
63    position: TitleBarPosition,
64    /// Style preset
65    style: TitleBarStyle,
66    /// Optional status text (right side)
67    status_text: Option<String>,
68    /// Optional status color
69    status_color: Option<Color>,
70    /// Mode indicator (e.g., "[FULLSCREEN]")
71    mode_indicator: Option<String>,
72    /// Cached bounds
73    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    /// Create a new title bar with app name.
109    #[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    /// Set version string.
118    #[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    /// Set search placeholder text.
125    #[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    /// Set current search text.
132    #[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    /// Set search active state.
139    #[must_use]
140    pub fn with_search_active(mut self, active: bool) -> Self {
141        self.search_active = active;
142        self
143    }
144
145    /// Set key binding hints.
146    #[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    /// Set primary color.
156    #[must_use]
157    pub fn with_primary_color(mut self, color: Color) -> Self {
158        self.primary_color = color;
159        self
160    }
161
162    /// Set secondary color.
163    #[must_use]
164    pub fn with_secondary_color(mut self, color: Color) -> Self {
165        self.secondary_color = color;
166        self
167    }
168
169    /// Set mode indicator (e.g., "[FULLSCREEN]").
170    #[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    /// Set position (top or bottom).
177    #[must_use]
178    pub fn with_position(mut self, position: TitleBarPosition) -> Self {
179        self.position = position;
180        self
181    }
182
183    /// Set style preset.
184    #[must_use]
185    pub fn with_style(mut self, style: TitleBarStyle) -> Self {
186        self.style = style;
187        self
188    }
189
190    /// Set status text (displayed on right side).
191    #[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    /// Update search text (for interactive use).
199    pub fn set_search_text(&mut self, text: impl Into<String>) {
200        self.search_text = text.into();
201    }
202
203    /// Get current search text.
204    #[must_use]
205    pub fn search_text(&self) -> &str {
206        &self.search_text
207    }
208
209    /// Toggle search active state.
210    pub fn toggle_search(&mut self) {
211        self.search_active = !self.search_active;
212    }
213
214    /// Check if search is active.
215    #[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        // Style configurations
284        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        // === LEFT: App name + version ===
291        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        // === CENTER: Search box ===
316        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            // Draw search box
321            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            // [/] search_text
343            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        // === RIGHT: Mode Indicator + Status + Keybinds ===
370        let right_section_start = width as f32 * 0.55;
371        let mut right_x = right_section_start;
372
373        // Mode indicator (e.g., [FULLSCREEN])
374        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                    }, // Yellow/gold
385                    weight: FontWeight::Bold,
386                    ..Default::default()
387                },
388            );
389            right_x += indicator.len() as f32 + 2.0;
390        }
391
392        // Status text (if any)
393        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        // Key bindings hint (right-aligned)
405        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    // =========================================================================
478    // CREATION & BUILDER TESTS
479    // =========================================================================
480
481    #[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    // =========================================================================
542    // SEARCH TESTS
543    // =========================================================================
544
545    #[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    // =========================================================================
567    // KEYBINDS TESTS
568    // =========================================================================
569
570    #[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    // =========================================================================
585    // STYLE TESTS
586    // =========================================================================
587
588    #[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    // =========================================================================
610    // STATUS TESTS
611    // =========================================================================
612
613    #[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    // =========================================================================
630    // BRICK TRAIT TESTS
631    // =========================================================================
632
633    #[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    // =========================================================================
679    // WIDGET TRAIT TESTS
680    // =========================================================================
681
682    #[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)); // Too small
789        bar.paint(&mut canvas); // Should return early
790    }
791
792    // =========================================================================
793    // EVENT HANDLING TESTS
794    // =========================================================================
795
796    #[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); // Still active
813    }
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()); // Cleared
825    }
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"); // Preserved
846    }
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}