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 = 2;
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, 2);  // Requires API version 0.2.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;
87pub mod buffer;
88pub mod canvas;
89pub mod channel;
90mod command;
91pub mod command_system;
92mod component;
93mod context;
94mod focus;
95pub mod form;
96pub mod image;
97pub mod markdown;
98mod render;
99mod scope;
100mod state;
101mod stream_state;
102mod terminal;
103mod terminal_state;
104pub mod testing;
105pub mod text;
106pub mod theme;
107pub mod toast;
108mod view;
109pub mod widget;
110
111pub mod prelude;
112
113pub use async_state::Async;
114pub use channel::{ChannelDrain, ChannelHandle, PortHandle, WakingSender};
115pub use command::KeyBinding;
116pub use component::Component;
117pub use scope::Scope;
118pub use state::State;
119pub use stream_state::{StreamHandle, StreamState, TextStreamHandle};
120pub use telex_macro::{async_data, channel as channel_macro, effect, effect_once, interval, port, reducer, state, stream, terminal, text_stream, text_stream_with_restart, view, with};
121pub use terminal::Terminal;
122pub use terminal_state::{TerminalBuffer, TerminalHandle};
123pub use view::{
124    Align, BoxBuilder, BoxNode, ButtonBuilder, ButtonNode, Callback, CanvasBuilder, CanvasNode,
125    ChangeCallback, CheckboxBuilder, CheckboxNode, ColumnWidth, CommandCallback,
126    CommandPaletteBuilder, CommandPaletteNode, CustomNode, ErrorBoundaryBuilder,
127    ErrorBoundaryNode, FormBuilder, FormFieldBuilder, FormFieldNode, FormNode,
128    FormSubmitCallback, HStackBuilder, HStackNode, ImageBuilder, ImageNode, Justify, LayoutMode,
129    SliderBuilder, SliderCallback, SliderNode,
130    ListBuilder, ListNode, Menu, MenuBarBuilder, MenuBarNode, MenuItemNode, ModalBuilder,
131    ModalNode, Orientation, PaletteCommand, RadioGroupBuilder, RadioGroupNode, SelectCallback,
132    SpacerNode, SplitBuilder, SplitNode, TabPosition, TableBuilder, TableColumn, TableNode,
133    TabsBuilder, TabsNode, TextAlign, TextAreaBuilder, TextAreaNode, TextBuilder,
134    TextInputBuilder, TextInputNode, TextNode, TerminalBuilder, TerminalNode,
135    ToastContainerBuilder, ToastContainerNode, ToastItem, ToastLevelView, ToastPosition,
136    ToggleCallback, TreeActivateCallback, TreeBuilder, TreeItem, TreeNode, TreePath,
137    TreeSelectCallback, VStackBuilder, VStackNode, View,
138};
139
140// Re-export canvas types for pixel-level drawing
141pub use canvas::{animated_canvas, AnimatedCanvasBuilder, DrawContext, PixelBuffer};
142
143// Re-export image types
144pub use image::ImageSource;
145
146// Re-export crossterm types needed for event handling and styling
147pub use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
148pub use crossterm::style::Color;
149
150use command::CommandRegistry;
151use context::ContextStorage;
152use focus::FocusManager;
153use scope::StateStorage;
154use std::cell::Cell;
155use std::io::{self, Result};
156use std::panic;
157use std::rc::Rc;
158use std::sync::atomic::Ordering;
159use std::time::Duration;
160
161thread_local! {
162    /// When true, the panic hook skips terminal cleanup because the panic will
163    /// be caught by an error boundary's `catch_unwind`.
164    pub(crate) static IN_ERROR_BOUNDARY: Cell<bool> = const { Cell::new(false) };
165}
166use theme::Theme;
167
168/// Trait for providing input events to the run loop.
169///
170/// The default implementation (`CrosstermEventSource`) uses crossterm's real
171/// terminal. Tests can provide a mock implementation via `run_headless()`.
172pub trait EventSource {
173    /// Poll for an event with the given timeout.
174    /// Returns `Ok(Some(event))` if an event is available, `Ok(None)` on timeout.
175    fn poll_event(&self, timeout: Duration) -> io::Result<Option<Event>>;
176
177    /// Called after each frame is rendered. Default is a no-op.
178    /// The test event source uses this to capture the rendered buffer.
179    fn on_frame_rendered(&self, _terminal: &Terminal) {}
180}
181
182/// Event source that reads from the real terminal via crossterm.
183struct CrosstermEventSource;
184
185impl EventSource for CrosstermEventSource {
186    fn poll_event(&self, timeout: Duration) -> io::Result<Option<Event>> {
187        if crossterm::event::poll(timeout)? {
188            Ok(Some(crossterm::event::read()?))
189        } else {
190            Ok(None)
191        }
192    }
193}
194
195/// Check if any modal is visible in the view tree.
196fn has_visible_modal(view: &View) -> bool {
197    match view {
198        View::Modal(node) => node.visible,
199        View::VStack(node) => node.children.iter().any(has_visible_modal),
200        View::HStack(node) => node.children.iter().any(has_visible_modal),
201        View::Box(node) => node
202            .child
203            .as_ref()
204            .map(|c| has_visible_modal(c))
205            .unwrap_or(false),
206        View::Split(node) => has_visible_modal(&node.first) || has_visible_modal(&node.second),
207        View::Tabs(node) => node.children.iter().any(has_visible_modal),
208        View::ErrorBoundary(node) => has_visible_modal(&node.child),
209        _ => false,
210    }
211}
212
213/// Check if any command palette is visible in the view tree.
214fn has_visible_command_palette(view: &View) -> bool {
215    match view {
216        View::CommandPalette(node) => node.visible,
217        View::VStack(node) => node.children.iter().any(has_visible_command_palette),
218        View::HStack(node) => node.children.iter().any(has_visible_command_palette),
219        View::Box(node) => node
220            .child
221            .as_ref()
222            .map(|c| has_visible_command_palette(c))
223            .unwrap_or(false),
224        View::Split(node) => {
225            has_visible_command_palette(&node.first) || has_visible_command_palette(&node.second)
226        }
227        View::Tabs(node) => node.children.iter().any(has_visible_command_palette),
228        View::ErrorBoundary(node) => has_visible_command_palette(&node.child),
229        _ => false,
230    }
231}
232
233/// Call the dismiss callback on visible command palettes.
234fn call_command_palette_dismiss(view: &View) {
235    match view {
236        View::CommandPalette(node) => {
237            if node.visible {
238                if let Some(callback) = &node.on_dismiss {
239                    callback();
240                }
241            }
242        }
243        View::VStack(node) => {
244            for child in &node.children {
245                call_command_palette_dismiss(child);
246            }
247        }
248        View::HStack(node) => {
249            for child in &node.children {
250                call_command_palette_dismiss(child);
251            }
252        }
253        View::Box(node) => {
254            if let Some(child) = &node.child {
255                call_command_palette_dismiss(child);
256            }
257        }
258        View::Split(node) => {
259            call_command_palette_dismiss(&node.first);
260            call_command_palette_dismiss(&node.second);
261        }
262        View::Tabs(node) => {
263            for child in &node.children {
264                call_command_palette_dismiss(child);
265            }
266        }
267        View::ErrorBoundary(node) => {
268            call_command_palette_dismiss(&node.child);
269        }
270        _ => {}
271    }
272}
273
274/// Find visible modals in the view tree and call their on_dismiss callbacks.
275fn call_modal_dismiss(view: &View) {
276    match view {
277        View::Modal(node) => {
278            if node.visible {
279                if let Some(callback) = &node.on_dismiss {
280                    callback();
281                }
282            }
283        }
284        View::VStack(node) => {
285            for child in &node.children {
286                call_modal_dismiss(child);
287            }
288        }
289        View::HStack(node) => {
290            for child in &node.children {
291                call_modal_dismiss(child);
292            }
293        }
294        View::Box(node) => {
295            if let Some(child) = &node.child {
296                call_modal_dismiss(child);
297            }
298        }
299        View::Split(node) => {
300            call_modal_dismiss(&node.first);
301            call_modal_dismiss(&node.second);
302        }
303        View::Tabs(node) => {
304            for child in &node.children {
305                call_modal_dismiss(child);
306            }
307        }
308        View::ErrorBoundary(node) => {
309            call_modal_dismiss(&node.child);
310        }
311        _ => {}
312    }
313}
314
315/// Check if debug mode is enabled via TELEX_DEBUG environment variable.
316pub fn is_debug_mode() -> bool {
317    std::env::var("TELEX_DEBUG")
318        .map(|v| v == "1" || v == "true")
319        .unwrap_or(false)
320}
321
322/// Run the application with the given root component and theme.
323///
324/// # Example
325/// ```rust,no_run
326/// use telex::prelude::*;
327/// use telex::theme::Theme;
328///
329/// telex::run_with_theme(
330///     |cx| view! { <Text>"Hello, Telex!"</Text> },
331///     Theme::nord(),
332/// ).unwrap();
333/// ```
334pub fn run_with_theme<C: Component>(root: C, theme: Theme) -> Result<()> {
335    theme::set_theme(theme);
336    run(root)
337}
338
339/// Run the application with the given root component.
340///
341/// This is the main entry point for Telex applications.
342///
343/// # Example
344/// ```rust,no_run
345/// use telex::prelude::*;
346///
347/// telex::run(|cx| view! { <Text>"Hello, Telex!"</Text> }).unwrap();
348/// ```
349///
350/// # Debug Mode
351/// Set `TELEX_DEBUG=1` to enable debug mode, which shows render timing
352/// and focus information.
353pub fn run<C: Component>(root: C) -> Result<()> {
354    // Set up custom panic handler to restore terminal on panic
355    let default_hook = panic::take_hook();
356    panic::set_hook(Box::new(move |panic_info| {
357        // If we're inside an error boundary's catch_unwind, skip cleanup —
358        // the boundary will handle rendering the fallback.
359        if IN_ERROR_BOUNDARY.with(|f| f.get()) {
360            return;
361        }
362
363        // Try to restore terminal state
364        let _ = crossterm::terminal::disable_raw_mode();
365        let _ = crossterm::execute!(
366            std::io::stdout(),
367            crossterm::terminal::LeaveAlternateScreen,
368            crossterm::cursor::Show
369        );
370
371        // Print a helpful error message
372        eprintln!("\n┌─ Telex Panic ─────────────────────────────────────────────────┐");
373        eprintln!("│                                                              │");
374
375        // Extract panic message
376        let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
377            s.to_string()
378        } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
379            s.clone()
380        } else {
381            "Unknown panic".to_string()
382        };
383
384        // Word wrap the message
385        for line in message.lines() {
386            let chunks: Vec<&str> = line
387                .as_bytes()
388                .chunks(58)
389                .map(|c| std::str::from_utf8(c).unwrap_or(""))
390                .collect();
391            for chunk in chunks {
392                eprintln!("│  {:<58}│", chunk);
393            }
394        }
395
396        eprintln!("│                                                              │");
397
398        // Print location if available
399        if let Some(location) = panic_info.location() {
400            eprintln!(
401                "│  Location: {}:{}:{:<25}│",
402                location.file().split('/').next_back().unwrap_or(location.file()),
403                location.line(),
404                location.column()
405            );
406        }
407
408        eprintln!("│                                                              │");
409        eprintln!("│  Tip: Check your hook order - hooks must be called          │");
410        eprintln!("│  unconditionally in the same order every render.            │");
411        eprintln!("│                                                              │");
412        eprintln!("└──────────────────────────────────────────────────────────────┘\n");
413
414        // Call default hook for stack trace
415        default_hook(panic_info);
416    }));
417
418    let terminal = Terminal::new()?;
419    let event_source = CrosstermEventSource;
420    run_inner(root, terminal, &event_source)
421}
422
423/// Run a component headlessly with scripted events. For testing only.
424///
425/// Runs the real event loop with a headless terminal and injected events.
426/// When all events are consumed, the loop exits and returns the final
427/// rendered frame as a string.
428///
429/// This exercises the same key dispatch logic as the real `run()` function.
430pub fn run_headless<C: Component>(
431    root: C,
432    width: u16,
433    height: u16,
434    events: Vec<Event>,
435) -> String {
436    let terminal = Terminal::new_headless(width, height);
437    let event_source = testing::TestEventSource::new(events);
438    let _ = run_inner(root, terminal, &event_source);
439    event_source.last_buffer()
440}
441
442/// Runs the real event loop with no user input for `duration`, then exits.
443///
444/// Returns all rendered frames in order. The last frame is rendered when
445/// Ctrl+Q arrives; frames before that were triggered by wake events
446/// (channels, streams) or the initial render. Use this to test that
447/// background streams wake the event loop without user input.
448pub fn run_headless_timed<C: Component>(
449    root: C,
450    width: u16,
451    height: u16,
452    duration: Duration,
453) -> Vec<String> {
454    let terminal = Terminal::new_headless(width, height);
455    let event_source = testing::StreamTestEventSource::new(duration);
456    let _ = run_inner(root, terminal, &event_source);
457    event_source.frames()
458}
459
460/// Inner event loop shared by `run()` and `run_headless()`.
461fn run_inner<C: Component, E: EventSource>(
462    root: C,
463    mut terminal: Terminal,
464    event_source: &E,
465) -> Result<()> {
466    let mut focus = FocusManager::new();
467    let storage = Rc::new(StateStorage::new());
468    let commands = Rc::new(CommandRegistry::new());
469    let context = Rc::new(ContextStorage::new());
470    let debug_mode = is_debug_mode();
471
472    let mut frame_count = 0u64;
473    let mut needs_render = true; // Always render on first frame
474    let wake_flag = storage.wake_flag().clone();
475
476    loop {
477        let render_start = std::time::Instant::now();
478
479        // Decay effect cycle counter (sliding window for infinite loop detection)
480        storage.decay_effect_counter();
481
482        // Drain all registered channels (external events -> frame buffers)
483        // Clear first, then drain so components see only this frame's messages.
484        storage.clear_channels();
485        storage.drain_channels();
486
487        // Channel data means we need to render
488        if storage.has_channel_data() {
489            needs_render = true;
490        }
491
492        // Poll terminal output (before rendering, so we pick up any new data)
493        focus.poll_terminals();
494
495        // Compute poll timeout: 0ms if wake flag is set (external event arrived),
496        // otherwise 16ms (~60fps). Reset the flag before polling.
497        let woken = wake_flag.swap(false, Ordering::Acquire);
498        if woken {
499            needs_render = true;
500        }
501        let poll_timeout = if needs_render {
502            Duration::ZERO
503        } else {
504            Duration::from_millis(16)
505        };
506
507        // Skip render if nothing changed since last frame.
508        // If input arrives during the skip-render poll, save it so we can
509        // dispatch it after re-rendering (instead of dropping it on the floor).
510        let mut pending_event: Option<Event> = None;
511        if !needs_render {
512            if let Some(event) = event_source.poll_event(poll_timeout)? {
513                if let Event::Resize(_, _) = event {
514                    needs_render = true;
515                    continue;
516                }
517                // Input arrived — save it and fall through to render + dispatch
518                pending_event = Some(event);
519            } else {
520                continue; // No input, no channel data, skip frame
521            }
522        }
523        needs_render = false; // Reset for next frame; input/effects/channels will set it again
524
525        // Clear command registry before each render
526        commands.clear();
527
528        // Create scope with existing storage, command registry, and context
529        let cx = Scope::with_all(
530            Rc::clone(&storage),
531            Rc::clone(&commands),
532            Rc::clone(&context),
533        );
534
535        // Render the view
536        let view = root.render(cx);
537
538        // Collect focusables for navigation
539        focus.collect_focusables(&view);
540
541        // Set default wrap width for text areas based on terminal width
542        // (subtract 2 for TextArea borders)
543        focus.set_default_textarea_wrap_width(terminal.width().saturating_sub(2));
544
545        let render_time = render_start.elapsed();
546        frame_count += 1;
547
548        // Get scroll and cursor offsets for all focusables
549        let scroll_offsets: Vec<(u16, u16)> = (0..focus.focus_index() + 10)
550            .map(|i| focus.scroll_offset(i))
551            .collect();
552        let cursor_offsets: Vec<usize> = (0..focus.focus_index() + 10)
553            .map(|i| focus.cursor_offset(i))
554            .collect();
555
556        // Check if modal is visible for render context
557        let modal_visible = has_visible_modal(&view);
558
559        // Draw with focus and scroll info, get back clamped offsets
560        let clamped_offsets = terminal.draw(
561            &view,
562            focus.focus_index(),
563            focus.is_focus_visible(),
564            scroll_offsets,
565            cursor_offsets,
566            modal_visible,
567        )?;
568        focus.update_scroll_states(&clamped_offsets);
569
570        // Draw debug info if enabled
571        if debug_mode {
572            terminal.draw_debug(
573                frame_count,
574                render_time.as_micros() as u64,
575                focus.focus_index(),
576                focus.focusable_count(),
577            )?;
578        }
579
580        // Run pending effects (after render, before input handling)
581        // If effects ran and potentially modified state, re-render once
582        if storage.flush_effects() {
583            // Effects ran - re-render to show any state changes they made
584            // Only do this once per frame to prevent infinite loops
585            needs_render = true;
586            let cx = Scope::with_all(
587                Rc::clone(&storage),
588                Rc::clone(&commands),
589                Rc::clone(&context),
590            );
591            let view = root.render(cx);
592            focus.collect_focusables(&view);
593            let scroll_offsets: Vec<(u16, u16)> = (0..focus.focus_index() + 10)
594                .map(|i| focus.scroll_offset(i))
595                .collect();
596            let cursor_offsets: Vec<usize> = (0..focus.focus_index() + 10)
597                .map(|i| focus.cursor_offset(i))
598                .collect();
599            let modal_visible = has_visible_modal(&view);
600            let clamped_offsets = terminal.draw(
601                &view,
602                focus.focus_index(),
603                focus.is_focus_visible(),
604                scroll_offsets,
605                cursor_offsets,
606                modal_visible,
607            )?;
608            focus.update_scroll_states(&clamped_offsets);
609            // Don't flush effects again - just one re-render per frame
610        }
611
612        // Store current buffer for test retrieval (headless mode)
613        event_source.on_frame_rendered(&terminal);
614
615        // For now, use a generous max_scroll to allow scrolling
616        // TODO: Calculate actual content height for focused scrollable
617        let max_scroll = 100u16;
618        let viewport_height = terminal.height().saturating_sub(6); // Approximate visible rows
619
620        // Handle input — use saved event from skip-render poll, or poll fresh
621        let input_event = if pending_event.is_some() {
622            pending_event.take()
623        } else {
624            event_source.poll_event(Duration::from_millis(16))?
625        };
626        if let Some(event) = input_event {
627            // Any input means we should re-render next frame
628            needs_render = true;
629
630            // Handle resize - just continue to trigger redraw
631            if let Event::Resize(_, _) = event {
632                continue;
633            }
634
635            if let Event::Key(key) = event {
636                // Check if a modal is visible - if so, only allow Escape
637                let modal_visible = has_visible_modal(&view);
638                let palette_visible = has_visible_command_palette(&view);
639
640                // When modal is visible, Escape dismisses it
641                // Other keys work normally (focus is scoped to modal content)
642                if modal_visible && key.code == KeyCode::Esc && key.modifiers == KeyModifiers::NONE
643                {
644                    call_modal_dismiss(&view);
645                    continue;
646                }
647
648                // Handle command palette input when visible
649                if palette_visible {
650                    match (key.modifiers, key.code) {
651                        (KeyModifiers::NONE, KeyCode::Esc) => {
652                            call_command_palette_dismiss(&view);
653                        }
654                        (KeyModifiers::NONE, KeyCode::Enter) => {
655                            if focus.is_focused_command_palette() {
656                                focus.command_palette_execute();
657                            }
658                        }
659                        (KeyModifiers::NONE, KeyCode::Up) => {
660                            // Navigate up in palette - handled by state in component
661                        }
662                        (KeyModifiers::NONE, KeyCode::Down) => {
663                            // Navigate down in palette - handled by state in component
664                        }
665                        (KeyModifiers::NONE, KeyCode::Backspace) => {
666                            if focus.is_focused_command_palette() {
667                                focus.command_palette_backspace();
668                            }
669                        }
670                        (KeyModifiers::NONE, KeyCode::Char(c)) => {
671                            if focus.is_focused_command_palette() {
672                                focus.command_palette_key(c);
673                            }
674                        }
675                        (KeyModifiers::SHIFT, KeyCode::Char(c)) => {
676                            if focus.is_focused_command_palette() {
677                                focus.command_palette_key(c.to_ascii_uppercase());
678                            }
679                        }
680                        _ => {}
681                    }
682                    continue;
683                }
684
685                // Escape closes open menu bar dropdowns
686                if key.code == KeyCode::Esc && key.modifiers == KeyModifiers::NONE
687                    && focus.is_focused_menu_bar() && focus.menu_bar_has_open_menu() {
688                    focus.menu_bar_close();
689                    continue;
690                }
691
692                // First, try user-registered commands
693                if commands.execute(key.code, key.modifiers) {
694                    continue;
695                }
696
697                match (key.modifiers, key.code) {
698                    // Ctrl+Q to quit (but not Ctrl+C, as that should pass through to terminal)
699                    (m, KeyCode::Char('q')) if m.contains(KeyModifiers::CONTROL) => {
700                        break;
701                    }
702                    // Ctrl+Shift+[ to escape terminal focus
703                    (m, KeyCode::Char('['))
704                        if m.contains(KeyModifiers::CONTROL)
705                            && m.contains(KeyModifiers::SHIFT) =>
706                    {
707                        if focus.is_focused_terminal() {
708                            focus.focus_next();
709                        }
710                    }
711                    // Terminal passthrough - send all keys to terminal if focused
712                    _ if focus.is_focused_terminal() => {
713                        if let Err(e) = focus.terminal_key(key) {
714                            eprintln!("Terminal input error: {}", e);
715                        }
716                    }
717                    // Tab to focus next
718                    (KeyModifiers::NONE, KeyCode::Tab) => {
719                        focus.focus_next();
720                    }
721                    // Shift+Tab to focus previous
722                    (KeyModifiers::SHIFT, KeyCode::BackTab) => {
723                        focus.focus_prev();
724                    }
725                    // Enter or Space to activate (for buttons, checkboxes, tree, table, menu bar)
726                    (KeyModifiers::NONE, KeyCode::Enter | KeyCode::Char(' ')) => {
727                        if focus.is_focused_text_area() {
728                            if key.code == KeyCode::Enter {
729                                focus.text_area_enter();
730                            } else {
731                                focus.text_area_key(' ');
732                            }
733                        } else if focus.is_focused_text_input() {
734                            if key.code == KeyCode::Enter {
735                                // Enter in text input submits
736                                focus.text_input_submit();
737                            } else {
738                                // Space in text input adds a space
739                                focus.text_input_key(' ');
740                            }
741                        } else if focus.is_focused_tree() {
742                            focus.tree_activate();
743                        } else if focus.is_focused_table() {
744                            focus.table_activate();
745                        } else if focus.is_focused_menu_bar() {
746                            if focus.menu_bar_has_open_menu() {
747                                // Execute selected item
748                                focus.menu_bar_execute();
749                            } else {
750                                // Open first menu
751                                focus.menu_bar_open();
752                            }
753                        } else {
754                            focus.activate();
755                        }
756                    }
757                    // Backspace for text input/area/form field
758                    (KeyModifiers::NONE, KeyCode::Backspace) => {
759                        if focus.is_focused_text_input() {
760                            focus.text_input_backspace();
761                        } else if focus.is_focused_text_area() {
762                            focus.text_area_backspace();
763                        } else if focus.is_focused_form_field() {
764                            focus.form_field_backspace();
765                        }
766                    }
767                    // Arrow keys for scrolling (when focused on scrollable) or list/tree/table/radio/textarea/menu/text input navigation
768                    (KeyModifiers::NONE, KeyCode::Up) => {
769                        if focus.is_focused_text_input() {
770                            focus.text_input_key_up();
771                        } else if focus.is_focused_text_area() {
772                            focus.text_area_cursor_up();
773                        } else if focus.is_focused_menu_bar() && focus.menu_bar_has_open_menu() {
774                            focus.menu_bar_select_prev();
775                        } else if focus.is_focused_scrollable() {
776                            // For auto_scroll_bottom, Up means scroll away from bottom (increase offset)
777                            if focus.is_focused_auto_scroll_bottom() {
778                                focus.scroll_down(1, max_scroll);
779                            } else {
780                                focus.scroll_up(1);
781                            }
782                        } else if focus.is_focused_list() {
783                            focus.list_select_prev();
784                        } else if focus.is_focused_tree() {
785                            focus.tree_select_prev();
786                        } else if focus.is_focused_table() {
787                            focus.table_select_prev();
788                        } else if focus.is_focused_radio_group() {
789                            focus.radio_group_select_prev();
790                        }
791                    }
792                    (KeyModifiers::NONE, KeyCode::Down) => {
793                        if focus.is_focused_text_input() {
794                            focus.text_input_key_down();
795                        } else if focus.is_focused_text_area() {
796                            focus.text_area_cursor_down();
797                        } else if focus.is_focused_menu_bar() && focus.menu_bar_has_open_menu() {
798                            focus.menu_bar_select_next();
799                        } else if focus.is_focused_scrollable() {
800                            // For auto_scroll_bottom, Down means scroll toward bottom (decrease offset)
801                            if focus.is_focused_auto_scroll_bottom() {
802                                focus.scroll_up(1);
803                            } else {
804                                focus.scroll_down(1, max_scroll);
805                            }
806                        } else if focus.is_focused_list() {
807                            focus.list_select_next();
808                        } else if focus.is_focused_tree() {
809                            focus.tree_select_next();
810                        } else if focus.is_focused_table() {
811                            focus.table_select_next();
812                        } else if focus.is_focused_radio_group() {
813                            focus.radio_group_select_next();
814                        }
815                    }
816                    // Page Up/Down
817                    (KeyModifiers::NONE, KeyCode::PageUp) => {
818                        if focus.is_focused_scrollable() {
819                            if focus.is_focused_auto_scroll_bottom() {
820                                focus.scroll_down(viewport_height, max_scroll);
821                            } else {
822                                focus.scroll_up(viewport_height);
823                            }
824                        }
825                    }
826                    (KeyModifiers::NONE, KeyCode::PageDown) => {
827                        if focus.is_focused_scrollable() {
828                            if focus.is_focused_auto_scroll_bottom() {
829                                focus.scroll_up(viewport_height);
830                            } else {
831                                focus.scroll_down(viewport_height, max_scroll);
832                            }
833                        }
834                    }
835                    // Home/End
836                    (KeyModifiers::NONE, KeyCode::Home) => {
837                        if focus.is_focused_scrollable() {
838                            // For auto_scroll_bottom, Home goes to top (max offset from bottom)
839                            if focus.is_focused_auto_scroll_bottom() {
840                                focus.scroll_end(max_scroll);
841                            } else {
842                                focus.scroll_home();
843                            }
844                        }
845                    }
846                    (KeyModifiers::NONE, KeyCode::End) => {
847                        if focus.is_focused_scrollable() {
848                            // For auto_scroll_bottom, End goes to bottom (zero offset)
849                            if focus.is_focused_auto_scroll_bottom() {
850                                focus.scroll_home();
851                            } else {
852                                focus.scroll_end(max_scroll);
853                            }
854                        }
855                    }
856                    // Left/Right arrows for text inputs, text areas, tabs, tree, and menu bar
857                    (KeyModifiers::NONE, KeyCode::Left) => {
858                        if focus.is_focused_text_input() {
859                            focus.text_input_cursor_left();
860                        } else if focus.is_focused_text_area() {
861                            focus.text_area_cursor_left();
862                        } else if focus.is_focused_menu_bar() {
863                            if focus.menu_bar_has_open_menu() {
864                                focus.menu_bar_prev();
865                            } else {
866                                focus.menu_bar_highlight_prev();
867                            }
868                        } else if focus.is_focused_tabs() {
869                            focus.tabs_select_prev();
870                        } else if focus.is_focused_slider() {
871                            focus.slider_decrement();
872                        } else if focus.is_focused_tree() {
873                            // Left triggers activate (app should collapse)
874                            focus.tree_activate();
875                        }
876                    }
877                    (KeyModifiers::NONE, KeyCode::Right) => {
878                        if focus.is_focused_text_input() {
879                            focus.text_input_cursor_right();
880                        } else if focus.is_focused_text_area() {
881                            focus.text_area_cursor_right();
882                        } else if focus.is_focused_menu_bar() {
883                            if focus.menu_bar_has_open_menu() {
884                                focus.menu_bar_next();
885                            } else {
886                                focus.menu_bar_highlight_next();
887                            }
888                        } else if focus.is_focused_tabs() {
889                            focus.tabs_select_next();
890                        } else if focus.is_focused_slider() {
891                            focus.slider_increment();
892                        } else if focus.is_focused_tree() {
893                            // Right triggers activate (app should expand)
894                            focus.tree_activate();
895                        }
896                    }
897                    // Character input for text fields, tabs, tree, and form fields
898                    (KeyModifiers::NONE, KeyCode::Char(c)) => {
899                        if focus.is_focused_text_input() {
900                            focus.text_input_key(c);
901                        } else if focus.is_focused_text_area() {
902                            focus.text_area_key(c);
903                        } else if focus.is_focused_form_field() {
904                            focus.form_field_key(c);
905                        } else if focus.is_focused_tabs() {
906                            // Handle [ ] for tab cycling and 1-9 for direct selection
907                            match c {
908                                '[' => focus.tabs_select_prev(),
909                                ']' => focus.tabs_select_next(),
910                                '1'..='9' => {
911                                    let idx = (c as usize) - ('1' as usize);
912                                    focus.tabs_select(idx);
913                                }
914                                _ => {}
915                            }
916                        } else if focus.is_focused_tree() {
917                            // j/k for vim-style navigation, space for activate
918                            match c {
919                                'j' => focus.tree_select_next(),
920                                'k' => focus.tree_select_prev(),
921                                ' ' => focus.tree_activate(),
922                                _ => {}
923                            }
924                        } else if focus.is_focused_table() {
925                            // j/k for vim-style navigation
926                            match c {
927                                'j' => focus.table_select_next(),
928                                'k' => focus.table_select_prev(),
929                                _ => {}
930                            }
931                        } else if focus.is_focused_radio_group() {
932                            // j/k for vim-style navigation
933                            match c {
934                                'j' => focus.radio_group_select_next(),
935                                'k' => focus.radio_group_select_prev(),
936                                _ => {}
937                            }
938                        }
939                    }
940                    (KeyModifiers::SHIFT, KeyCode::Char(c)) => {
941                        if focus.is_focused_text_input() {
942                            focus.text_input_key(c.to_ascii_uppercase());
943                        } else if focus.is_focused_text_area() {
944                            focus.text_area_key(c.to_ascii_uppercase());
945                        } else if focus.is_focused_form_field() {
946                            focus.form_field_key(c.to_ascii_uppercase());
947                        }
948                    }
949                    _ => {}
950                }
951            }
952        }
953    }
954
955    // Run all effect cleanup functions before exiting
956    storage.cleanup_all_effects();
957
958    terminal.cleanup()?;
959    Ok(())
960}