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 { 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    // =========================================================================
471    // CREATION & BUILDER TESTS
472    // =========================================================================
473
474    #[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    // =========================================================================
535    // SEARCH TESTS
536    // =========================================================================
537
538    #[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    // =========================================================================
560    // KEYBINDS TESTS
561    // =========================================================================
562
563    #[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    // =========================================================================
578    // STYLE TESTS
579    // =========================================================================
580
581    #[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    // =========================================================================
603    // STATUS TESTS
604    // =========================================================================
605
606    #[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    // =========================================================================
623    // BRICK TRAIT TESTS
624    // =========================================================================
625
626    #[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    // =========================================================================
672    // WIDGET TRAIT TESTS
673    // =========================================================================
674
675    #[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)); // Too small
782        bar.paint(&mut canvas); // Should return early
783    }
784
785    // =========================================================================
786    // EVENT HANDLING TESTS
787    // =========================================================================
788
789    #[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); // Still active
806    }
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()); // Cleared
818    }
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"); // Preserved
839    }
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}