1#![forbid(unsafe_code)]
2
3pub mod align;
129pub mod badge;
131pub mod block;
133pub mod borders;
134pub mod cached;
135pub mod columns;
136pub mod command_palette;
137pub mod constraint_overlay;
138#[cfg(feature = "debug-overlay")]
139pub mod debug_overlay;
140pub mod drag;
142pub mod emoji;
143pub mod error_boundary;
144pub mod fenwick;
146pub mod file_picker;
147pub mod focus;
149pub mod group;
150pub mod height_predictor;
152pub mod help;
153pub mod help_registry;
154pub mod hint_ranker;
156pub mod history_panel;
158pub mod input;
159pub mod inspector;
161pub mod json_view;
162pub mod keyboard_drag;
163pub mod layout;
164pub mod layout_debugger;
165pub mod list;
166pub mod log_ring;
167pub mod log_viewer;
168pub mod measurable;
170pub mod measure_cache;
172pub mod modal;
173pub mod mouse;
175pub mod notification_queue;
177pub mod padding;
178pub mod paginator;
179pub mod panel;
180pub mod paragraph;
182pub mod pretty;
183pub mod progress;
184pub mod rule;
185pub mod scrollbar;
186pub mod sparkline;
187pub mod spinner;
188pub mod stateful;
190pub mod status_line;
191pub mod stopwatch;
192pub mod table;
194pub mod textarea;
195pub mod timer;
196pub mod toast;
198pub mod tree;
199pub mod undo_support;
201pub mod validation_error;
203pub mod virtualized;
204pub mod voi_debug_overlay;
205
206pub use align::{Align, VerticalAlignment};
207pub use badge::Badge;
208pub use cached::{CacheKey, CachedWidget, CachedWidgetState, FnKey, HashKey, NoCacheKey};
209pub use columns::{Column, Columns};
210pub use constraint_overlay::{ConstraintOverlay, ConstraintOverlayStyle};
211#[cfg(feature = "debug-overlay")]
212pub use debug_overlay::{
213 DebugOverlay, DebugOverlayOptions, DebugOverlayState, DebugOverlayStateful,
214 DebugOverlayStatefulState,
215};
216pub use group::Group;
217pub use help_registry::{HelpContent, HelpId, HelpRegistry, Keybinding};
218pub use history_panel::{HistoryEntry, HistoryPanel, HistoryPanelMode};
219pub use layout_debugger::{LayoutConstraints, LayoutDebugger, LayoutRecord};
220pub use log_ring::LogRing;
221pub use log_viewer::{LogViewer, LogViewerState, LogWrapMode, SearchConfig, SearchMode};
222pub use paginator::{Paginator, PaginatorMode};
223pub use panel::Panel;
224pub use sparkline::Sparkline;
225pub use status_line::{StatusItem, StatusLine};
226pub use virtualized::{
227 HeightCache, ItemHeight, RenderItem, Virtualized, VirtualizedList, VirtualizedListState,
228 VirtualizedStorage,
229};
230pub use voi_debug_overlay::{
231 VoiDebugOverlay, VoiDecisionSummary, VoiLedgerEntry, VoiObservationSummary, VoiOverlayData,
232 VoiOverlayStyle, VoiPosteriorSummary,
233};
234
235pub use toast::{
237 KeyEvent as ToastKeyEvent, Toast, ToastAction, ToastAnimationConfig, ToastAnimationPhase,
238 ToastAnimationState, ToastConfig, ToastContent, ToastEasing, ToastEntranceAnimation,
239 ToastEvent, ToastExitAnimation, ToastIcon, ToastId, ToastPosition, ToastState, ToastStyle,
240};
241
242pub use notification_queue::{
244 NotificationPriority, NotificationQueue, QueueAction, QueueConfig, QueueStats,
245};
246
247pub use mouse::MouseResult;
249
250pub use measurable::{MeasurableWidget, SizeConstraints};
252
253pub use measure_cache::{CacheStats, MeasureCache, WidgetId};
255pub use modal::{
256 BackdropConfig, MODAL_HIT_BACKDROP, MODAL_HIT_CONTENT, Modal, ModalAction, ModalConfig,
257 ModalPosition, ModalSizeConstraints, ModalState,
258};
259
260pub use inspector::{
262 DiagnosticEntry, DiagnosticEventKind, DiagnosticLog, HitInfo, InspectorMode, InspectorOverlay,
263 InspectorState, InspectorStyle, TelemetryCallback, TelemetryHooks, WidgetInfo,
264 diagnostics_enabled, init_diagnostics, is_deterministic_mode, reset_event_counter,
265 set_diagnostics_enabled,
266};
267
268pub use focus::{
270 FocusEvent, FocusGraph, FocusGroup, FocusId, FocusIndicator, FocusIndicatorKind, FocusManager,
271 FocusNode, FocusTrap, NavDirection,
272};
273
274pub use drag::{
276 DragConfig, DragPayload, DragState, Draggable, DropPosition, DropResult, DropTarget,
277};
278
279pub use stateful::{StateKey, Stateful, VersionedState};
281
282pub use list::ListPersistState;
284pub use table::TablePersistState;
285pub use tree::TreePersistState;
286pub use virtualized::VirtualizedListPersistState;
287
288pub use undo_support::{
290 ListOperation, ListUndoExt, SelectionOperation, TableOperation, TableUndoExt,
291 TextEditOperation, TextInputUndoExt, TreeOperation, TreeUndoExt, UndoSupport, UndoWidgetId,
292 WidgetTextEditCmd,
293};
294
295pub use validation_error::{
297 ANIMATION_DURATION_MS, ERROR_BG_DEFAULT, ERROR_FG_DEFAULT, ERROR_ICON_DEFAULT,
298 ValidationErrorDisplay, ValidationErrorState,
299};
300
301use ftui_core::geometry::Rect;
302use ftui_render::buffer::Buffer;
303use ftui_render::cell::Cell;
304use ftui_render::frame::{Frame, WidgetSignal};
305use ftui_style::Style;
306use ftui_text::grapheme_width;
307
308pub trait Widget {
348 fn render(&self, area: Rect, frame: &mut Frame);
354
355 fn is_essential(&self) -> bool {
366 false
367 }
368}
369
370pub struct Budgeted<W> {
372 widget_id: u64,
373 signal: WidgetSignal,
374 inner: W,
375}
376
377impl<W> Budgeted<W> {
378 #[must_use]
380 pub fn new(widget_id: u64, inner: W) -> Self {
381 Self {
382 widget_id,
383 signal: WidgetSignal::new(widget_id),
384 inner,
385 }
386 }
387
388 #[must_use]
390 pub fn with_signal(mut self, mut signal: WidgetSignal) -> Self {
391 signal.widget_id = self.widget_id;
392 self.signal = signal;
393 self
394 }
395
396 #[must_use]
398 pub fn inner(&self) -> &W {
399 &self.inner
400 }
401}
402
403impl<W: Widget> Widget for Budgeted<W> {
404 fn render(&self, area: Rect, frame: &mut Frame) {
405 let mut signal = self.signal.clone();
406 signal.widget_id = self.widget_id;
407 signal.essential = self.inner.is_essential();
408 signal.area_cells = area.width as u32 * area.height as u32;
409 frame.register_widget_signal(signal);
410
411 if frame.should_render_widget(self.widget_id, self.inner.is_essential()) {
412 self.inner.render(area, frame);
413 }
414 }
415
416 fn is_essential(&self) -> bool {
417 self.inner.is_essential()
418 }
419}
420
421impl<W: StatefulWidget + Widget> StatefulWidget for Budgeted<W> {
422 type State = W::State;
423
424 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
425 let mut signal = self.signal.clone();
426 signal.widget_id = self.widget_id;
427 signal.essential = self.inner.is_essential();
428 signal.area_cells = area.width as u32 * area.height as u32;
429 frame.register_widget_signal(signal);
430
431 if frame.should_render_widget(self.widget_id, self.inner.is_essential()) {
432 StatefulWidget::render(&self.inner, area, frame, state);
433 }
434 }
435}
436
437pub trait StatefulWidget {
474 type State;
476
477 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State);
484}
485
486pub(crate) fn apply_style(cell: &mut Cell, style: Style) {
488 if let Some(fg) = style.fg {
489 cell.fg = fg;
490 }
491 if let Some(bg) = style.bg {
492 cell.bg = bg;
493 }
494 if let Some(attrs) = style.attrs {
495 let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
501 cell.attrs = cell.attrs.with_flags(cell_flags);
502 }
503}
504
505pub(crate) fn set_style_area(buf: &mut Buffer, area: Rect, style: Style) {
509 if style.is_empty() {
510 return;
511 }
512 let fg = style.fg;
518 let bg = style.bg;
519 let attrs = style.attrs;
520 for y in area.y..area.bottom() {
521 for x in area.x..area.right() {
522 if let Some(cell) = buf.get_mut(x, y) {
523 if let Some(fg) = fg {
524 cell.fg = fg;
525 }
526 if let Some(bg) = bg {
527 match bg.a() {
528 0 => {} 255 => cell.bg = bg,
530 _ => cell.bg = bg.over(cell.bg),
531 }
532 }
533 if let Some(attrs) = attrs {
534 let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
535 cell.attrs = cell.attrs.with_flags(cell_flags);
536 }
537 }
538 }
539 }
540}
541
542pub(crate) fn draw_text_span(
547 frame: &mut Frame,
548 mut x: u16,
549 y: u16,
550 content: &str,
551 style: Style,
552 max_x: u16,
553) -> u16 {
554 use unicode_segmentation::UnicodeSegmentation;
555
556 for grapheme in content.graphemes(true) {
557 if x >= max_x {
558 break;
559 }
560 let w = grapheme_width(grapheme);
561 if w == 0 {
562 continue;
563 }
564 if x.saturating_add(w as u16) > max_x {
565 break;
566 }
567
568 let cell_content = if w > 1 || grapheme.chars().count() > 1 {
570 let id = frame.intern_with_width(grapheme, w as u8);
571 ftui_render::cell::CellContent::from_grapheme(id)
572 } else if let Some(c) = grapheme.chars().next() {
573 ftui_render::cell::CellContent::from_char(c)
574 } else {
575 continue;
576 };
577
578 let mut cell = Cell::new(cell_content);
579 apply_style(&mut cell, style);
580
581 frame.buffer.set_fast(x, y, cell);
584
585 x = x.saturating_add(w as u16);
586 }
587 x
588}
589
590#[allow(dead_code)]
592pub(crate) fn draw_text_span_with_link(
593 frame: &mut Frame,
594 x: u16,
595 y: u16,
596 content: &str,
597 style: Style,
598 max_x: u16,
599 link_url: Option<&str>,
600) -> u16 {
601 draw_text_span_scrolled(frame, x, y, content, style, max_x, 0, link_url)
602}
603
604#[allow(dead_code, clippy::too_many_arguments)]
606pub(crate) fn draw_text_span_scrolled(
607 frame: &mut Frame,
608 mut x: u16,
609 y: u16,
610 content: &str,
611 style: Style,
612 max_x: u16,
613 scroll_x: u16,
614 link_url: Option<&str>,
615) -> u16 {
616 use unicode_segmentation::UnicodeSegmentation;
617
618 let link_id = if let Some(url) = link_url {
620 frame.register_link(url)
621 } else {
622 0
623 };
624
625 let mut visual_pos: u16 = 0;
626
627 for grapheme in content.graphemes(true) {
628 if x >= max_x {
629 break;
630 }
631 let w = grapheme_width(grapheme);
632 if w == 0 {
633 continue;
634 }
635
636 let next_visual_pos = visual_pos.saturating_add(w as u16);
637
638 if next_visual_pos <= scroll_x {
640 visual_pos = next_visual_pos;
642 continue;
643 }
644
645 if visual_pos < scroll_x {
646 visual_pos = next_visual_pos;
649 continue;
650 }
651
652 if x.saturating_add(w as u16) > max_x {
653 break;
654 }
655
656 let cell_content = if w > 1 || grapheme.chars().count() > 1 {
658 let id = frame.intern_with_width(grapheme, w as u8);
659 ftui_render::cell::CellContent::from_grapheme(id)
660 } else if let Some(c) = grapheme.chars().next() {
661 ftui_render::cell::CellContent::from_char(c)
662 } else {
663 continue;
664 };
665
666 let mut cell = Cell::new(cell_content);
667 apply_style(&mut cell, style);
668
669 if link_id != 0 {
671 cell.attrs = cell.attrs.with_link(link_id);
672 }
673
674 frame.buffer.set_fast(x, y, cell);
675
676 x = x.saturating_add(w as u16);
677 visual_pos = next_visual_pos;
678 }
679 x
680}
681
682#[cfg(test)]
683mod tests {
684 use super::*;
685 use ftui_render::cell::PackedRgba;
686 use ftui_render::grapheme_pool::GraphemePool;
687
688 #[test]
689 fn apply_style_sets_fg() {
690 let mut cell = Cell::default();
691 let style = Style::new().fg(PackedRgba::rgb(255, 0, 0));
692 apply_style(&mut cell, style);
693 assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
694 }
695
696 #[test]
697 fn apply_style_sets_bg() {
698 let mut cell = Cell::default();
699 let style = Style::new().bg(PackedRgba::rgb(0, 255, 0));
700 apply_style(&mut cell, style);
701 assert_eq!(cell.bg, PackedRgba::rgb(0, 255, 0));
702 }
703
704 #[test]
705 fn apply_style_preserves_content() {
706 let mut cell = Cell::from_char('Z');
707 let style = Style::new().fg(PackedRgba::rgb(1, 2, 3));
708 apply_style(&mut cell, style);
709 assert_eq!(cell.content.as_char(), Some('Z'));
710 }
711
712 #[test]
713 fn apply_style_empty_is_noop() {
714 let original = Cell::default();
715 let mut cell = Cell::default();
716 apply_style(&mut cell, Style::default());
717 assert_eq!(cell.fg, original.fg);
718 assert_eq!(cell.bg, original.bg);
719 }
720
721 #[test]
722 fn set_style_area_applies_to_all_cells() {
723 let mut buf = Buffer::new(3, 2);
724 let area = Rect::new(0, 0, 3, 2);
725 let style = Style::new().bg(PackedRgba::rgb(10, 20, 30));
726 set_style_area(&mut buf, area, style);
727
728 for y in 0..2 {
729 for x in 0..3 {
730 assert_eq!(
731 buf.get(x, y).unwrap().bg,
732 PackedRgba::rgb(10, 20, 30),
733 "cell ({x},{y}) should have style applied"
734 );
735 }
736 }
737 }
738
739 #[test]
740 fn set_style_area_composites_alpha_bg_over_existing_bg() {
741 let mut buf = Buffer::new(1, 1);
742 let base = PackedRgba::rgb(200, 0, 0);
743 buf.set(0, 0, Cell::default().with_bg(base));
744
745 let overlay = PackedRgba::rgba(0, 0, 200, 128);
746 set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().bg(overlay));
747
748 let expected = overlay.over(base);
749 assert_eq!(buf.get(0, 0).unwrap().bg, expected);
750 }
751
752 #[test]
753 fn set_style_area_partial_rect() {
754 let mut buf = Buffer::new(5, 5);
755 let area = Rect::new(1, 1, 2, 2);
756 let style = Style::new().fg(PackedRgba::rgb(99, 99, 99));
757 set_style_area(&mut buf, area, style);
758
759 assert_eq!(buf.get(1, 1).unwrap().fg, PackedRgba::rgb(99, 99, 99));
761 assert_eq!(buf.get(2, 2).unwrap().fg, PackedRgba::rgb(99, 99, 99));
762
763 assert_ne!(buf.get(0, 0).unwrap().fg, PackedRgba::rgb(99, 99, 99));
765 }
766
767 #[test]
768 fn set_style_area_empty_style_is_noop() {
769 let mut buf = Buffer::new(3, 3);
770 buf.set(0, 0, Cell::from_char('A'));
771 let original_fg = buf.get(0, 0).unwrap().fg;
772
773 set_style_area(&mut buf, Rect::new(0, 0, 3, 3), Style::default());
774
775 assert_eq!(buf.get(0, 0).unwrap().fg, original_fg);
777 assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
778 }
779
780 #[test]
781 fn draw_text_span_basic() {
782 let mut pool = GraphemePool::new();
783 let mut frame = Frame::new(10, 1, &mut pool);
784 let end_x = draw_text_span(&mut frame, 0, 0, "ABC", Style::default(), 10);
785
786 assert_eq!(end_x, 3);
787 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
788 assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('B'));
789 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
790 }
791
792 #[test]
793 fn draw_text_span_clipped_at_max_x() {
794 let mut pool = GraphemePool::new();
795 let mut frame = Frame::new(10, 1, &mut pool);
796 let end_x = draw_text_span(&mut frame, 0, 0, "ABCDEF", Style::default(), 3);
797
798 assert_eq!(end_x, 3);
799 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
800 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
801 assert!(frame.buffer.get(3, 0).unwrap().is_empty());
803 }
804
805 #[test]
806 fn draw_text_span_starts_at_offset() {
807 let mut pool = GraphemePool::new();
808 let mut frame = Frame::new(10, 1, &mut pool);
809 let end_x = draw_text_span(&mut frame, 5, 0, "XY", Style::default(), 10);
810
811 assert_eq!(end_x, 7);
812 assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('X'));
813 assert_eq!(frame.buffer.get(6, 0).unwrap().content.as_char(), Some('Y'));
814 assert!(frame.buffer.get(4, 0).unwrap().is_empty());
815 }
816
817 #[test]
818 fn draw_text_span_empty_string() {
819 let mut pool = GraphemePool::new();
820 let mut frame = Frame::new(5, 1, &mut pool);
821 let end_x = draw_text_span(&mut frame, 0, 0, "", Style::default(), 5);
822 assert_eq!(end_x, 0);
823 }
824
825 #[test]
826 fn draw_text_span_applies_style() {
827 let mut pool = GraphemePool::new();
828 let mut frame = Frame::new(5, 1, &mut pool);
829 let style = Style::new().fg(PackedRgba::rgb(255, 128, 0));
830 draw_text_span(&mut frame, 0, 0, "A", style, 5);
831
832 assert_eq!(
833 frame.buffer.get(0, 0).unwrap().fg,
834 PackedRgba::rgb(255, 128, 0)
835 );
836 }
837
838 #[test]
839 fn draw_text_span_max_x_at_start_draws_nothing() {
840 let mut pool = GraphemePool::new();
841 let mut frame = Frame::new(5, 1, &mut pool);
842 let end_x = draw_text_span(&mut frame, 3, 0, "ABC", Style::default(), 3);
843 assert_eq!(end_x, 3);
844 assert!(frame.buffer.get(3, 0).unwrap().is_empty());
845 }
846
847 #[test]
848 fn widget_is_essential_default_false() {
849 struct DummyWidget;
850 impl Widget for DummyWidget {
851 fn render(&self, _: Rect, _: &mut Frame) {}
852 }
853 assert!(!DummyWidget.is_essential());
854 }
855
856 #[test]
857 fn budgeted_new_and_inner() {
858 struct TestW;
859 impl Widget for TestW {
860 fn render(&self, _: Rect, _: &mut Frame) {}
861 }
862 let b = Budgeted::new(42, TestW);
863 assert_eq!(b.widget_id, 42);
864 let _ = b.inner(); }
866
867 #[test]
868 fn budgeted_with_signal() {
869 struct TestW;
870 impl Widget for TestW {
871 fn render(&self, _: Rect, _: &mut Frame) {}
872 }
873 let sig = WidgetSignal::new(99);
874 let b = Budgeted::new(42, TestW).with_signal(sig);
875 assert_eq!(b.signal.widget_id, 42);
877 }
878
879 #[test]
880 fn set_style_area_transparent_bg_is_noop() {
881 let mut buf = Buffer::new(1, 1);
882 let base = PackedRgba::rgb(100, 100, 100);
883 buf.set(0, 0, Cell::default().with_bg(base));
884
885 let transparent = PackedRgba::rgba(255, 0, 0, 0);
887 set_style_area(
888 &mut buf,
889 Rect::new(0, 0, 1, 1),
890 Style::new().bg(transparent),
891 );
892 assert_eq!(buf.get(0, 0).unwrap().bg, base);
893 }
894
895 #[test]
896 fn set_style_area_opaque_bg_replaces() {
897 let mut buf = Buffer::new(1, 1);
898 buf.set(
899 0,
900 0,
901 Cell::default().with_bg(PackedRgba::rgb(100, 100, 100)),
902 );
903
904 let opaque = PackedRgba::rgba(0, 255, 0, 255);
905 set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().bg(opaque));
906 assert_eq!(buf.get(0, 0).unwrap().bg, opaque);
907 }
908
909 #[test]
910 fn draw_text_span_scrolled_skips_chars() {
911 let mut pool = GraphemePool::new();
912 let mut frame = Frame::new(10, 1, &mut pool);
913 let end_x =
915 draw_text_span_scrolled(&mut frame, 0, 0, "ABCDE", Style::default(), 10, 2, None);
916
917 assert_eq!(end_x, 3);
918 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('C'));
919 assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('D'));
920 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('E'));
921 }
922}