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 notification_queue;
175pub mod padding;
176pub mod paginator;
177pub mod panel;
178pub mod paragraph;
180pub mod pretty;
181pub mod progress;
182pub mod rule;
183pub mod scrollbar;
184pub mod sparkline;
185pub mod spinner;
186pub mod stateful;
188pub mod status_line;
189pub mod stopwatch;
190pub mod table;
192pub mod textarea;
193pub mod timer;
194pub mod toast;
196pub mod tree;
197pub mod undo_support;
199pub mod validation_error;
201pub mod virtualized;
202pub mod voi_debug_overlay;
203
204pub use align::{Align, VerticalAlignment};
205pub use badge::Badge;
206pub use cached::{CacheKey, CachedWidget, CachedWidgetState, FnKey, HashKey, NoCacheKey};
207pub use columns::{Column, Columns};
208pub use constraint_overlay::{ConstraintOverlay, ConstraintOverlayStyle};
209#[cfg(feature = "debug-overlay")]
210pub use debug_overlay::{
211 DebugOverlay, DebugOverlayOptions, DebugOverlayState, DebugOverlayStateful,
212 DebugOverlayStatefulState,
213};
214pub use group::Group;
215pub use help_registry::{HelpContent, HelpId, HelpRegistry, Keybinding};
216pub use history_panel::{HistoryEntry, HistoryPanel, HistoryPanelMode};
217pub use layout_debugger::{LayoutConstraints, LayoutDebugger, LayoutRecord};
218pub use log_ring::LogRing;
219pub use log_viewer::{LogViewer, LogViewerState, LogWrapMode, SearchConfig, SearchMode};
220pub use paginator::{Paginator, PaginatorMode};
221pub use panel::Panel;
222pub use sparkline::Sparkline;
223pub use status_line::{StatusItem, StatusLine};
224pub use virtualized::{
225 HeightCache, ItemHeight, RenderItem, Virtualized, VirtualizedList, VirtualizedListState,
226 VirtualizedStorage,
227};
228pub use voi_debug_overlay::{
229 VoiDebugOverlay, VoiDecisionSummary, VoiLedgerEntry, VoiObservationSummary, VoiOverlayData,
230 VoiOverlayStyle, VoiPosteriorSummary,
231};
232
233pub use toast::{
235 KeyEvent as ToastKeyEvent, Toast, ToastAction, ToastAnimationConfig, ToastAnimationPhase,
236 ToastAnimationState, ToastConfig, ToastContent, ToastEasing, ToastEntranceAnimation,
237 ToastEvent, ToastExitAnimation, ToastIcon, ToastId, ToastPosition, ToastState, ToastStyle,
238};
239
240pub use notification_queue::{
242 NotificationPriority, NotificationQueue, QueueAction, QueueConfig, QueueStats,
243};
244
245pub use measurable::{MeasurableWidget, SizeConstraints};
247
248pub use measure_cache::{CacheStats, MeasureCache, WidgetId};
250pub use modal::{
251 BackdropConfig, MODAL_HIT_BACKDROP, MODAL_HIT_CONTENT, Modal, ModalAction, ModalConfig,
252 ModalPosition, ModalSizeConstraints, ModalState,
253};
254
255pub use inspector::{
257 DiagnosticEntry, DiagnosticEventKind, DiagnosticLog, HitInfo, InspectorMode, InspectorOverlay,
258 InspectorState, InspectorStyle, TelemetryCallback, TelemetryHooks, WidgetInfo,
259 diagnostics_enabled, init_diagnostics, is_deterministic_mode, reset_event_counter,
260 set_diagnostics_enabled,
261};
262
263pub use focus::{
265 FocusEvent, FocusGraph, FocusGroup, FocusId, FocusManager, FocusNode, FocusTrap, NavDirection,
266};
267
268pub use drag::{
270 DragConfig, DragPayload, DragState, Draggable, DropPosition, DropResult, DropTarget,
271};
272
273pub use stateful::{StateKey, Stateful, VersionedState};
275
276pub use list::ListPersistState;
278pub use table::TablePersistState;
279pub use tree::TreePersistState;
280pub use virtualized::VirtualizedListPersistState;
281
282pub use undo_support::{
284 ListOperation, ListUndoExt, SelectionOperation, TableOperation, TableUndoExt,
285 TextEditOperation, TextInputUndoExt, TreeOperation, TreeUndoExt, UndoSupport, UndoWidgetId,
286 WidgetTextEditCmd,
287};
288
289pub use validation_error::{
291 ANIMATION_DURATION_MS, ERROR_BG_DEFAULT, ERROR_FG_DEFAULT, ERROR_ICON_DEFAULT,
292 ValidationErrorDisplay, ValidationErrorState,
293};
294
295use ftui_core::geometry::Rect;
296use ftui_render::buffer::Buffer;
297use ftui_render::cell::Cell;
298use ftui_render::frame::{Frame, WidgetSignal};
299use ftui_style::Style;
300use ftui_text::grapheme_width;
301
302pub trait Widget {
342 fn render(&self, area: Rect, frame: &mut Frame);
348
349 fn is_essential(&self) -> bool {
360 false
361 }
362}
363
364pub struct Budgeted<W> {
366 widget_id: u64,
367 signal: WidgetSignal,
368 inner: W,
369}
370
371impl<W> Budgeted<W> {
372 #[must_use]
374 pub fn new(widget_id: u64, inner: W) -> Self {
375 Self {
376 widget_id,
377 signal: WidgetSignal::new(widget_id),
378 inner,
379 }
380 }
381
382 #[must_use]
384 pub fn with_signal(mut self, mut signal: WidgetSignal) -> Self {
385 signal.widget_id = self.widget_id;
386 self.signal = signal;
387 self
388 }
389
390 #[must_use]
392 pub fn inner(&self) -> &W {
393 &self.inner
394 }
395}
396
397impl<W: Widget> Widget for Budgeted<W> {
398 fn render(&self, area: Rect, frame: &mut Frame) {
399 let mut signal = self.signal.clone();
400 signal.widget_id = self.widget_id;
401 signal.essential = self.inner.is_essential();
402 signal.area_cells = area.width as u32 * area.height as u32;
403 frame.register_widget_signal(signal);
404
405 if frame.should_render_widget(self.widget_id, self.inner.is_essential()) {
406 self.inner.render(area, frame);
407 }
408 }
409
410 fn is_essential(&self) -> bool {
411 self.inner.is_essential()
412 }
413}
414
415impl<W: StatefulWidget + Widget> StatefulWidget for Budgeted<W> {
416 type State = W::State;
417
418 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
419 let mut signal = self.signal.clone();
420 signal.widget_id = self.widget_id;
421 signal.essential = self.inner.is_essential();
422 signal.area_cells = area.width as u32 * area.height as u32;
423 frame.register_widget_signal(signal);
424
425 if frame.should_render_widget(self.widget_id, self.inner.is_essential()) {
426 StatefulWidget::render(&self.inner, area, frame, state);
427 }
428 }
429}
430
431pub trait StatefulWidget {
468 type State;
470
471 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State);
478}
479
480pub(crate) fn apply_style(cell: &mut Cell, style: Style) {
482 if let Some(fg) = style.fg {
483 cell.fg = fg;
484 }
485 if let Some(bg) = style.bg {
486 cell.bg = bg;
487 }
488 if let Some(attrs) = style.attrs {
489 let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
495 cell.attrs = cell.attrs.with_flags(cell_flags);
496 }
497}
498
499pub(crate) fn set_style_area(buf: &mut Buffer, area: Rect, style: Style) {
503 if style.is_empty() {
504 return;
505 }
506 let fg = style.fg;
512 let bg = style.bg;
513 let attrs = style.attrs;
514 for y in area.y..area.bottom() {
515 for x in area.x..area.right() {
516 if let Some(cell) = buf.get_mut(x, y) {
517 if let Some(fg) = fg {
518 cell.fg = fg;
519 }
520 if let Some(bg) = bg {
521 match bg.a() {
522 0 => {} 255 => cell.bg = bg,
524 _ => cell.bg = bg.over(cell.bg),
525 }
526 }
527 if let Some(attrs) = attrs {
528 let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
529 cell.attrs = cell.attrs.with_flags(cell_flags);
530 }
531 }
532 }
533 }
534}
535
536pub(crate) fn draw_text_span(
541 frame: &mut Frame,
542 mut x: u16,
543 y: u16,
544 content: &str,
545 style: Style,
546 max_x: u16,
547) -> u16 {
548 use unicode_segmentation::UnicodeSegmentation;
549
550 for grapheme in content.graphemes(true) {
551 if x >= max_x {
552 break;
553 }
554 let w = grapheme_width(grapheme);
555 if w == 0 {
556 continue;
557 }
558 if x.saturating_add(w as u16) > max_x {
559 break;
560 }
561
562 let cell_content = if w > 1 || grapheme.chars().count() > 1 {
564 let id = frame.intern_with_width(grapheme, w as u8);
565 ftui_render::cell::CellContent::from_grapheme(id)
566 } else if let Some(c) = grapheme.chars().next() {
567 ftui_render::cell::CellContent::from_char(c)
568 } else {
569 continue;
570 };
571
572 let mut cell = Cell::new(cell_content);
573 apply_style(&mut cell, style);
574
575 frame.buffer.set(x, y, cell);
577
578 x = x.saturating_add(w as u16);
579 }
580 x
581}
582
583#[allow(dead_code)]
585pub(crate) fn draw_text_span_with_link(
586 frame: &mut Frame,
587 x: u16,
588 y: u16,
589 content: &str,
590 style: Style,
591 max_x: u16,
592 link_url: Option<&str>,
593) -> u16 {
594 draw_text_span_scrolled(frame, x, y, content, style, max_x, 0, link_url)
595}
596
597#[allow(dead_code, clippy::too_many_arguments)]
599pub(crate) fn draw_text_span_scrolled(
600 frame: &mut Frame,
601 mut x: u16,
602 y: u16,
603 content: &str,
604 style: Style,
605 max_x: u16,
606 scroll_x: u16,
607 link_url: Option<&str>,
608) -> u16 {
609 use unicode_segmentation::UnicodeSegmentation;
610
611 let link_id = if let Some(url) = link_url {
613 frame.register_link(url)
614 } else {
615 0
616 };
617
618 let mut visual_pos: u16 = 0;
619
620 for grapheme in content.graphemes(true) {
621 if x >= max_x {
622 break;
623 }
624 let w = grapheme_width(grapheme);
625 if w == 0 {
626 continue;
627 }
628
629 let next_visual_pos = visual_pos.saturating_add(w as u16);
630
631 if next_visual_pos <= scroll_x {
633 visual_pos = next_visual_pos;
635 continue;
636 }
637
638 if visual_pos < scroll_x {
639 visual_pos = next_visual_pos;
642 continue;
643 }
644
645 if x.saturating_add(w as u16) > max_x {
646 break;
647 }
648
649 let cell_content = if w > 1 || grapheme.chars().count() > 1 {
651 let id = frame.intern_with_width(grapheme, w as u8);
652 ftui_render::cell::CellContent::from_grapheme(id)
653 } else if let Some(c) = grapheme.chars().next() {
654 ftui_render::cell::CellContent::from_char(c)
655 } else {
656 continue;
657 };
658
659 let mut cell = Cell::new(cell_content);
660 apply_style(&mut cell, style);
661
662 if link_id != 0 {
664 cell.attrs = cell.attrs.with_link(link_id);
665 }
666
667 frame.buffer.set(x, y, cell);
668
669 x = x.saturating_add(w as u16);
670 visual_pos = next_visual_pos;
671 }
672 x
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use ftui_render::cell::PackedRgba;
679 use ftui_render::grapheme_pool::GraphemePool;
680
681 #[test]
682 fn apply_style_sets_fg() {
683 let mut cell = Cell::default();
684 let style = Style::new().fg(PackedRgba::rgb(255, 0, 0));
685 apply_style(&mut cell, style);
686 assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
687 }
688
689 #[test]
690 fn apply_style_sets_bg() {
691 let mut cell = Cell::default();
692 let style = Style::new().bg(PackedRgba::rgb(0, 255, 0));
693 apply_style(&mut cell, style);
694 assert_eq!(cell.bg, PackedRgba::rgb(0, 255, 0));
695 }
696
697 #[test]
698 fn apply_style_preserves_content() {
699 let mut cell = Cell::from_char('Z');
700 let style = Style::new().fg(PackedRgba::rgb(1, 2, 3));
701 apply_style(&mut cell, style);
702 assert_eq!(cell.content.as_char(), Some('Z'));
703 }
704
705 #[test]
706 fn apply_style_empty_is_noop() {
707 let original = Cell::default();
708 let mut cell = Cell::default();
709 apply_style(&mut cell, Style::default());
710 assert_eq!(cell.fg, original.fg);
711 assert_eq!(cell.bg, original.bg);
712 }
713
714 #[test]
715 fn set_style_area_applies_to_all_cells() {
716 let mut buf = Buffer::new(3, 2);
717 let area = Rect::new(0, 0, 3, 2);
718 let style = Style::new().bg(PackedRgba::rgb(10, 20, 30));
719 set_style_area(&mut buf, area, style);
720
721 for y in 0..2 {
722 for x in 0..3 {
723 assert_eq!(
724 buf.get(x, y).unwrap().bg,
725 PackedRgba::rgb(10, 20, 30),
726 "cell ({x},{y}) should have style applied"
727 );
728 }
729 }
730 }
731
732 #[test]
733 fn set_style_area_composites_alpha_bg_over_existing_bg() {
734 let mut buf = Buffer::new(1, 1);
735 let base = PackedRgba::rgb(200, 0, 0);
736 buf.set(0, 0, Cell::default().with_bg(base));
737
738 let overlay = PackedRgba::rgba(0, 0, 200, 128);
739 set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().bg(overlay));
740
741 let expected = overlay.over(base);
742 assert_eq!(buf.get(0, 0).unwrap().bg, expected);
743 }
744
745 #[test]
746 fn set_style_area_partial_rect() {
747 let mut buf = Buffer::new(5, 5);
748 let area = Rect::new(1, 1, 2, 2);
749 let style = Style::new().fg(PackedRgba::rgb(99, 99, 99));
750 set_style_area(&mut buf, area, style);
751
752 assert_eq!(buf.get(1, 1).unwrap().fg, PackedRgba::rgb(99, 99, 99));
754 assert_eq!(buf.get(2, 2).unwrap().fg, PackedRgba::rgb(99, 99, 99));
755
756 assert_ne!(buf.get(0, 0).unwrap().fg, PackedRgba::rgb(99, 99, 99));
758 }
759
760 #[test]
761 fn set_style_area_empty_style_is_noop() {
762 let mut buf = Buffer::new(3, 3);
763 buf.set(0, 0, Cell::from_char('A'));
764 let original_fg = buf.get(0, 0).unwrap().fg;
765
766 set_style_area(&mut buf, Rect::new(0, 0, 3, 3), Style::default());
767
768 assert_eq!(buf.get(0, 0).unwrap().fg, original_fg);
770 assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
771 }
772
773 #[test]
774 fn draw_text_span_basic() {
775 let mut pool = GraphemePool::new();
776 let mut frame = Frame::new(10, 1, &mut pool);
777 let end_x = draw_text_span(&mut frame, 0, 0, "ABC", Style::default(), 10);
778
779 assert_eq!(end_x, 3);
780 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
781 assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('B'));
782 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
783 }
784
785 #[test]
786 fn draw_text_span_clipped_at_max_x() {
787 let mut pool = GraphemePool::new();
788 let mut frame = Frame::new(10, 1, &mut pool);
789 let end_x = draw_text_span(&mut frame, 0, 0, "ABCDEF", Style::default(), 3);
790
791 assert_eq!(end_x, 3);
792 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
793 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
794 assert!(frame.buffer.get(3, 0).unwrap().is_empty());
796 }
797
798 #[test]
799 fn draw_text_span_starts_at_offset() {
800 let mut pool = GraphemePool::new();
801 let mut frame = Frame::new(10, 1, &mut pool);
802 let end_x = draw_text_span(&mut frame, 5, 0, "XY", Style::default(), 10);
803
804 assert_eq!(end_x, 7);
805 assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('X'));
806 assert_eq!(frame.buffer.get(6, 0).unwrap().content.as_char(), Some('Y'));
807 assert!(frame.buffer.get(4, 0).unwrap().is_empty());
808 }
809
810 #[test]
811 fn draw_text_span_empty_string() {
812 let mut pool = GraphemePool::new();
813 let mut frame = Frame::new(5, 1, &mut pool);
814 let end_x = draw_text_span(&mut frame, 0, 0, "", Style::default(), 5);
815 assert_eq!(end_x, 0);
816 }
817
818 #[test]
819 fn draw_text_span_applies_style() {
820 let mut pool = GraphemePool::new();
821 let mut frame = Frame::new(5, 1, &mut pool);
822 let style = Style::new().fg(PackedRgba::rgb(255, 128, 0));
823 draw_text_span(&mut frame, 0, 0, "A", style, 5);
824
825 assert_eq!(
826 frame.buffer.get(0, 0).unwrap().fg,
827 PackedRgba::rgb(255, 128, 0)
828 );
829 }
830
831 #[test]
832 fn draw_text_span_max_x_at_start_draws_nothing() {
833 let mut pool = GraphemePool::new();
834 let mut frame = Frame::new(5, 1, &mut pool);
835 let end_x = draw_text_span(&mut frame, 3, 0, "ABC", Style::default(), 3);
836 assert_eq!(end_x, 3);
837 assert!(frame.buffer.get(3, 0).unwrap().is_empty());
838 }
839}