1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ShellConfig {
34 pub title: String,
36 pub theme: Theme,
38 pub show_status_bar: bool,
40 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#[derive(Debug, Clone, Default)]
59pub struct StatusLine {
60 pub left: String,
62 pub center: String,
64 pub right: String,
66}
67
68impl StatusLine {
69 #[must_use]
71 pub fn new() -> Self {
72 Self::default()
73 }
74
75 #[must_use]
77 pub fn with_left(mut self, text: impl Into<String>) -> Self {
78 self.left = text.into();
79 self
80 }
81
82 #[must_use]
84 pub fn with_center(mut self, text: impl Into<String>) -> Self {
85 self.center = text.into();
86 self
87 }
88
89 #[must_use]
91 pub fn with_right(mut self, text: impl Into<String>) -> Self {
92 self.right = text.into();
93 self
94 }
95}
96
97pub struct AppShell {
101 pub config: ShellConfig,
103 pub registry: ScreenRegistry,
105 pub active_screen: Option<ScreenId>,
107 pub keymap: Keymap,
109 pub overlays: OverlayManager,
111 pub palette: CommandPalette,
113 pub status_line: StatusLine,
115 pub should_quit: bool,
117 last_palette_action: Option<String>,
119 last_render_area: Cell<Rect>,
121 cached_layout: CachedLayout,
123 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 #[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 pub fn navigate_to(&mut self, id: &ScreenId) {
176 if self.registry.get(id).is_some() {
177 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 self.active_screen = Some(id.clone());
186 if let Some(screen) = self.registry.get_mut(id) {
187 screen.on_focus();
188 }
189 self.cached_tabs.invalidate();
191 }
192 }
193
194 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 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 #[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 #[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 self.last_render_area.set(Rect::new(0, 0, 80, 24));
241 }
242
243 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 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 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 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 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 #[must_use]
378 pub fn last_palette_action(&self) -> Option<&str> {
379 self.last_palette_action.as_deref()
380 }
381
382 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 }