Skip to main content

frankensearch_tui/
shell.rs

1//! App shell: status bar, breadcrumbs, screen lifecycle.
2//!
3//! The [`AppShell`] owns the [`ScreenRegistry`], manages navigation between
4//! screens, renders the chrome (status bar, breadcrumbs), and dispatches
5//! input events to the active screen.
6
7use std::cell::Cell;
8use std::hash::{DefaultHasher, Hash, Hasher};
9
10use ftui_core::geometry::Rect;
11use ftui_render::frame::Frame;
12use ftui_style::Style;
13use ftui_text::{Line, Span};
14use ftui_widgets::{
15    Widget,
16    block::Block,
17    borders::{BorderType, Borders},
18    paragraph::Paragraph,
19};
20use serde::{Deserialize, Serialize};
21
22use crate::frame::{CachedLayout, CachedTabState};
23use crate::input::{InputEvent, KeyAction, Keymap};
24use crate::overlay::OverlayManager;
25use crate::palette::{CommandPalette, PaletteState};
26use crate::screen::{KeybindingHint, ScreenAction, ScreenContext, ScreenId, ScreenRegistry};
27use crate::theme::Theme;
28
29// ─── Shell Config ────────────────────────────────────────────────────────────
30
31/// Configuration for the app shell.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ShellConfig {
34    /// Application title shown in the status bar.
35    pub title: String,
36    /// Theme preset to use.
37    pub theme: Theme,
38    /// Whether to show the status bar.
39    pub show_status_bar: bool,
40    /// Whether to show breadcrumbs (tab bar).
41    pub show_breadcrumbs: bool,
42}
43
44impl Default for ShellConfig {
45    fn default() -> Self {
46        Self {
47            title: "frankensearch".to_string(),
48            theme: Theme::dark(),
49            show_status_bar: true,
50            show_breadcrumbs: true,
51        }
52    }
53}
54
55// ─── Status Line ─────────────────────────────────────────────────────────────
56
57/// Status line content rendered at the bottom of the shell.
58#[derive(Debug, Clone, Default)]
59pub struct StatusLine {
60    /// Left-aligned status text.
61    pub left: String,
62    /// Center status text.
63    pub center: String,
64    /// Right-aligned status text.
65    pub right: String,
66}
67
68impl StatusLine {
69    /// Create a new status line.
70    #[must_use]
71    pub fn new() -> Self {
72        Self::default()
73    }
74
75    /// Set the left-aligned text.
76    #[must_use]
77    pub fn with_left(mut self, text: impl Into<String>) -> Self {
78        self.left = text.into();
79        self
80    }
81
82    /// Set the center text.
83    #[must_use]
84    pub fn with_center(mut self, text: impl Into<String>) -> Self {
85        self.center = text.into();
86        self
87    }
88
89    /// Set the right-aligned text.
90    #[must_use]
91    pub fn with_right(mut self, text: impl Into<String>) -> Self {
92        self.right = text.into();
93        self
94    }
95}
96
97// ─── App Shell ───────────────────────────────────────────────────────────────
98
99/// The main app shell that manages screens, chrome, and input dispatch.
100pub struct AppShell {
101    /// Shell configuration.
102    pub config: ShellConfig,
103    /// Screen registry.
104    pub registry: ScreenRegistry,
105    /// Currently active screen ID.
106    pub active_screen: Option<ScreenId>,
107    /// Keymap for input resolution.
108    pub keymap: Keymap,
109    /// Overlay manager.
110    pub overlays: OverlayManager,
111    /// Command palette.
112    pub palette: CommandPalette,
113    /// Status line content.
114    pub status_line: StatusLine,
115    /// Whether the app should quit.
116    pub should_quit: bool,
117    /// Last confirmed palette action ID (reset each `handle_input` call).
118    last_palette_action: Option<String>,
119    /// Terminal area captured from the most recent render pass.
120    last_render_area: Cell<Rect>,
121    /// Cached layout to avoid recomputing splits every frame.
122    cached_layout: CachedLayout,
123    /// Cached tab titles and selected index.
124    cached_tabs: CachedTabState,
125}
126
127impl AppShell {
128    fn build_help_overlay_request(&self) -> crate::overlay::OverlayRequest {
129        let mut request = crate::overlay::OverlayRequest::new(
130            crate::overlay::OverlayKind::Help,
131            "Keyboard Shortcuts",
132        )
133        .with_body("Global shortcuts + active screen controls");
134
135        if let Some(screen_id) = &self.active_screen
136            && let Some(screen) = self.registry.get(screen_id)
137        {
138            let hints = screen.keybindings();
139            if !hints.is_empty() {
140                request.title = format!("Keyboard Shortcuts · {}", screen.title());
141                request.actions = Self::encode_screen_keybindings(hints);
142            }
143        }
144
145        request
146    }
147
148    fn encode_screen_keybindings(hints: &[KeybindingHint]) -> Vec<String> {
149        hints
150            .iter()
151            .map(|hint| format!("{}|{}", hint.key, hint.description))
152            .collect()
153    }
154
155    /// Create a new app shell with the given config.
156    #[must_use]
157    pub fn new(config: ShellConfig) -> Self {
158        Self {
159            config,
160            registry: ScreenRegistry::new(),
161            active_screen: None,
162            keymap: Keymap::default_bindings(),
163            overlays: OverlayManager::new(),
164            palette: CommandPalette::new(),
165            status_line: StatusLine::new(),
166            should_quit: false,
167            last_palette_action: None,
168            last_render_area: Cell::new(Rect::new(0, 0, 0, 0)),
169            cached_layout: CachedLayout::new(),
170            cached_tabs: CachedTabState::new(),
171        }
172    }
173
174    /// Navigate to a screen by ID.
175    pub fn navigate_to(&mut self, id: &ScreenId) {
176        if self.registry.get(id).is_some() {
177            // Blur the old screen.
178            if let Some(old_id) = &self.active_screen {
179                let old_id = old_id.clone();
180                if let Some(screen) = self.registry.get_mut(&old_id) {
181                    screen.on_blur();
182                }
183            }
184            // Focus the new screen.
185            self.active_screen = Some(id.clone());
186            if let Some(screen) = self.registry.get_mut(id) {
187                screen.on_focus();
188            }
189            // Invalidate tab cache since the active screen changed.
190            self.cached_tabs.invalidate();
191        }
192    }
193
194    /// Navigate to the next screen in tab order.
195    pub fn next_screen(&mut self) {
196        if let Some(current) = &self.active_screen
197            && let Some(next) = self.registry.next_screen(current).cloned()
198        {
199            self.navigate_to(&next);
200        }
201    }
202
203    /// Navigate to the previous screen in tab order.
204    pub fn prev_screen(&mut self) {
205        if let Some(current) = &self.active_screen
206            && let Some(prev) = self.registry.prev_screen(current).cloned()
207        {
208            self.navigate_to(&prev);
209        }
210    }
211
212    /// Build the screen context for the current state.
213    #[must_use]
214    pub fn screen_context(&self, area: Rect) -> ScreenContext {
215        ScreenContext {
216            active_screen: self
217                .active_screen
218                .clone()
219                .unwrap_or_else(|| ScreenId::new("")),
220            terminal_width: area.width,
221            terminal_height: area.height,
222            focused: true,
223        }
224    }
225
226    /// Handle an input event. Returns `true` if the app should quit.
227    ///
228    /// Returns the confirmed palette action ID if the user selected a command.
229    /// Callers should check `last_palette_action()` after calling this.
230    #[allow(clippy::too_many_lines)]
231    pub fn handle_input(&mut self, event: &InputEvent) -> bool {
232        self.last_palette_action = None;
233
234        if let InputEvent::Resize(width, height) = event {
235            self.last_render_area.set(Rect::new(0, 0, *width, *height));
236        }
237
238        if self.last_render_area.get().width == 0 || self.last_render_area.get().height == 0 {
239            // Fallback keeps context sane in non-interactive test harnesses.
240            self.last_render_area.set(Rect::new(0, 0, 80, 24));
241        }
242
243        // If the command palette is open, route input there first.
244        if self.palette.state() == &PaletteState::Open {
245            if let InputEvent::Key(key, mods) = event {
246                if let Some(action) = self.keymap.resolve(*key, *mods) {
247                    match action {
248                        KeyAction::TogglePalette | KeyAction::Dismiss => {
249                            self.palette.close();
250                            return false;
251                        }
252                        KeyAction::Up => {
253                            self.palette.select_prev();
254                            return false;
255                        }
256                        KeyAction::Down => {
257                            self.palette.select_next();
258                            return false;
259                        }
260                        KeyAction::Confirm => {
261                            if let Some(action_id) = self.palette.confirm() {
262                                self.last_palette_action = Some(action_id);
263                            }
264                            self.palette.close();
265                            return false;
266                        }
267                        _ => {}
268                    }
269                }
270                // Handle text input for palette search.
271                match key {
272                    ftui_core::event::KeyCode::Char(ch)
273                        if !mods.intersects(
274                            ftui_core::event::Modifiers::CTRL
275                                | ftui_core::event::Modifiers::ALT
276                                | ftui_core::event::Modifiers::SUPER,
277                        ) =>
278                    {
279                        self.palette.push_char(*ch);
280                        return false;
281                    }
282                    ftui_core::event::KeyCode::Backspace => {
283                        self.palette.pop_char();
284                        return false;
285                    }
286                    _ => {}
287                }
288            }
289            return false;
290        }
291
292        // If an overlay is active, let it handle first.
293        if self.overlays.has_active() {
294            if let InputEvent::Key(key, mods) = event
295                && let Some(action) = self.keymap.resolve(*key, *mods)
296                && action == &KeyAction::Dismiss
297            {
298                self.overlays.dismiss();
299                return false;
300            }
301            return false;
302        }
303
304        // Resolve key actions.
305        if let InputEvent::Key(key, mods) = event
306            && let Some(action) = self.keymap.resolve(*key, *mods).cloned()
307        {
308            match action {
309                KeyAction::Quit => {
310                    self.should_quit = true;
311                    return true;
312                }
313                KeyAction::NextScreen => {
314                    self.next_screen();
315                    return false;
316                }
317                KeyAction::PrevScreen => {
318                    self.prev_screen();
319                    return false;
320                }
321                KeyAction::ToggleHelp => {
322                    if self
323                        .overlays
324                        .top()
325                        .is_some_and(|o| o.kind == crate::overlay::OverlayKind::Help)
326                    {
327                        self.overlays.dismiss();
328                    } else {
329                        self.overlays.push(self.build_help_overlay_request());
330                    }
331                    return false;
332                }
333                KeyAction::TogglePalette => {
334                    self.palette.toggle();
335                    return false;
336                }
337                KeyAction::CycleTheme => {
338                    self.config.theme = Theme::from_preset(self.config.theme.preset.next());
339                    self.cached_tabs.invalidate();
340                    return false;
341                }
342                _ => {}
343            }
344        }
345
346        // Forward to active screen.
347        if let Some(screen_id) = &self.active_screen {
348            let screen_id = screen_id.clone();
349            let ctx = self.screen_context(self.last_render_area.get());
350            if let Some(screen) = self.registry.get_mut(&screen_id) {
351                match screen.handle_input(event, &ctx) {
352                    ScreenAction::Quit => {
353                        self.should_quit = true;
354                        return true;
355                    }
356                    ScreenAction::Navigate(target) => {
357                        self.navigate_to(&target);
358                    }
359                    ScreenAction::OpenOverlay(name) => {
360                        self.overlays.push(crate::overlay::OverlayRequest::new(
361                            crate::overlay::OverlayKind::Custom(name.clone()),
362                            name,
363                        ));
364                    }
365                    ScreenAction::Consumed | ScreenAction::Ignored => {}
366                }
367            }
368        }
369
370        false
371    }
372
373    /// Get the last palette action confirmed by the user.
374    ///
375    /// Returns `Some(action_id)` if the user selected an action from the
376    /// command palette during the last `handle_input()` call.
377    #[must_use]
378    pub fn last_palette_action(&self) -> Option<&str> {
379        self.last_palette_action.as_deref()
380    }
381
382    /// Render the shell chrome and active screen.
383    ///
384    /// Uses cached layout and tab state to avoid redundant allocations
385    /// when the terminal dimensions and screen configuration haven't changed.
386    #[allow(clippy::too_many_lines)]
387    pub fn render(&mut self, frame: &mut Frame) {
388        let area = frame.bounds();
389        self.last_render_area.set(area);
390        let ctx = self.screen_context(area);
391
392        // Cached layout avoids recomputing splits when area hasn't changed.
393        // Copy the Rect values directly (Rect is Copy, 8 bytes) instead of
394        // allocating a Vec clone every frame.
395        let show_bc = self.config.show_breadcrumbs;
396        let show_sb = self.config.show_status_bar;
397        let num_screens = self.registry.len();
398        let layout_chunks = self
399            .cached_layout
400            .get_or_compute(area, show_bc, show_sb, num_screens);
401        let bc_area = if show_bc && num_screens > 1 {
402            Some(layout_chunks[0])
403        } else {
404            None
405        };
406        let content_idx = usize::from(bc_area.is_some());
407        let content_area = layout_chunks[content_idx];
408        let status_area = if show_sb {
409            Some(layout_chunks[content_idx + 1])
410        } else {
411            None
412        };
413
414        // Breadcrumbs / tabs (using cached titles and selected index).
415        if let Some(bc_rect) = bc_area {
416            let screen_ids = self.registry.screen_ids();
417            let mut id_hasher = DefaultHasher::new();
418            let mut title_hasher = DefaultHasher::new();
419            for id in screen_ids {
420                id.0.hash(&mut id_hasher);
421                id.0.hash(&mut title_hasher);
422                if let Some(screen) = self.registry.get(id) {
423                    screen.title().hash(&mut title_hasher);
424                }
425            }
426            let screen_ids_hash = id_hasher.finish();
427            let title_signature = title_hasher.finish();
428            let active_str = self.active_screen.as_ref().map(|id| id.0.as_str());
429
430            if !self
431                .cached_tabs
432                .is_valid(screen_ids_hash, title_signature, active_str)
433            {
434                let titles: Vec<String> = screen_ids
435                    .iter()
436                    .map(|id| {
437                        self.registry
438                            .get(id)
439                            .map_or_else(|| id.0.clone(), |s| s.title().to_string())
440                    })
441                    .collect();
442
443                let selected = self
444                    .active_screen
445                    .as_ref()
446                    .and_then(|active| screen_ids.iter().position(|id| id == active))
447                    .unwrap_or(0);
448
449                self.cached_tabs.update(
450                    titles,
451                    selected,
452                    screen_ids_hash,
453                    title_signature,
454                    active_str,
455                );
456            }
457
458            let mut tab_spans: Vec<Span<'_>> = Vec::new();
459            for (i, title) in self.cached_tabs.titles.iter().enumerate() {
460                if i > 0 {
461                    tab_spans.push(Span::styled(
462                        " \u{2502} ",
463                        Style::new().fg(self.config.theme.border.to_color()),
464                    ));
465                }
466                if i == self.cached_tabs.selected {
467                    tab_spans.push(Span::styled(
468                        format!(" {title} "),
469                        Style::new()
470                            .fg(self.config.theme.highlight_fg.to_color())
471                            .bg(self.config.theme.accent.to_color())
472                            .bold(),
473                    ));
474                } else {
475                    tab_spans.push(Span::styled(
476                        format!(" {title} "),
477                        Style::new().fg(self.config.theme.muted.to_color()).bg(self
478                            .config
479                            .theme
480                            .bg
481                            .to_color()),
482                    ));
483                }
484            }
485            let tab_line = Paragraph::new(Line::from_spans(tab_spans))
486                .style(Style::new().bg(self.config.theme.bg.to_color()));
487
488            tab_line.render(bc_rect, frame);
489        }
490
491        // Main content area.
492        if let Some(screen_id) = &self.active_screen {
493            if let Some(screen) = self.registry.get(screen_id) {
494                screen.render(frame, &ctx);
495            }
496        } else {
497            // No screen active — render placeholder.
498            let block = Block::new()
499                .borders(Borders::ALL)
500                .border_type(BorderType::Rounded)
501                .border_style(Style::new().fg(self.config.theme.border.to_color()))
502                .style(
503                    Style::new().bg(self.config.theme.bg.to_color()).fg(self
504                        .config
505                        .theme
506                        .fg
507                        .to_color()),
508                );
509            let placeholder = Paragraph::new("No screens registered").block(block);
510            placeholder.render(content_area, frame);
511        }
512
513        // Status bar.
514        if let Some(sb_rect) = status_area {
515            let status_text = if self.status_line.center.is_empty() {
516                format!(" {} ", self.config.title)
517            } else {
518                format!(
519                    " {} \u{2502} {} ",
520                    self.config.title, self.status_line.center
521                )
522            };
523
524            let active_screen_hints = self
525                .active_screen
526                .as_ref()
527                .and_then(|id| self.registry.get(id))
528                .map_or(&[][..], |screen| screen.keybindings());
529            let hints = Self::status_bar_hints(sb_rect.width, active_screen_hints);
530            let left_content_len =
531                self.status_line.left.len() + status_text.len() + self.status_line.right.len();
532            let pad_width = (sb_rect.width as usize).saturating_sub(left_content_len + hints.len());
533            let padding = " ".repeat(pad_width);
534
535            let muted_style = Style::new().fg(self.config.theme.muted.to_color());
536
537            let status_spans = vec![
538                Span::styled(
539                    self.status_line.left.clone(),
540                    Style::new().fg(self.config.theme.status_bar_fg.to_color()),
541                ),
542                Span::styled(
543                    status_text,
544                    Style::new()
545                        .fg(self.config.theme.status_bar_fg.to_color())
546                        .bold(),
547                ),
548                Span::styled(
549                    self.status_line.right.clone(),
550                    Style::new().fg(self.config.theme.status_bar_fg.to_color()),
551                ),
552                Span::raw(padding),
553                Span::styled(hints, muted_style),
554            ];
555
556            let status = Paragraph::new(Line::from_spans(status_spans))
557                .style(Style::new().bg(self.config.theme.status_bar_bg.to_color()));
558
559            status.render(sb_rect, frame);
560        }
561    }
562
563    /// Build right-aligned keybinding hints for the status bar.
564    fn status_bar_hints(width: u16, screen_hints: &[KeybindingHint]) -> String {
565        if width < 60 {
566            return String::new();
567        }
568
569        let mut base = if width < 90 {
570            "Tab:Nav  ?:Help  ^T:Theme ".to_string()
571        } else {
572            "Tab:Nav  ?:Help  ^P:Cmd  ^T:Theme  q:Quit ".to_string()
573        };
574
575        if width >= 110 && !screen_hints.is_empty() {
576            let contextual = screen_hints
577                .iter()
578                .take(2)
579                .map(|hint| format!("{} {}", hint.key, hint.description))
580                .collect::<Vec<_>>()
581                .join("  ");
582            if !contextual.is_empty() {
583                base.push_str("| ");
584                base.push_str(&contextual);
585                base.push(' ');
586            }
587        }
588
589        base
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use std::any::Any;
596    use std::sync::{Arc, Mutex};
597
598    use ftui_render::frame::Frame;
599
600    use crate::screen::{Screen, ScreenAction};
601
602    use super::*;
603
604    #[test]
605    fn shell_config_default() {
606        let config = ShellConfig::default();
607        assert_eq!(config.title, "frankensearch");
608        assert!(config.show_status_bar);
609        assert!(config.show_breadcrumbs);
610    }
611
612    #[test]
613    fn status_line_builder() {
614        let status = StatusLine::new()
615            .with_left("left")
616            .with_center("center")
617            .with_right("right");
618        assert_eq!(status.left, "left");
619        assert_eq!(status.center, "center");
620        assert_eq!(status.right, "right");
621    }
622
623    #[test]
624    fn shell_creation() {
625        let shell = AppShell::new(ShellConfig::default());
626        assert!(!shell.should_quit);
627        assert!(shell.active_screen.is_none());
628        assert!(shell.registry.is_empty());
629    }
630
631    #[test]
632    fn shell_config_serde_roundtrip() {
633        let config = ShellConfig::default();
634        let json = serde_json::to_string(&config).unwrap();
635        let decoded: ShellConfig = serde_json::from_str(&json).unwrap();
636        assert_eq!(decoded.title, config.title);
637    }
638
639    #[test]
640    fn shell_quit_handling() {
641        let mut shell = AppShell::new(ShellConfig::default());
642        let event = InputEvent::Key(
643            ftui_core::event::KeyCode::Char('q'),
644            ftui_core::event::Modifiers::NONE,
645        );
646        let quit = shell.handle_input(&event);
647        assert!(quit);
648        assert!(shell.should_quit);
649    }
650
651    struct CaptureContextScreen {
652        id: ScreenId,
653        captured: Arc<Mutex<Option<(u16, u16)>>>,
654    }
655
656    impl CaptureContextScreen {
657        fn new(id: &str, captured: Arc<Mutex<Option<(u16, u16)>>>) -> Self {
658            Self {
659                id: ScreenId::new(id),
660                captured,
661            }
662        }
663    }
664
665    impl Screen for CaptureContextScreen {
666        fn id(&self) -> &ScreenId {
667            &self.id
668        }
669
670        fn title(&self) -> &'static str {
671            "capture"
672        }
673
674        fn render(&self, _frame: &mut Frame, _ctx: &ScreenContext) {}
675
676        fn handle_input(&mut self, _event: &InputEvent, ctx: &ScreenContext) -> ScreenAction {
677            *self.captured.lock().expect("capture lock") =
678                Some((ctx.terminal_width, ctx.terminal_height));
679            ScreenAction::Consumed
680        }
681
682        fn as_any(&self) -> &dyn Any {
683            self
684        }
685
686        fn as_any_mut(&mut self) -> &mut dyn Any {
687            self
688        }
689    }
690
691    #[test]
692    fn handle_input_uses_last_render_area_for_context() {
693        let mut shell = AppShell::new(ShellConfig::default());
694        let captured = Arc::new(Mutex::new(None));
695        let screen_id = ScreenId::new("capture");
696        shell.registry.register(Box::new(CaptureContextScreen::new(
697            "capture",
698            captured.clone(),
699        )));
700        shell.navigate_to(&screen_id);
701
702        shell.last_render_area.set(Rect::new(0, 0, 132, 47));
703        let event = InputEvent::Key(
704            ftui_core::event::KeyCode::Char('x'),
705            ftui_core::event::Modifiers::NONE,
706        );
707        let _ = shell.handle_input(&event);
708
709        let seen = captured
710            .lock()
711            .expect("capture lock")
712            .expect("context captured");
713        assert_eq!(seen, (132, 47));
714    }
715
716    #[test]
717    fn palette_toggle_shortcut_closes_palette_when_open() {
718        let mut shell = AppShell::new(ShellConfig::default());
719        let toggle = InputEvent::Key(
720            ftui_core::event::KeyCode::Char('p'),
721            ftui_core::event::Modifiers::CTRL,
722        );
723
724        let _ = shell.handle_input(&toggle);
725        assert_eq!(shell.palette.state(), &PaletteState::Open);
726
727        let _ = shell.handle_input(&toggle);
728        assert_eq!(shell.palette.state(), &PaletteState::Closed);
729    }
730
731    #[test]
732    fn palette_accepts_shift_modified_characters() {
733        let mut shell = AppShell::new(ShellConfig::default());
734        let open = InputEvent::Key(
735            ftui_core::event::KeyCode::Char('p'),
736            ftui_core::event::Modifiers::CTRL,
737        );
738        let _ = shell.handle_input(&open);
739
740        let shifted = InputEvent::Key(
741            ftui_core::event::KeyCode::Char('A'),
742            ftui_core::event::Modifiers::SHIFT,
743        );
744        let _ = shell.handle_input(&shifted);
745
746        assert_eq!(shell.palette.query(), "A");
747    }
748
749    #[test]
750    fn resize_event_refreshes_context_dimensions() {
751        let mut shell = AppShell::new(ShellConfig::default());
752        let captured = Arc::new(Mutex::new(None));
753        let screen_id = ScreenId::new("capture");
754        shell.registry.register(Box::new(CaptureContextScreen::new(
755            "capture",
756            captured.clone(),
757        )));
758        shell.navigate_to(&screen_id);
759
760        let resize = InputEvent::Resize(111, 37);
761        let _ = shell.handle_input(&resize);
762
763        let key = InputEvent::Key(
764            ftui_core::event::KeyCode::Char('x'),
765            ftui_core::event::Modifiers::NONE,
766        );
767        let _ = shell.handle_input(&key);
768
769        let seen = captured
770            .lock()
771            .expect("capture lock")
772            .expect("context captured");
773        assert_eq!(seen, (111, 37));
774    }
775
776    // ─── bd-n6x8 tests begin ───
777
778    /// Minimal stub screen for navigation/lifecycle tests.
779    struct StubScreen {
780        id: ScreenId,
781        title: &'static str,
782        focused: Arc<Mutex<bool>>,
783    }
784
785    impl StubScreen {
786        fn new(id: &str, title: &'static str) -> Self {
787            Self {
788                id: ScreenId::new(id),
789                title,
790                focused: Arc::new(Mutex::new(false)),
791            }
792        }
793
794        #[expect(dead_code)]
795        fn is_focused(&self) -> bool {
796            *self.focused.lock().unwrap()
797        }
798    }
799
800    impl Screen for StubScreen {
801        fn id(&self) -> &ScreenId {
802            &self.id
803        }
804
805        fn title(&self) -> &'static str {
806            self.title
807        }
808
809        fn render(&self, _frame: &mut Frame, _ctx: &ScreenContext) {}
810
811        fn handle_input(&mut self, _event: &InputEvent, _ctx: &ScreenContext) -> ScreenAction {
812            ScreenAction::Ignored
813        }
814
815        fn on_focus(&mut self) {
816            *self.focused.lock().unwrap() = true;
817        }
818
819        fn on_blur(&mut self) {
820            *self.focused.lock().unwrap() = false;
821        }
822
823        fn as_any(&self) -> &dyn Any {
824            self
825        }
826
827        fn as_any_mut(&mut self) -> &mut dyn Any {
828            self
829        }
830    }
831
832    #[test]
833    fn status_line_default_is_empty() {
834        let sl = StatusLine::default();
835        assert!(sl.left.is_empty());
836        assert!(sl.center.is_empty());
837        assert!(sl.right.is_empty());
838    }
839
840    #[test]
841    fn status_line_new_matches_default() {
842        let n = StatusLine::new();
843        let d = StatusLine::default();
844        assert_eq!(n.left, d.left);
845        assert_eq!(n.center, d.center);
846        assert_eq!(n.right, d.right);
847    }
848
849    #[test]
850    fn status_line_partial_builder_only_left() {
851        let sl = StatusLine::new().with_left("L");
852        assert_eq!(sl.left, "L");
853        assert!(sl.center.is_empty());
854        assert!(sl.right.is_empty());
855    }
856
857    #[test]
858    fn status_line_partial_builder_only_right() {
859        let sl = StatusLine::new().with_right("R");
860        assert!(sl.left.is_empty());
861        assert!(sl.center.is_empty());
862        assert_eq!(sl.right, "R");
863    }
864
865    #[test]
866    fn status_line_debug() {
867        let sl = StatusLine::new().with_center("mid");
868        let debug = format!("{sl:?}");
869        assert!(debug.contains("StatusLine"));
870    }
871
872    #[test]
873    fn status_line_clone() {
874        let sl = StatusLine::new().with_left("L").with_center("C");
875        #[allow(clippy::redundant_clone)]
876        let cloned = sl.clone();
877        assert_eq!(cloned.left, "L");
878        assert_eq!(cloned.center, "C");
879    }
880
881    #[test]
882    fn shell_config_debug() {
883        let config = ShellConfig::default();
884        let debug = format!("{config:?}");
885        assert!(debug.contains("ShellConfig"));
886        assert!(debug.contains("frankensearch"));
887    }
888
889    #[test]
890    fn shell_config_clone() {
891        let config = ShellConfig::default();
892        #[allow(clippy::redundant_clone)]
893        let cloned = config.clone();
894        assert_eq!(cloned.title, "frankensearch");
895        assert!(cloned.show_status_bar);
896    }
897
898    #[test]
899    fn navigate_to_nonexistent_screen_is_noop() {
900        let mut shell = AppShell::new(ShellConfig::default());
901        let bad_id = ScreenId::new("nonexistent");
902        shell.navigate_to(&bad_id);
903        assert!(shell.active_screen.is_none());
904    }
905
906    #[test]
907    fn navigate_to_valid_screen() {
908        let mut shell = AppShell::new(ShellConfig::default());
909        let id_a = ScreenId::new("a");
910        shell
911            .registry
912            .register(Box::new(StubScreen::new("a", "Screen A")));
913        shell.navigate_to(&id_a);
914        assert_eq!(shell.active_screen.as_ref(), Some(&id_a));
915    }
916
917    #[test]
918    fn navigate_blurs_old_focuses_new() {
919        let mut shell = AppShell::new(ShellConfig::default());
920        let focused_a = Arc::new(Mutex::new(false));
921        let focused_b = Arc::new(Mutex::new(false));
922        let screen_a = StubScreen {
923            id: ScreenId::new("a"),
924            title: "A",
925            focused: Arc::clone(&focused_a),
926        };
927        let screen_b = StubScreen {
928            id: ScreenId::new("b"),
929            title: "B",
930            focused: Arc::clone(&focused_b),
931        };
932        shell.registry.register(Box::new(screen_a));
933        shell.registry.register(Box::new(screen_b));
934
935        let id_a = ScreenId::new("a");
936        let id_b = ScreenId::new("b");
937
938        shell.navigate_to(&id_a);
939        assert!(*focused_a.lock().unwrap());
940
941        shell.navigate_to(&id_b);
942        assert!(!*focused_a.lock().unwrap(), "old screen should be blurred");
943        assert!(*focused_b.lock().unwrap(), "new screen should be focused");
944    }
945
946    #[test]
947    fn next_screen_with_no_active_is_noop() {
948        let mut shell = AppShell::new(ShellConfig::default());
949        shell.registry.register(Box::new(StubScreen::new("a", "A")));
950        shell.next_screen();
951        assert!(shell.active_screen.is_none());
952    }
953
954    #[test]
955    fn prev_screen_with_no_active_is_noop() {
956        let mut shell = AppShell::new(ShellConfig::default());
957        shell.registry.register(Box::new(StubScreen::new("a", "A")));
958        shell.prev_screen();
959        assert!(shell.active_screen.is_none());
960    }
961
962    #[test]
963    fn screen_context_with_no_active_uses_empty_id() {
964        let shell = AppShell::new(ShellConfig::default());
965        let ctx = shell.screen_context(Rect::new(0, 0, 100, 50));
966        assert_eq!(ctx.active_screen, ScreenId::new(""));
967        assert_eq!(ctx.terminal_width, 100);
968        assert_eq!(ctx.terminal_height, 50);
969        assert!(ctx.focused);
970    }
971
972    #[test]
973    fn screen_context_with_active_screen() {
974        let mut shell = AppShell::new(ShellConfig::default());
975        shell
976            .registry
977            .register(Box::new(StubScreen::new("s1", "S1")));
978        shell.navigate_to(&ScreenId::new("s1"));
979        let ctx = shell.screen_context(Rect::new(0, 0, 80, 24));
980        assert_eq!(ctx.active_screen, ScreenId::new("s1"));
981    }
982
983    #[test]
984    fn last_palette_action_initially_none() {
985        let shell = AppShell::new(ShellConfig::default());
986        assert!(shell.last_palette_action().is_none());
987    }
988
989    #[test]
990    fn palette_backspace_removes_char() {
991        let mut shell = AppShell::new(ShellConfig::default());
992        // Open palette
993        let open = InputEvent::Key(
994            ftui_core::event::KeyCode::Char('p'),
995            ftui_core::event::Modifiers::CTRL,
996        );
997        let _ = shell.handle_input(&open);
998
999        // Type "ab"
1000        let a = InputEvent::Key(
1001            ftui_core::event::KeyCode::Char('a'),
1002            ftui_core::event::Modifiers::NONE,
1003        );
1004        let b = InputEvent::Key(
1005            ftui_core::event::KeyCode::Char('b'),
1006            ftui_core::event::Modifiers::NONE,
1007        );
1008        let _ = shell.handle_input(&a);
1009        let _ = shell.handle_input(&b);
1010        assert_eq!(shell.palette.query(), "ab");
1011
1012        // Backspace
1013        let bs = InputEvent::Key(
1014            ftui_core::event::KeyCode::Backspace,
1015            ftui_core::event::Modifiers::NONE,
1016        );
1017        let _ = shell.handle_input(&bs);
1018        assert_eq!(shell.palette.query(), "a");
1019    }
1020
1021    #[test]
1022    fn palette_esc_closes() {
1023        let mut shell = AppShell::new(ShellConfig::default());
1024        let open = InputEvent::Key(
1025            ftui_core::event::KeyCode::Char('p'),
1026            ftui_core::event::Modifiers::CTRL,
1027        );
1028        let _ = shell.handle_input(&open);
1029        assert_eq!(shell.palette.state(), &PaletteState::Open);
1030
1031        let esc = InputEvent::Key(
1032            ftui_core::event::KeyCode::Escape,
1033            ftui_core::event::Modifiers::NONE,
1034        );
1035        let _ = shell.handle_input(&esc);
1036        assert_eq!(shell.palette.state(), &PaletteState::Closed);
1037    }
1038
1039    #[test]
1040    fn palette_enter_closes_and_clears() {
1041        let mut shell = AppShell::new(ShellConfig::default());
1042        let open = InputEvent::Key(
1043            ftui_core::event::KeyCode::Char('p'),
1044            ftui_core::event::Modifiers::CTRL,
1045        );
1046        let _ = shell.handle_input(&open);
1047        assert_eq!(shell.palette.state(), &PaletteState::Open);
1048
1049        let enter = InputEvent::Key(
1050            ftui_core::event::KeyCode::Enter,
1051            ftui_core::event::Modifiers::NONE,
1052        );
1053        let quit = shell.handle_input(&enter);
1054        assert!(!quit);
1055        assert_eq!(shell.palette.state(), &PaletteState::Closed);
1056    }
1057
1058    #[test]
1059    fn overlay_esc_dismisses() {
1060        let mut shell = AppShell::new(ShellConfig::default());
1061        shell.last_render_area.set(Rect::new(0, 0, 80, 24));
1062
1063        // Open help overlay with '?'
1064        let help = InputEvent::Key(
1065            ftui_core::event::KeyCode::Char('?'),
1066            ftui_core::event::Modifiers::NONE,
1067        );
1068        let _ = shell.handle_input(&help);
1069        assert!(shell.overlays.has_active());
1070
1071        // Dismiss with Esc
1072        let esc = InputEvent::Key(
1073            ftui_core::event::KeyCode::Escape,
1074            ftui_core::event::Modifiers::NONE,
1075        );
1076        let _ = shell.handle_input(&esc);
1077        assert!(!shell.overlays.has_active());
1078    }
1079
1080    #[test]
1081    fn help_opens_with_question_mark() {
1082        let mut shell = AppShell::new(ShellConfig::default());
1083        shell.last_render_area.set(Rect::new(0, 0, 80, 24));
1084
1085        let help = InputEvent::Key(
1086            ftui_core::event::KeyCode::Char('?'),
1087            ftui_core::event::Modifiers::NONE,
1088        );
1089
1090        let _ = shell.handle_input(&help);
1091        assert!(shell.overlays.has_active());
1092
1093        // When overlay is active, non-Dismiss keys are swallowed
1094        let _ = shell.handle_input(&help);
1095        assert!(
1096            shell.overlays.has_active(),
1097            "overlay stays open on repeat ?"
1098        );
1099    }
1100
1101    #[test]
1102    fn tab_key_triggers_next_screen() {
1103        let mut shell = AppShell::new(ShellConfig::default());
1104        shell.last_render_area.set(Rect::new(0, 0, 80, 24));
1105        shell.registry.register(Box::new(StubScreen::new("a", "A")));
1106        shell.registry.register(Box::new(StubScreen::new("b", "B")));
1107        shell.navigate_to(&ScreenId::new("a"));
1108
1109        let tab = InputEvent::Key(
1110            ftui_core::event::KeyCode::Tab,
1111            ftui_core::event::Modifiers::NONE,
1112        );
1113        let _ = shell.handle_input(&tab);
1114        assert_eq!(shell.active_screen.as_ref(), Some(&ScreenId::new("b")));
1115    }
1116
1117    #[test]
1118    fn shift_tab_triggers_prev_screen() {
1119        let mut shell = AppShell::new(ShellConfig::default());
1120        shell.last_render_area.set(Rect::new(0, 0, 80, 24));
1121        shell.registry.register(Box::new(StubScreen::new("a", "A")));
1122        shell.registry.register(Box::new(StubScreen::new("b", "B")));
1123        shell.navigate_to(&ScreenId::new("b"));
1124
1125        let shift_tab = InputEvent::Key(
1126            ftui_core::event::KeyCode::BackTab,
1127            ftui_core::event::Modifiers::SHIFT,
1128        );
1129        let _ = shell.handle_input(&shift_tab);
1130        assert_eq!(shell.active_screen.as_ref(), Some(&ScreenId::new("a")));
1131    }
1132
1133    #[test]
1134    fn overlay_active_blocks_screen_input() {
1135        let mut shell = AppShell::new(ShellConfig::default());
1136        shell.last_render_area.set(Rect::new(0, 0, 80, 24));
1137        let captured = Arc::new(Mutex::new(None));
1138        shell
1139            .registry
1140            .register(Box::new(CaptureContextScreen::new("cap", captured.clone())));
1141        shell.navigate_to(&ScreenId::new("cap"));
1142
1143        // Open overlay
1144        let help = InputEvent::Key(
1145            ftui_core::event::KeyCode::Char('?'),
1146            ftui_core::event::Modifiers::NONE,
1147        );
1148        let _ = shell.handle_input(&help);
1149        assert!(shell.overlays.has_active());
1150
1151        // Send a key that would normally reach the screen
1152        let x = InputEvent::Key(
1153            ftui_core::event::KeyCode::Char('x'),
1154            ftui_core::event::Modifiers::NONE,
1155        );
1156        let _ = shell.handle_input(&x);
1157        // Screen should NOT have received the event
1158        assert!(captured.lock().unwrap().is_none());
1159    }
1160
1161    #[test]
1162    fn palette_ctrl_char_not_inserted() {
1163        let mut shell = AppShell::new(ShellConfig::default());
1164        let open = InputEvent::Key(
1165            ftui_core::event::KeyCode::Char('p'),
1166            ftui_core::event::Modifiers::CTRL,
1167        );
1168        let _ = shell.handle_input(&open);
1169
1170        // Ctrl+A should NOT be inserted as text
1171        let ctrl_a = InputEvent::Key(
1172            ftui_core::event::KeyCode::Char('a'),
1173            ftui_core::event::Modifiers::CTRL,
1174        );
1175        let _ = shell.handle_input(&ctrl_a);
1176        assert_eq!(shell.palette.query(), "");
1177    }
1178
1179    #[test]
1180    fn palette_alt_char_not_inserted() {
1181        let mut shell = AppShell::new(ShellConfig::default());
1182        let open = InputEvent::Key(
1183            ftui_core::event::KeyCode::Char('p'),
1184            ftui_core::event::Modifiers::CTRL,
1185        );
1186        let _ = shell.handle_input(&open);
1187
1188        let alt_x = InputEvent::Key(
1189            ftui_core::event::KeyCode::Char('x'),
1190            ftui_core::event::Modifiers::ALT,
1191        );
1192        let _ = shell.handle_input(&alt_x);
1193        assert_eq!(shell.palette.query(), "");
1194    }
1195
1196    #[test]
1197    fn handle_input_does_not_quit_on_non_quit_key() {
1198        let mut shell = AppShell::new(ShellConfig::default());
1199        shell.last_render_area.set(Rect::new(0, 0, 80, 24));
1200        let event = InputEvent::Key(
1201            ftui_core::event::KeyCode::Char('a'),
1202            ftui_core::event::Modifiers::NONE,
1203        );
1204        let quit = shell.handle_input(&event);
1205        assert!(!quit);
1206        assert!(!shell.should_quit);
1207    }
1208
1209    // ─── bd-n6x8 tests end ───
1210}