Skip to main content

telex/
lib.rs

1//! Telex - A DX-first TUI framework for Rust.
2//!
3//! Build terminal apps that feel good to write.
4
5// =============================================================================
6// API Versioning
7// =============================================================================
8
9/// Current API major version.
10/// For 0.x releases, minor version bumps may contain breaking changes.
11pub const API_VERSION_MAJOR: u32 = 0;
12
13/// Current API minor version.
14pub const API_VERSION_MINOR: u32 = 1;
15
16/// Current API patch version.
17pub const API_VERSION_PATCH: u32 = 0;
18
19/// Check that your code is compatible with the current Telex API version.
20///
21/// For pre-1.0 versions (0.x.y), this requires an exact major.minor match,
22/// since breaking changes can occur on minor version bumps.
23///
24/// For 1.0+ versions, this requires the same major version and that your
25/// required minor version is not newer than the library's minor version.
26///
27/// # Example
28/// ```rust,ignore
29/// use telex::prelude::*;
30///
31/// telex::require_api!(0, 1);  // Requires API version 0.1.x
32///
33/// fn main() {
34///     telex::run(App).unwrap();
35/// }
36/// ```
37///
38/// If the version doesn't match, you'll get a compile-time error with
39/// guidance on how to migrate.
40#[macro_export]
41macro_rules! require_api {
42    ($major:literal, $minor:literal) => {
43        const _: () = {
44            // For 0.x versions, require exact major.minor match (breaking changes on minor bumps)
45            // For 1.x+, require same major and compatible minor (required <= current)
46            if $crate::API_VERSION_MAJOR == 0 {
47                // Pre-1.0: exact match required
48                assert!(
49                    $major == $crate::API_VERSION_MAJOR && $minor == $crate::API_VERSION_MINOR,
50                    concat!(
51                        "Telex API version mismatch: this code requires ", $major, ".", $minor,
52                        " but the library is version ",
53                        env!("CARGO_PKG_VERSION"),
54                        ". See https://docs.rs/telex for migration guides."
55                    )
56                );
57            } else {
58                // Post-1.0: same major, compatible minor
59                assert!(
60                    $major == $crate::API_VERSION_MAJOR,
61                    concat!(
62                        "Telex API major version mismatch: this code requires major version ", $major,
63                        " but the library is version ",
64                        env!("CARGO_PKG_VERSION"),
65                        ". This is a breaking change - see https://docs.rs/telex for migration guides."
66                    )
67                );
68                assert!(
69                    $minor <= $crate::API_VERSION_MINOR,
70                    concat!(
71                        "Telex API minor version too new: this code requires ", $major, ".", $minor,
72                        " but the library is version ",
73                        env!("CARGO_PKG_VERSION"),
74                        ". Please upgrade the telex dependency in your Cargo.toml."
75                    )
76                );
77            }
78        };
79    };
80}
81
82// =============================================================================
83// Modules
84// =============================================================================
85
86mod async_state;
87mod buffer;
88pub mod canvas;
89mod command;
90pub mod command_system;
91mod component;
92mod context;
93mod focus;
94pub mod form;
95pub mod image;
96pub mod markdown;
97mod render;
98mod scope;
99mod state;
100mod stream_state;
101mod terminal;
102mod terminal_state;
103pub mod testing;
104pub mod text;
105pub mod theme;
106pub mod toast;
107mod view;
108
109pub mod prelude;
110
111pub use async_state::Async;
112pub use command::KeyBinding;
113pub use component::Component;
114pub use scope::Scope;
115pub use state::State;
116pub use stream_state::{StreamHandle, StreamState, TextStreamHandle};
117pub use telex_macro::{effect, effect_once, state, view, with};
118pub use terminal::Terminal;
119pub use terminal_state::{TerminalBuffer, TerminalHandle};
120pub use view::{
121    Align, BoxBuilder, BoxNode, ButtonBuilder, ButtonNode, Callback, CanvasBuilder, CanvasNode,
122    ChangeCallback, CheckboxBuilder, CheckboxNode, ColumnWidth, CommandCallback,
123    CommandPaletteBuilder, CommandPaletteNode, FormBuilder, FormFieldBuilder, FormFieldNode,
124    FormNode, FormSubmitCallback, HStackBuilder, HStackNode, ImageBuilder, ImageNode, Justify,
125    LayoutMode, ListBuilder, ListNode, Menu, MenuBarBuilder, MenuBarNode, MenuItemNode,
126    ModalBuilder, ModalNode, Orientation, PaletteCommand, RadioGroupBuilder, RadioGroupNode,
127    SelectCallback, SpacerNode, SplitBuilder, SplitNode, TabPosition, TableBuilder, TableColumn,
128    TableNode, TabsBuilder, TabsNode, TextAlign, TextAreaBuilder, TextAreaNode, TextBuilder,
129    TextInputBuilder, TextInputNode, TextNode, TerminalBuilder, TerminalNode,
130    ToastContainerBuilder, ToastContainerNode, ToastItem, ToastLevelView, ToastPosition,
131    ToggleCallback, TreeActivateCallback, TreeBuilder, TreeItem, TreeNode, TreePath,
132    TreeSelectCallback, VStackBuilder, VStackNode, View,
133};
134
135// Re-export canvas types for pixel-level drawing
136pub use canvas::{animated_canvas, AnimatedCanvasBuilder, DrawContext, PixelBuffer};
137
138// Re-export image types
139pub use image::ImageSource;
140
141// Re-export crossterm types needed for event handling and styling
142pub use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
143pub use crossterm::style::Color;
144
145use command::CommandRegistry;
146use context::ContextStorage;
147use focus::FocusManager;
148use scope::StateStorage;
149use std::io::Result;
150use std::panic;
151use std::rc::Rc;
152use theme::Theme;
153
154/// Check if any modal is visible in the view tree.
155fn has_visible_modal(view: &View) -> bool {
156    match view {
157        View::Modal(node) => node.visible,
158        View::VStack(node) => node.children.iter().any(has_visible_modal),
159        View::HStack(node) => node.children.iter().any(has_visible_modal),
160        View::Box(node) => node
161            .child
162            .as_ref()
163            .map(|c| has_visible_modal(c))
164            .unwrap_or(false),
165        View::Split(node) => has_visible_modal(&node.first) || has_visible_modal(&node.second),
166        View::Tabs(node) => node.children.iter().any(has_visible_modal),
167        _ => false,
168    }
169}
170
171/// Check if any command palette is visible in the view tree.
172fn has_visible_command_palette(view: &View) -> bool {
173    match view {
174        View::CommandPalette(node) => node.visible,
175        View::VStack(node) => node.children.iter().any(has_visible_command_palette),
176        View::HStack(node) => node.children.iter().any(has_visible_command_palette),
177        View::Box(node) => node
178            .child
179            .as_ref()
180            .map(|c| has_visible_command_palette(c))
181            .unwrap_or(false),
182        View::Split(node) => {
183            has_visible_command_palette(&node.first) || has_visible_command_palette(&node.second)
184        }
185        View::Tabs(node) => node.children.iter().any(has_visible_command_palette),
186        _ => false,
187    }
188}
189
190/// Call the dismiss callback on visible command palettes.
191fn call_command_palette_dismiss(view: &View) {
192    match view {
193        View::CommandPalette(node) => {
194            if node.visible {
195                if let Some(callback) = &node.on_dismiss {
196                    callback();
197                }
198            }
199        }
200        View::VStack(node) => {
201            for child in &node.children {
202                call_command_palette_dismiss(child);
203            }
204        }
205        View::HStack(node) => {
206            for child in &node.children {
207                call_command_palette_dismiss(child);
208            }
209        }
210        View::Box(node) => {
211            if let Some(child) = &node.child {
212                call_command_palette_dismiss(child);
213            }
214        }
215        View::Split(node) => {
216            call_command_palette_dismiss(&node.first);
217            call_command_palette_dismiss(&node.second);
218        }
219        View::Tabs(node) => {
220            for child in &node.children {
221                call_command_palette_dismiss(child);
222            }
223        }
224        _ => {}
225    }
226}
227
228/// Find visible modals in the view tree and call their on_dismiss callbacks.
229fn call_modal_dismiss(view: &View) {
230    match view {
231        View::Modal(node) => {
232            if node.visible {
233                if let Some(callback) = &node.on_dismiss {
234                    callback();
235                }
236            }
237        }
238        View::VStack(node) => {
239            for child in &node.children {
240                call_modal_dismiss(child);
241            }
242        }
243        View::HStack(node) => {
244            for child in &node.children {
245                call_modal_dismiss(child);
246            }
247        }
248        View::Box(node) => {
249            if let Some(child) = &node.child {
250                call_modal_dismiss(child);
251            }
252        }
253        View::Split(node) => {
254            call_modal_dismiss(&node.first);
255            call_modal_dismiss(&node.second);
256        }
257        View::Tabs(node) => {
258            for child in &node.children {
259                call_modal_dismiss(child);
260            }
261        }
262        _ => {}
263    }
264}
265
266/// Check if debug mode is enabled via TELEX_DEBUG environment variable.
267pub fn is_debug_mode() -> bool {
268    std::env::var("TELEX_DEBUG")
269        .map(|v| v == "1" || v == "true")
270        .unwrap_or(false)
271}
272
273/// Run the application with the given root component and theme.
274///
275/// # Example
276/// ```rust,no_run
277/// use telex::prelude::*;
278/// use telex::theme::Theme;
279///
280/// telex::run_with_theme(
281///     |cx| view! { <Text>"Hello, Telex!"</Text> },
282///     Theme::nord(),
283/// ).unwrap();
284/// ```
285pub fn run_with_theme<C: Component>(root: C, theme: Theme) -> Result<()> {
286    theme::set_theme(theme);
287    run(root)
288}
289
290/// Run the application with the given root component.
291///
292/// This is the main entry point for Telex applications.
293///
294/// # Example
295/// ```rust,no_run
296/// use telex::prelude::*;
297///
298/// telex::run(|cx| view! { <Text>"Hello, Telex!"</Text> }).unwrap();
299/// ```
300///
301/// # Debug Mode
302/// Set `TELEX_DEBUG=1` to enable debug mode, which shows render timing
303/// and focus information.
304pub fn run<C: Component>(root: C) -> Result<()> {
305    // Set up custom panic handler to restore terminal on panic
306    let default_hook = panic::take_hook();
307    panic::set_hook(Box::new(move |panic_info| {
308        // Try to restore terminal state
309        let _ = crossterm::terminal::disable_raw_mode();
310        let _ = crossterm::execute!(
311            std::io::stdout(),
312            crossterm::terminal::LeaveAlternateScreen,
313            crossterm::cursor::Show
314        );
315
316        // Print a helpful error message
317        eprintln!("\n┌─ Telex Panic ─────────────────────────────────────────────────┐");
318        eprintln!("│                                                              │");
319
320        // Extract panic message
321        let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
322            s.to_string()
323        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
324            s.clone()
325        } else {
326            "Unknown panic".to_string()
327        };
328
329        // Word wrap the message
330        for line in message.lines() {
331            let chunks: Vec<&str> = line
332                .as_bytes()
333                .chunks(58)
334                .map(|c| std::str::from_utf8(c).unwrap_or(""))
335                .collect();
336            for chunk in chunks {
337                eprintln!("│  {:<58}│", chunk);
338            }
339        }
340
341        eprintln!("│                                                              │");
342
343        // Print location if available
344        if let Some(location) = panic_info.location() {
345            eprintln!(
346                "│  Location: {}:{}:{:<25}│",
347                location.file().split('/').next_back().unwrap_or(location.file()),
348                location.line(),
349                location.column()
350            );
351        }
352
353        eprintln!("│                                                              │");
354        eprintln!("│  Tip: Check your hook order - hooks must be called          │");
355        eprintln!("│  unconditionally in the same order every render.            │");
356        eprintln!("│                                                              │");
357        eprintln!("└──────────────────────────────────────────────────────────────┘\n");
358
359        // Call default hook for stack trace
360        default_hook(panic_info);
361    }));
362
363    let mut terminal = Terminal::new()?;
364    let mut focus = FocusManager::new();
365    let storage = Rc::new(StateStorage::new());
366    let commands = Rc::new(CommandRegistry::new());
367    let context = Rc::new(ContextStorage::new());
368    let debug_mode = is_debug_mode();
369
370    let mut frame_count = 0u64;
371
372    loop {
373        let render_start = std::time::Instant::now();
374
375        // Decay effect cycle counter (sliding window for infinite loop detection)
376        storage.decay_effect_counter();
377
378        // Poll terminal output (before rendering, so we pick up any new data)
379        focus.poll_terminals();
380
381        // Clear command registry before each render
382        commands.clear();
383
384        // Create scope with existing storage, command registry, and context
385        let cx = Scope::with_all(
386            Rc::clone(&storage),
387            Rc::clone(&commands),
388            Rc::clone(&context),
389        );
390
391        // Render the view
392        let view = root.render(cx);
393
394        // Collect focusables for navigation
395        focus.collect_focusables(&view);
396
397        // Set default wrap width for text areas based on terminal width
398        // (subtract 2 for TextArea borders)
399        if let Ok((term_width, _)) = crossterm::terminal::size() {
400            focus.set_default_textarea_wrap_width(term_width.saturating_sub(2));
401        }
402
403        let render_time = render_start.elapsed();
404        frame_count += 1;
405
406        // Get scroll and cursor offsets for all focusables
407        let scroll_offsets: Vec<(u16, u16)> = (0..focus.focus_index() + 10)
408            .map(|i| focus.scroll_offset(i))
409            .collect();
410        let cursor_offsets: Vec<usize> = (0..focus.focus_index() + 10)
411            .map(|i| focus.cursor_offset(i))
412            .collect();
413
414        // Check if modal is visible for render context
415        let modal_visible = has_visible_modal(&view);
416
417        // Draw with focus and scroll info, get back clamped offsets
418        let clamped_offsets = terminal.draw(
419            &view,
420            focus.focus_index(),
421            focus.is_focus_visible(),
422            scroll_offsets,
423            cursor_offsets,
424            modal_visible,
425        )?;
426        focus.update_scroll_states(&clamped_offsets);
427
428        // Draw debug info if enabled
429        if debug_mode {
430            terminal.draw_debug(
431                frame_count,
432                render_time.as_micros() as u64,
433                focus.focus_index(),
434                focus.focusable_count(),
435            )?;
436        }
437
438        // Run pending effects (after render, before input handling)
439        // If effects ran and potentially modified state, re-render once
440        if storage.flush_effects() {
441            // Effects ran - re-render to show any state changes they made
442            // Only do this once per frame to prevent infinite loops from use_effect
443            storage.reset_index();
444            let cx = Scope::with_all(
445                Rc::clone(&storage),
446                Rc::clone(&commands),
447                Rc::clone(&context),
448            );
449            let view = root.render(cx);
450            focus.collect_focusables(&view);
451            let scroll_offsets: Vec<(u16, u16)> = (0..focus.focus_index() + 10)
452                .map(|i| focus.scroll_offset(i))
453                .collect();
454            let cursor_offsets: Vec<usize> = (0..focus.focus_index() + 10)
455                .map(|i| focus.cursor_offset(i))
456                .collect();
457            let modal_visible = has_visible_modal(&view);
458            let clamped_offsets = terminal.draw(
459                &view,
460                focus.focus_index(),
461                focus.is_focus_visible(),
462                scroll_offsets,
463                cursor_offsets,
464                modal_visible,
465            )?;
466            focus.update_scroll_states(&clamped_offsets);
467            // Don't flush effects again - just one re-render per frame
468        }
469
470        // For now, use a generous max_scroll to allow scrolling
471        // TODO: Calculate actual content height for focused scrollable
472        let max_scroll = 100u16;
473        let viewport_height = terminal.height().saturating_sub(6); // Approximate visible rows
474
475        // Handle input
476        if let Some(event) = terminal.poll_event()? {
477            // Handle resize - just continue to trigger redraw
478            if let Event::Resize(_, _) = event {
479                continue;
480            }
481
482            if let Event::Key(key) = event {
483                // Check if a modal is visible - if so, only allow Escape
484                let modal_visible = has_visible_modal(&view);
485                let palette_visible = has_visible_command_palette(&view);
486
487                // When modal is visible, Escape dismisses it
488                // Other keys work normally (focus is scoped to modal content)
489                if modal_visible && key.code == KeyCode::Esc && key.modifiers == KeyModifiers::NONE
490                {
491                    call_modal_dismiss(&view);
492                    continue;
493                }
494
495                // Handle command palette input when visible
496                if palette_visible {
497                    match (key.modifiers, key.code) {
498                        (KeyModifiers::NONE, KeyCode::Esc) => {
499                            call_command_palette_dismiss(&view);
500                        }
501                        (KeyModifiers::NONE, KeyCode::Enter) => {
502                            if focus.is_focused_command_palette() {
503                                focus.command_palette_execute();
504                            }
505                        }
506                        (KeyModifiers::NONE, KeyCode::Up) => {
507                            // Navigate up in palette - handled by state in component
508                        }
509                        (KeyModifiers::NONE, KeyCode::Down) => {
510                            // Navigate down in palette - handled by state in component
511                        }
512                        (KeyModifiers::NONE, KeyCode::Backspace) => {
513                            if focus.is_focused_command_palette() {
514                                focus.command_palette_backspace();
515                            }
516                        }
517                        (KeyModifiers::NONE, KeyCode::Char(c)) => {
518                            if focus.is_focused_command_palette() {
519                                focus.command_palette_key(c);
520                            }
521                        }
522                        (KeyModifiers::SHIFT, KeyCode::Char(c)) => {
523                            if focus.is_focused_command_palette() {
524                                focus.command_palette_key(c.to_ascii_uppercase());
525                            }
526                        }
527                        _ => {}
528                    }
529                    continue;
530                }
531
532                // Escape closes open menu bar dropdowns
533                if key.code == KeyCode::Esc && key.modifiers == KeyModifiers::NONE
534                    && focus.is_focused_menu_bar() && focus.menu_bar_has_open_menu() {
535                    focus.menu_bar_close();
536                    continue;
537                }
538
539                // First, try user-registered commands
540                if commands.execute(key.code, key.modifiers) {
541                    continue;
542                }
543
544                match (key.modifiers, key.code) {
545                    // Ctrl+Q to quit (but not Ctrl+C, as that should pass through to terminal)
546                    (m, KeyCode::Char('q')) if m.contains(KeyModifiers::CONTROL) => {
547                        break;
548                    }
549                    // Ctrl+Shift+[ to escape terminal focus
550                    (m, KeyCode::Char('['))
551                        if m.contains(KeyModifiers::CONTROL)
552                            && m.contains(KeyModifiers::SHIFT) =>
553                    {
554                        if focus.is_focused_terminal() {
555                            focus.focus_next();
556                        }
557                    }
558                    // Terminal passthrough - send all keys to terminal if focused
559                    _ if focus.is_focused_terminal() => {
560                        if let Err(e) = focus.terminal_key(key) {
561                            eprintln!("Terminal input error: {}", e);
562                        }
563                    }
564                    // Tab to focus next
565                    (KeyModifiers::NONE, KeyCode::Tab) => {
566                        focus.focus_next();
567                    }
568                    // Shift+Tab to focus previous
569                    (KeyModifiers::SHIFT, KeyCode::BackTab) => {
570                        focus.focus_prev();
571                    }
572                    // Enter or Space to activate (for buttons, checkboxes, tree, table, menu bar)
573                    (KeyModifiers::NONE, KeyCode::Enter | KeyCode::Char(' ')) => {
574                        if focus.is_focused_text_area() {
575                            if key.code == KeyCode::Enter {
576                                focus.text_area_enter();
577                            } else {
578                                focus.text_area_key(' ');
579                            }
580                        } else if focus.is_focused_text_input() {
581                            if key.code == KeyCode::Enter {
582                                // Enter in text input submits
583                                focus.text_input_submit();
584                            } else {
585                                // Space in text input adds a space
586                                focus.text_input_key(' ');
587                            }
588                        } else if focus.is_focused_tree() {
589                            focus.tree_activate();
590                        } else if focus.is_focused_table() {
591                            focus.table_activate();
592                        } else if focus.is_focused_menu_bar() {
593                            if focus.menu_bar_has_open_menu() {
594                                // Execute selected item
595                                focus.menu_bar_execute();
596                            } else {
597                                // Open first menu
598                                focus.menu_bar_open();
599                            }
600                        } else {
601                            focus.activate();
602                        }
603                    }
604                    // Backspace for text input/area/form field
605                    (KeyModifiers::NONE, KeyCode::Backspace) => {
606                        if focus.is_focused_text_input() {
607                            focus.text_input_backspace();
608                        } else if focus.is_focused_text_area() {
609                            focus.text_area_backspace();
610                        } else if focus.is_focused_form_field() {
611                            focus.form_field_backspace();
612                        }
613                    }
614                    // Arrow keys for scrolling (when focused on scrollable) or list/tree/table/radio/textarea/menu/text input navigation
615                    (KeyModifiers::NONE, KeyCode::Up) => {
616                        if focus.is_focused_text_input() {
617                            focus.text_input_key_up();
618                        } else if focus.is_focused_text_area() {
619                            focus.text_area_cursor_up();
620                        } else if focus.is_focused_menu_bar() && focus.menu_bar_has_open_menu() {
621                            focus.menu_bar_select_prev();
622                        } else if focus.is_focused_scrollable() {
623                            // For auto_scroll_bottom, Up means scroll away from bottom (increase offset)
624                            if focus.is_focused_auto_scroll_bottom() {
625                                focus.scroll_down(1, max_scroll);
626                            } else {
627                                focus.scroll_up(1);
628                            }
629                        } else if focus.is_focused_list() {
630                            focus.list_select_prev();
631                        } else if focus.is_focused_tree() {
632                            focus.tree_select_prev();
633                        } else if focus.is_focused_table() {
634                            focus.table_select_prev();
635                        } else if focus.is_focused_radio_group() {
636                            focus.radio_group_select_prev();
637                        }
638                    }
639                    (KeyModifiers::NONE, KeyCode::Down) => {
640                        if focus.is_focused_text_input() {
641                            focus.text_input_key_down();
642                        } else if focus.is_focused_text_area() {
643                            focus.text_area_cursor_down();
644                        } else if focus.is_focused_menu_bar() && focus.menu_bar_has_open_menu() {
645                            focus.menu_bar_select_next();
646                        } else if focus.is_focused_scrollable() {
647                            // For auto_scroll_bottom, Down means scroll toward bottom (decrease offset)
648                            if focus.is_focused_auto_scroll_bottom() {
649                                focus.scroll_up(1);
650                            } else {
651                                focus.scroll_down(1, max_scroll);
652                            }
653                        } else if focus.is_focused_list() {
654                            focus.list_select_next();
655                        } else if focus.is_focused_tree() {
656                            focus.tree_select_next();
657                        } else if focus.is_focused_table() {
658                            focus.table_select_next();
659                        } else if focus.is_focused_radio_group() {
660                            focus.radio_group_select_next();
661                        }
662                    }
663                    // Page Up/Down
664                    (KeyModifiers::NONE, KeyCode::PageUp) => {
665                        if focus.is_focused_scrollable() {
666                            if focus.is_focused_auto_scroll_bottom() {
667                                focus.scroll_down(viewport_height, max_scroll);
668                            } else {
669                                focus.scroll_up(viewport_height);
670                            }
671                        }
672                    }
673                    (KeyModifiers::NONE, KeyCode::PageDown) => {
674                        if focus.is_focused_scrollable() {
675                            if focus.is_focused_auto_scroll_bottom() {
676                                focus.scroll_up(viewport_height);
677                            } else {
678                                focus.scroll_down(viewport_height, max_scroll);
679                            }
680                        }
681                    }
682                    // Home/End
683                    (KeyModifiers::NONE, KeyCode::Home) => {
684                        if focus.is_focused_scrollable() {
685                            // For auto_scroll_bottom, Home goes to top (max offset from bottom)
686                            if focus.is_focused_auto_scroll_bottom() {
687                                focus.scroll_end(max_scroll);
688                            } else {
689                                focus.scroll_home();
690                            }
691                        }
692                    }
693                    (KeyModifiers::NONE, KeyCode::End) => {
694                        if focus.is_focused_scrollable() {
695                            // For auto_scroll_bottom, End goes to bottom (zero offset)
696                            if focus.is_focused_auto_scroll_bottom() {
697                                focus.scroll_home();
698                            } else {
699                                focus.scroll_end(max_scroll);
700                            }
701                        }
702                    }
703                    // Left/Right arrows for text inputs, text areas, tabs, tree, and menu bar
704                    (KeyModifiers::NONE, KeyCode::Left) => {
705                        if focus.is_focused_text_input() {
706                            focus.text_input_cursor_left();
707                        } else if focus.is_focused_text_area() {
708                            focus.text_area_cursor_left();
709                        } else if focus.is_focused_menu_bar() {
710                            if focus.menu_bar_has_open_menu() {
711                                focus.menu_bar_prev();
712                            } else {
713                                focus.menu_bar_highlight_prev();
714                            }
715                        } else if focus.is_focused_tabs() {
716                            focus.tabs_select_prev();
717                        } else if focus.is_focused_tree() {
718                            // Left triggers activate (app should collapse)
719                            focus.tree_activate();
720                        }
721                    }
722                    (KeyModifiers::NONE, KeyCode::Right) => {
723                        if focus.is_focused_text_input() {
724                            focus.text_input_cursor_right();
725                        } else if focus.is_focused_text_area() {
726                            focus.text_area_cursor_right();
727                        } else if focus.is_focused_menu_bar() {
728                            if focus.menu_bar_has_open_menu() {
729                                focus.menu_bar_next();
730                            } else {
731                                focus.menu_bar_highlight_next();
732                            }
733                        } else if focus.is_focused_tabs() {
734                            focus.tabs_select_next();
735                        } else if focus.is_focused_tree() {
736                            // Right triggers activate (app should expand)
737                            focus.tree_activate();
738                        }
739                    }
740                    // Character input for text fields, tabs, tree, and form fields
741                    (KeyModifiers::NONE, KeyCode::Char(c)) => {
742                        if focus.is_focused_text_input() {
743                            focus.text_input_key(c);
744                        } else if focus.is_focused_text_area() {
745                            focus.text_area_key(c);
746                        } else if focus.is_focused_form_field() {
747                            focus.form_field_key(c);
748                        } else if focus.is_focused_tabs() {
749                            // Handle [ ] for tab cycling and 1-9 for direct selection
750                            match c {
751                                '[' => focus.tabs_select_prev(),
752                                ']' => focus.tabs_select_next(),
753                                '1'..='9' => {
754                                    let idx = (c as usize) - ('1' as usize);
755                                    focus.tabs_select(idx);
756                                }
757                                _ => {}
758                            }
759                        } else if focus.is_focused_tree() {
760                            // j/k for vim-style navigation, space for activate
761                            match c {
762                                'j' => focus.tree_select_next(),
763                                'k' => focus.tree_select_prev(),
764                                ' ' => focus.tree_activate(),
765                                _ => {}
766                            }
767                        } else if focus.is_focused_table() {
768                            // j/k for vim-style navigation
769                            match c {
770                                'j' => focus.table_select_next(),
771                                'k' => focus.table_select_prev(),
772                                _ => {}
773                            }
774                        } else if focus.is_focused_radio_group() {
775                            // j/k for vim-style navigation
776                            match c {
777                                'j' => focus.radio_group_select_next(),
778                                'k' => focus.radio_group_select_prev(),
779                                _ => {}
780                            }
781                        }
782                    }
783                    (KeyModifiers::SHIFT, KeyCode::Char(c)) => {
784                        if focus.is_focused_text_input() {
785                            focus.text_input_key(c.to_ascii_uppercase());
786                        } else if focus.is_focused_text_area() {
787                            focus.text_area_key(c.to_ascii_uppercase());
788                        } else if focus.is_focused_form_field() {
789                            focus.form_field_key(c.to_ascii_uppercase());
790                        }
791                    }
792                    _ => {}
793                }
794            }
795        }
796    }
797
798    // Run all effect cleanup functions before exiting
799    storage.cleanup_all_effects();
800
801    terminal.cleanup()?;
802    Ok(())
803}