1pub const API_VERSION_MAJOR: u32 = 0;
12
13pub const API_VERSION_MINOR: u32 = 1;
15
16pub const API_VERSION_PATCH: u32 = 0;
18
19#[macro_export]
41macro_rules! require_api {
42 ($major:literal, $minor:literal) => {
43 const _: () = {
44 if $crate::API_VERSION_MAJOR == 0 {
47 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 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
82mod 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
135pub use canvas::{animated_canvas, AnimatedCanvasBuilder, DrawContext, PixelBuffer};
137
138pub use image::ImageSource;
140
141pub 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
154fn 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
171fn 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
190fn 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
228fn 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
266pub fn is_debug_mode() -> bool {
268 std::env::var("TELEX_DEBUG")
269 .map(|v| v == "1" || v == "true")
270 .unwrap_or(false)
271}
272
273pub fn run_with_theme<C: Component>(root: C, theme: Theme) -> Result<()> {
286 theme::set_theme(theme);
287 run(root)
288}
289
290pub fn run<C: Component>(root: C) -> Result<()> {
305 let default_hook = panic::take_hook();
307 panic::set_hook(Box::new(move |panic_info| {
308 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 eprintln!("\n┌─ Telex Panic ─────────────────────────────────────────────────┐");
318 eprintln!("│ │");
319
320 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 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 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 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 storage.decay_effect_counter();
377
378 focus.poll_terminals();
380
381 commands.clear();
383
384 let cx = Scope::with_all(
386 Rc::clone(&storage),
387 Rc::clone(&commands),
388 Rc::clone(&context),
389 );
390
391 let view = root.render(cx);
393
394 focus.collect_focusables(&view);
396
397 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 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 let modal_visible = has_visible_modal(&view);
416
417 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 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 if storage.flush_effects() {
441 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 }
469
470 let max_scroll = 100u16;
473 let viewport_height = terminal.height().saturating_sub(6); if let Some(event) = terminal.poll_event()? {
477 if let Event::Resize(_, _) = event {
479 continue;
480 }
481
482 if let Event::Key(key) = event {
483 let modal_visible = has_visible_modal(&view);
485 let palette_visible = has_visible_command_palette(&view);
486
487 if modal_visible && key.code == KeyCode::Esc && key.modifiers == KeyModifiers::NONE
490 {
491 call_modal_dismiss(&view);
492 continue;
493 }
494
495 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 }
509 (KeyModifiers::NONE, KeyCode::Down) => {
510 }
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 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 if commands.execute(key.code, key.modifiers) {
541 continue;
542 }
543
544 match (key.modifiers, key.code) {
545 (m, KeyCode::Char('q')) if m.contains(KeyModifiers::CONTROL) => {
547 break;
548 }
549 (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 _ if focus.is_focused_terminal() => {
560 if let Err(e) = focus.terminal_key(key) {
561 eprintln!("Terminal input error: {}", e);
562 }
563 }
564 (KeyModifiers::NONE, KeyCode::Tab) => {
566 focus.focus_next();
567 }
568 (KeyModifiers::SHIFT, KeyCode::BackTab) => {
570 focus.focus_prev();
571 }
572 (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 focus.text_input_submit();
584 } else {
585 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 focus.menu_bar_execute();
596 } else {
597 focus.menu_bar_open();
599 }
600 } else {
601 focus.activate();
602 }
603 }
604 (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 (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 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 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 (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 (KeyModifiers::NONE, KeyCode::Home) => {
684 if focus.is_focused_scrollable() {
685 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 if focus.is_focused_auto_scroll_bottom() {
697 focus.scroll_home();
698 } else {
699 focus.scroll_end(max_scroll);
700 }
701 }
702 }
703 (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 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 focus.tree_activate();
738 }
739 }
740 (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 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 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 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 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 storage.cleanup_all_effects();
800
801 terminal.cleanup()?;
802 Ok(())
803}