1#![forbid(unsafe_code)]
2
3pub mod adaptive_radix;
129pub mod align;
130pub mod badge;
132pub mod block;
134pub mod borders;
135pub mod cached;
136pub mod choreography;
137pub mod columns;
138pub mod command_palette;
139pub mod constraint_overlay;
140#[cfg(feature = "debug-overlay")]
141pub mod debug_overlay;
142pub mod decision_card;
144pub mod diagnostics;
146pub mod drag;
148pub mod drift_visualization;
150pub mod elias_fano;
152pub mod emoji;
153pub mod error_boundary;
154pub mod fenwick;
156pub mod file_picker;
157pub mod focus;
159pub mod group;
160pub mod height_predictor;
162pub mod help;
163pub mod help_registry;
164pub mod hint_ranker;
166pub mod history_panel;
168pub mod input;
169pub mod inspector;
171pub mod json_view;
172pub mod keyboard_drag;
173pub mod layout;
174pub mod layout_debugger;
175pub mod list;
176pub mod log_ring;
177pub mod log_viewer;
178pub mod louds;
179pub mod measurable;
181pub mod measure_cache;
183pub mod modal;
184pub mod mouse;
186pub mod notification_queue;
188pub mod padding;
189pub mod paginator;
190pub mod panel;
191pub mod paragraph;
193pub mod popover;
194pub mod pretty;
195pub mod progress;
196pub mod receipt_verifier_panel;
198pub mod rule;
199pub mod scrollbar;
200pub mod sparkline;
201pub mod spinner;
202pub mod stateful;
204pub mod status_line;
205pub mod stopwatch;
206pub mod table;
208pub mod tabs;
209pub mod textarea;
210pub mod timer;
211pub mod toast;
213pub mod tree;
214pub mod undo_support;
216pub mod validation_error;
218pub mod virtualized;
219pub mod voi_debug_overlay;
220
221#[cfg(all(test, feature = "tracing"))]
222pub(crate) mod tracing_test_support {
223 use std::sync::{Mutex, MutexGuard, OnceLock};
224
225 pub(crate) struct TraceTestGuard {
228 _lock: MutexGuard<'static, ()>,
229 }
230
231 impl Drop for TraceTestGuard {
232 fn drop(&mut self) {
233 tracing::callsite::rebuild_interest_cache();
234 }
235 }
236
237 pub(crate) fn acquire() -> TraceTestGuard {
238 static TRACE_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
239
240 let lock = TRACE_TEST_LOCK
242 .get_or_init(|| Mutex::new(()))
243 .lock()
244 .unwrap_or_else(|e| e.into_inner());
245 tracing::callsite::rebuild_interest_cache();
246 TraceTestGuard { _lock: lock }
247 }
248}
249
250pub use align::{Align, VerticalAlignment};
251pub use badge::Badge;
252pub use cached::{CacheKey, CachedWidget, CachedWidgetState, FnKey, HashKey, NoCacheKey};
253pub use columns::{Column, Columns};
254pub use constraint_overlay::{ConstraintOverlay, ConstraintOverlayStyle};
255#[cfg(feature = "debug-overlay")]
256pub use debug_overlay::{
257 DebugOverlay, DebugOverlayOptions, DebugOverlayState, DebugOverlayStateful,
258 DebugOverlayStatefulState,
259};
260pub use decision_card::DecisionCard;
261pub use group::Group;
262pub use help_registry::{HelpContent, HelpId, HelpRegistry, Keybinding};
263pub use history_panel::{HistoryEntry, HistoryPanel, HistoryPanelMode};
264pub use layout_debugger::{LayoutConstraints, LayoutDebugger, LayoutRecord};
265pub use log_ring::LogRing;
266pub use log_viewer::{LogViewer, LogViewerState, LogWrapMode, SearchConfig, SearchMode};
267pub use paginator::{Paginator, PaginatorMode};
268pub use panel::Panel;
269pub use sparkline::Sparkline;
270pub use status_line::{StatusItem, StatusLine};
271pub use tabs::{Tab, Tabs, TabsState};
272pub use virtualized::{
273 HeightCache, ItemHeight, RenderItem, Virtualized, VirtualizedList, VirtualizedListState,
274 VirtualizedStorage,
275};
276pub use voi_debug_overlay::{
277 VoiDebugOverlay, VoiDecisionSummary, VoiLedgerEntry, VoiObservationSummary, VoiOverlayData,
278 VoiOverlayStyle, VoiPosteriorSummary,
279};
280
281pub use toast::{
283 KeyEvent as ToastKeyEvent, Toast, ToastAction, ToastAnimationConfig, ToastAnimationPhase,
284 ToastAnimationState, ToastConfig, ToastContent, ToastEasing, ToastEntranceAnimation,
285 ToastEvent, ToastExitAnimation, ToastIcon, ToastId, ToastPosition, ToastState, ToastStyle,
286};
287
288pub use notification_queue::{
290 NotificationPriority, NotificationQueue, QueueAction, QueueConfig, QueueStats,
291};
292
293pub use ftui_a11y::Accessible;
295
296pub use mouse::MouseResult;
298
299pub use measurable::{MeasurableWidget, SizeConstraints};
301
302pub use measure_cache::{CacheStats, MeasureCache, WidgetId};
304pub use modal::{
305 BackdropConfig, MODAL_HIT_BACKDROP, MODAL_HIT_CONTENT, Modal, ModalAction, ModalConfig,
306 ModalPosition, ModalSizeConstraints, ModalState,
307};
308
309pub use inspector::{
311 DiagnosticEntry, DiagnosticEventKind, DiagnosticLog, HitInfo, InspectorMode, InspectorOverlay,
312 InspectorState, InspectorStyle, TelemetryCallback, TelemetryHooks, WidgetInfo,
313 diagnostics_enabled, init_diagnostics, is_deterministic_mode, reset_event_counter,
314 set_diagnostics_enabled,
315};
316
317pub use focus::{
319 FocusEvent, FocusGraph, FocusGroup, FocusId, FocusIndicator, FocusIndicatorKind, FocusManager,
320 FocusNode, FocusTrap, NavDirection,
321};
322
323pub use drag::{
325 DragConfig, DragPayload, DragState, Draggable, DropPosition, DropResult, DropTarget,
326};
327
328pub use stateful::{StateKey, Stateful, VersionedState};
330
331pub use list::ListPersistState;
333pub use table::TablePersistState;
334pub use tree::TreePersistState;
335pub use virtualized::VirtualizedListPersistState;
336
337pub use undo_support::{
339 ListOperation, ListUndoExt, SelectionOperation, TableOperation, TableUndoExt,
340 TextEditOperation, TextInputUndoExt, TreeOperation, TreeUndoExt, UndoSupport, UndoWidgetId,
341 WidgetTextEditCmd,
342};
343
344pub use validation_error::{
346 ANIMATION_DURATION_MS, ERROR_BG_DEFAULT, ERROR_FG_DEFAULT, ERROR_ICON_DEFAULT,
347 ValidationErrorDisplay, ValidationErrorState,
348};
349
350use ftui_core::geometry::Rect;
351use ftui_render::buffer::Buffer;
352use ftui_render::cell::Cell;
353use ftui_render::frame::{Frame, WidgetSignal};
354use ftui_style::Style;
355use ftui_text::grapheme_width;
356
357#[must_use]
362pub(crate) fn a11y_node_id(area: Rect) -> u64 {
363 const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
365 const FNV_PRIME: u64 = 1_099_511_628_211;
366 let mut h = FNV_OFFSET;
367 for byte in area
368 .x
369 .to_le_bytes()
370 .iter()
371 .chain(&area.y.to_le_bytes())
372 .chain(&area.width.to_le_bytes())
373 .chain(&area.height.to_le_bytes())
374 {
375 h ^= u64::from(*byte);
376 h = h.wrapping_mul(FNV_PRIME);
377 }
378 h
379}
380
381pub trait Widget {
421 fn render(&self, area: Rect, frame: &mut Frame);
427
428 fn is_essential(&self) -> bool {
439 false
440 }
441}
442
443pub struct Budgeted<W> {
445 widget_id: u64,
446 signal: WidgetSignal,
447 inner: W,
448}
449
450impl<W> Budgeted<W> {
451 #[must_use]
453 pub fn new(widget_id: u64, inner: W) -> Self {
454 Self {
455 widget_id,
456 signal: WidgetSignal::new(widget_id),
457 inner,
458 }
459 }
460
461 #[must_use]
463 pub fn with_signal(mut self, mut signal: WidgetSignal) -> Self {
464 signal.widget_id = self.widget_id;
465 self.signal = signal;
466 self
467 }
468
469 #[must_use]
471 pub fn inner(&self) -> &W {
472 &self.inner
473 }
474}
475
476impl<W: Widget> Widget for Budgeted<W> {
477 fn render(&self, area: Rect, frame: &mut Frame) {
478 let mut signal = self.signal.clone();
479 signal.widget_id = self.widget_id;
480 signal.essential = self.inner.is_essential();
481 signal.area_cells = area.width as u32 * area.height as u32;
482 frame.register_widget_signal(signal);
483
484 if frame.should_render_widget(self.widget_id, self.inner.is_essential()) {
485 self.inner.render(area, frame);
486 }
487 }
488
489 fn is_essential(&self) -> bool {
490 self.inner.is_essential()
491 }
492}
493
494impl<W: StatefulWidget + Widget> StatefulWidget for Budgeted<W> {
495 type State = W::State;
496
497 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
498 let mut signal = self.signal.clone();
499 signal.widget_id = self.widget_id;
500 signal.essential = self.inner.is_essential();
501 signal.area_cells = area.width as u32 * area.height as u32;
502 frame.register_widget_signal(signal);
503
504 if frame.should_render_widget(self.widget_id, self.inner.is_essential()) {
505 StatefulWidget::render(&self.inner, area, frame, state);
506 }
507 }
508}
509
510pub trait StatefulWidget {
547 type State;
549
550 fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State);
557}
558
559pub(crate) fn apply_style(cell: &mut Cell, style: Style) {
566 if let Some(fg) = style.fg {
567 cell.fg = fg;
568 }
569 if let Some(bg) = style.bg {
570 match bg.a() {
571 0 => {} 255 => cell.bg = bg, _ => cell.bg = bg.over(cell.bg), }
575 }
576 if let Some(attrs) = style.attrs {
577 let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
578 cell.attrs = cell.attrs.merged_flags(cell_flags);
579 }
580}
581
582pub(crate) fn set_style_area(buf: &mut Buffer, area: Rect, style: Style) {
592 if style.is_empty() {
593 return;
594 }
595 let clipped = area.intersection(&buf.current_scissor());
596 if clipped.is_empty() {
597 return;
598 }
599
600 let opacity = buf.current_opacity();
601 let fg = style.fg.map(|fg| fg.with_opacity(opacity));
602 let bg = style.bg.map(|bg| bg.with_opacity(opacity));
603 let attrs = style.attrs.map(ftui_render::cell::StyleFlags::from);
604 for y in clipped.y..clipped.bottom() {
605 let Some(row) = buf.row_cells_mut_span(y, clipped.x, clipped.right()) else {
606 continue;
607 };
608 for cell in row {
609 if let Some(fg) = fg {
610 cell.fg = fg;
611 }
612 if let Some(bg) = bg {
613 match bg.a() {
614 0 => {} 255 => cell.bg = bg, _ => cell.bg = bg.over(cell.bg), }
618 }
619 if let Some(attrs) = attrs {
620 cell.attrs = cell.attrs.merged_flags(attrs);
621 }
622 }
623 }
624}
625
626pub(crate) fn clear_text_area(frame: &mut Frame, area: Rect, style: Style) {
628 if area.width == 0 || area.height == 0 {
629 return;
630 }
631
632 let mut cell = Cell::from_char(' ');
633 apply_style(&mut cell, style);
634 frame.buffer.fill(area, cell);
635}
636
637pub(crate) fn clear_text_row(frame: &mut Frame, area: Rect, style: Style) {
639 clear_text_area(frame, Rect::new(area.x, area.y, area.width, 1), style);
640}
641
642fn inherited_text_cell(
648 frame: &Frame,
649 x: u16,
650 y: u16,
651 content: ftui_render::cell::CellContent,
652) -> Cell {
653 let mut cell = frame.buffer.get(x, y).copied().unwrap_or_default();
654 cell.content = content;
655 cell.attrs = ftui_render::cell::CellAttrs::new(cell.attrs.flags(), 0);
656 cell
657}
658
659pub(crate) fn draw_text_span(
664 frame: &mut Frame,
665 mut x: u16,
666 y: u16,
667 content: &str,
668 style: Style,
669 max_x: u16,
670) -> u16 {
671 use unicode_segmentation::UnicodeSegmentation;
672
673 for grapheme in content.graphemes(true) {
674 if x >= max_x {
675 break;
676 }
677 let w = grapheme_width(grapheme);
678 if w == 0 {
679 continue;
680 }
681 if x.saturating_add(w as u16) > max_x {
682 break;
683 }
684
685 let cell_content = if w > 1 || grapheme.chars().count() > 1 {
687 let id = frame.intern_with_width(grapheme, w as u8);
688 ftui_render::cell::CellContent::from_grapheme(id)
689 } else if let Some(c) = grapheme.chars().next() {
690 ftui_render::cell::CellContent::from_char(c)
691 } else {
692 continue;
693 };
694
695 let mut cell = inherited_text_cell(frame, x, y, cell_content);
696 apply_style(&mut cell, style);
697
698 frame.buffer.set_fast(x, y, cell);
701
702 x = x.saturating_add(w as u16);
703 }
704 x
705}
706
707#[allow(dead_code)]
709pub(crate) fn draw_text_span_with_link(
710 frame: &mut Frame,
711 x: u16,
712 y: u16,
713 content: &str,
714 style: Style,
715 max_x: u16,
716 link_url: Option<&str>,
717) -> u16 {
718 draw_text_span_scrolled(frame, x, y, content, style, max_x, 0, link_url)
719}
720
721#[allow(dead_code, clippy::too_many_arguments)]
723pub(crate) fn draw_text_span_scrolled(
724 frame: &mut Frame,
725 mut x: u16,
726 y: u16,
727 content: &str,
728 style: Style,
729 max_x: u16,
730 scroll_x: u16,
731 link_url: Option<&str>,
732) -> u16 {
733 use unicode_segmentation::UnicodeSegmentation;
734
735 let link_id = if let Some(url) = link_url {
737 frame.register_link(url)
738 } else {
739 0
740 };
741
742 let mut visual_pos: u16 = 0;
743
744 for grapheme in content.graphemes(true) {
745 if x >= max_x {
746 break;
747 }
748 let w = grapheme_width(grapheme);
749 if w == 0 {
750 continue;
751 }
752
753 let next_visual_pos = visual_pos.saturating_add(w as u16);
754
755 if next_visual_pos <= scroll_x {
757 visual_pos = next_visual_pos;
759 continue;
760 }
761
762 if visual_pos < scroll_x {
763 visual_pos = next_visual_pos;
766 continue;
767 }
768
769 if x.saturating_add(w as u16) > max_x {
770 break;
771 }
772
773 let cell_content = if w > 1 || grapheme.chars().count() > 1 {
775 let id = frame.intern_with_width(grapheme, w as u8);
776 ftui_render::cell::CellContent::from_grapheme(id)
777 } else if let Some(c) = grapheme.chars().next() {
778 ftui_render::cell::CellContent::from_char(c)
779 } else {
780 continue;
781 };
782
783 let mut cell = inherited_text_cell(frame, x, y, cell_content);
784 apply_style(&mut cell, style);
785
786 if link_id != 0 {
788 cell.attrs = cell.attrs.with_link(link_id);
789 }
790
791 frame.buffer.set_fast(x, y, cell);
792
793 x = x.saturating_add(w as u16);
794 visual_pos = next_visual_pos;
795 }
796 x
797}
798
799pub(crate) fn contains_ignore_case(haystack: &str, needle_lower: &str) -> bool {
801 if needle_lower.is_empty() {
802 return true;
803 }
804 if haystack.is_ascii() && needle_lower.is_ascii() {
806 let haystack_bytes = haystack.as_bytes();
807 let needle_bytes = needle_lower.as_bytes();
808 if needle_bytes.len() > haystack_bytes.len() {
809 return false;
810 }
811 for i in 0..=haystack_bytes.len() - needle_bytes.len() {
813 let mut match_found = true;
814 for (j, &b) in needle_bytes.iter().enumerate() {
815 if haystack_bytes[i + j].to_ascii_lowercase() != b {
816 match_found = false;
817 break;
818 }
819 }
820 if match_found {
821 return true;
822 }
823 }
824 return false;
825 }
826 haystack.to_lowercase().contains(needle_lower)
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833 use ftui_render::cell::PackedRgba;
834 use ftui_render::grapheme_pool::GraphemePool;
835
836 #[test]
837 fn apply_style_sets_fg() {
838 let mut cell = Cell::default();
839 let style = Style::new().fg(PackedRgba::rgb(255, 0, 0));
840 apply_style(&mut cell, style);
841 assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
842 }
843
844 #[test]
845 fn apply_style_sets_bg() {
846 let mut cell = Cell::default();
847 let style = Style::new().bg(PackedRgba::rgb(0, 255, 0));
848 apply_style(&mut cell, style);
849 assert_eq!(cell.bg, PackedRgba::rgb(0, 255, 0));
850 }
851
852 #[test]
853 fn apply_style_preserves_content() {
854 let mut cell = Cell::from_char('Z');
855 let style = Style::new().fg(PackedRgba::rgb(1, 2, 3));
856 apply_style(&mut cell, style);
857 assert_eq!(cell.content.as_char(), Some('Z'));
858 }
859
860 #[test]
861 fn apply_style_empty_is_noop() {
862 let original = Cell::default();
863 let mut cell = Cell::default();
864 apply_style(&mut cell, Style::default());
865 assert_eq!(cell.fg, original.fg);
866 assert_eq!(cell.bg, original.bg);
867 }
868
869 #[test]
870 fn apply_style_bg_only_preserves_fg() {
871 let mut cell = Cell::from_char('x').with_fg(PackedRgba::rgb(0, 200, 0));
873 let selection = Style::new().bg(PackedRgba::rgb(0, 0, 180));
874 apply_style(&mut cell, selection);
875 assert_eq!(cell.fg, PackedRgba::rgb(0, 200, 0));
877 assert_eq!(cell.bg, PackedRgba::rgb(0, 0, 180));
878 }
879
880 #[test]
881 fn apply_style_composites_alpha_bg() {
882 let base_bg = PackedRgba::rgb(200, 0, 0);
883 let mut cell = Cell::default().with_bg(base_bg);
884 let overlay = PackedRgba::rgba(0, 0, 200, 128);
885 apply_style(&mut cell, Style::new().bg(overlay));
886 assert_eq!(cell.bg, overlay.over(base_bg));
887 }
888
889 #[test]
890 fn apply_style_transparent_bg_is_noop() {
891 let base_bg = PackedRgba::rgb(100, 100, 100);
892 let mut cell = Cell::default().with_bg(base_bg);
893 apply_style(&mut cell, Style::new().bg(PackedRgba::rgba(255, 0, 0, 0)));
894 assert_eq!(cell.bg, base_bg);
895 }
896
897 #[test]
898 fn apply_style_merges_attrs_not_replaces() {
899 use ftui_render::cell::StyleFlags as CellFlags;
900 let mut cell = Cell::default();
902 cell.attrs = cell.attrs.with_flags(CellFlags::BOLD);
903 let overlay = Style::new().italic();
905 apply_style(&mut cell, overlay);
906 assert!(cell.attrs.has_flag(CellFlags::BOLD), "BOLD must survive");
907 assert!(
908 cell.attrs.has_flag(CellFlags::ITALIC),
909 "ITALIC must be added"
910 );
911 }
912
913 #[test]
914 fn set_style_area_bg_only_preserves_per_cell_fg() {
915 let mut buf = Buffer::new(3, 1);
917 buf.set(0, 0, Cell::from_char('R').with_fg(PackedRgba::RED));
918 buf.set(1, 0, Cell::from_char('G').with_fg(PackedRgba::GREEN));
919 buf.set(2, 0, Cell::from_char('B').with_fg(PackedRgba::BLUE));
920
921 let highlight = Style::new().bg(PackedRgba::rgb(40, 40, 40));
923 set_style_area(&mut buf, Rect::new(0, 0, 3, 1), highlight);
924
925 assert_eq!(buf.get(0, 0).unwrap().fg, PackedRgba::RED);
927 assert_eq!(buf.get(1, 0).unwrap().fg, PackedRgba::GREEN);
928 assert_eq!(buf.get(2, 0).unwrap().fg, PackedRgba::BLUE);
929 assert_eq!(buf.get(0, 0).unwrap().bg, PackedRgba::rgb(40, 40, 40));
931 }
932
933 #[test]
934 fn set_style_area_merges_attrs_not_replaces() {
935 use ftui_render::cell::StyleFlags as CellFlags;
936 let mut buf = Buffer::new(1, 1);
937 let mut cell = Cell::from_char('X');
938 cell.attrs = cell.attrs.with_flags(CellFlags::BOLD);
939 buf.set(0, 0, cell);
940
941 set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().italic());
942
943 let result = buf.get(0, 0).unwrap();
944 assert!(result.attrs.has_flag(CellFlags::BOLD), "BOLD must survive");
945 assert!(
946 result.attrs.has_flag(CellFlags::ITALIC),
947 "ITALIC must be added"
948 );
949 }
950
951 #[test]
952 fn set_style_area_applies_to_all_cells() {
953 let mut buf = Buffer::new(3, 2);
954 let area = Rect::new(0, 0, 3, 2);
955 let style = Style::new().bg(PackedRgba::rgb(10, 20, 30));
956 set_style_area(&mut buf, area, style);
957
958 for y in 0..2 {
959 for x in 0..3 {
960 assert_eq!(
961 buf.get(x, y).unwrap().bg,
962 PackedRgba::rgb(10, 20, 30),
963 "cell ({x},{y}) should have style applied"
964 );
965 }
966 }
967 }
968
969 #[test]
970 fn set_style_area_composites_alpha_bg_over_existing_bg() {
971 let mut buf = Buffer::new(1, 1);
972 let base = PackedRgba::rgb(200, 0, 0);
973 buf.set(0, 0, Cell::default().with_bg(base));
974
975 let overlay = PackedRgba::rgba(0, 0, 200, 128);
976 set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().bg(overlay));
977
978 let expected = overlay.over(base);
979 assert_eq!(buf.get(0, 0).unwrap().bg, expected);
980 }
981
982 #[test]
983 fn set_style_area_partial_rect() {
984 let mut buf = Buffer::new(5, 5);
985 let area = Rect::new(1, 1, 2, 2);
986 let style = Style::new().fg(PackedRgba::rgb(99, 99, 99));
987 set_style_area(&mut buf, area, style);
988
989 assert_eq!(buf.get(1, 1).unwrap().fg, PackedRgba::rgb(99, 99, 99));
991 assert_eq!(buf.get(2, 2).unwrap().fg, PackedRgba::rgb(99, 99, 99));
992
993 assert_ne!(buf.get(0, 0).unwrap().fg, PackedRgba::rgb(99, 99, 99));
995 }
996
997 #[test]
998 fn set_style_area_empty_style_is_noop() {
999 let mut buf = Buffer::new(3, 3);
1000 buf.set(0, 0, Cell::from_char('A'));
1001 let original_fg = buf.get(0, 0).unwrap().fg;
1002
1003 set_style_area(&mut buf, Rect::new(0, 0, 3, 3), Style::default());
1004
1005 assert_eq!(buf.get(0, 0).unwrap().fg, original_fg);
1007 assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
1008 }
1009
1010 #[test]
1011 fn set_style_area_respects_scissor() {
1012 let mut buf = Buffer::new(3, 3);
1013 let style = Style::new().bg(PackedRgba::rgb(10, 20, 30));
1014
1015 buf.push_scissor(Rect::new(1, 1, 1, 1));
1016 set_style_area(&mut buf, Rect::new(0, 0, 3, 3), style);
1017
1018 assert_eq!(buf.get(1, 1).unwrap().bg, PackedRgba::rgb(10, 20, 30));
1019 assert_ne!(buf.get(0, 1).unwrap().bg, PackedRgba::rgb(10, 20, 30));
1020 assert_ne!(buf.get(1, 0).unwrap().bg, PackedRgba::rgb(10, 20, 30));
1021 assert_ne!(buf.get(2, 2).unwrap().bg, PackedRgba::rgb(10, 20, 30));
1022 }
1023
1024 #[test]
1025 fn set_style_area_respects_opacity_stack() {
1026 let mut buf = Buffer::new(1, 1);
1027 let base_fg = PackedRgba::rgb(20, 30, 40);
1028 let base_bg = PackedRgba::rgb(50, 60, 70);
1029 buf.set(0, 0, Cell::from_char('X').with_fg(base_fg).with_bg(base_bg));
1030
1031 let overlay_fg = PackedRgba::rgb(200, 100, 0);
1032 let overlay_bg = PackedRgba::rgb(0, 0, 200);
1033 buf.push_opacity(0.5);
1034 set_style_area(
1035 &mut buf,
1036 Rect::new(0, 0, 1, 1),
1037 Style::new().fg(overlay_fg).bg(overlay_bg),
1038 );
1039
1040 let cell = buf.get(0, 0).unwrap();
1041 assert_eq!(cell.fg, overlay_fg.with_opacity(0.5));
1042 assert_eq!(cell.bg, overlay_bg.with_opacity(0.5).over(base_bg));
1043 }
1044
1045 #[test]
1046 fn draw_text_span_basic() {
1047 let mut pool = GraphemePool::new();
1048 let mut frame = Frame::new(10, 1, &mut pool);
1049 let end_x = draw_text_span(&mut frame, 0, 0, "ABC", Style::default(), 10);
1050
1051 assert_eq!(end_x, 3);
1052 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
1053 assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('B'));
1054 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
1055 }
1056
1057 #[test]
1058 fn draw_text_span_clipped_at_max_x() {
1059 let mut pool = GraphemePool::new();
1060 let mut frame = Frame::new(10, 1, &mut pool);
1061 let end_x = draw_text_span(&mut frame, 0, 0, "ABCDEF", Style::default(), 3);
1062
1063 assert_eq!(end_x, 3);
1064 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
1065 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
1066 assert!(frame.buffer.get(3, 0).unwrap().is_empty());
1068 }
1069
1070 #[test]
1071 fn draw_text_span_starts_at_offset() {
1072 let mut pool = GraphemePool::new();
1073 let mut frame = Frame::new(10, 1, &mut pool);
1074 let end_x = draw_text_span(&mut frame, 5, 0, "XY", Style::default(), 10);
1075
1076 assert_eq!(end_x, 7);
1077 assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('X'));
1078 assert_eq!(frame.buffer.get(6, 0).unwrap().content.as_char(), Some('Y'));
1079 assert!(frame.buffer.get(4, 0).unwrap().is_empty());
1080 }
1081
1082 #[test]
1083 fn draw_text_span_empty_string() {
1084 let mut pool = GraphemePool::new();
1085 let mut frame = Frame::new(5, 1, &mut pool);
1086 let end_x = draw_text_span(&mut frame, 0, 0, "", Style::default(), 5);
1087 assert_eq!(end_x, 0);
1088 }
1089
1090 #[test]
1091 fn draw_text_span_applies_style() {
1092 let mut pool = GraphemePool::new();
1093 let mut frame = Frame::new(5, 1, &mut pool);
1094 let style = Style::new().fg(PackedRgba::rgb(255, 128, 0));
1095 draw_text_span(&mut frame, 0, 0, "A", style, 5);
1096
1097 assert_eq!(
1098 frame.buffer.get(0, 0).unwrap().fg,
1099 PackedRgba::rgb(255, 128, 0)
1100 );
1101 }
1102
1103 #[test]
1104 fn draw_text_span_preserves_existing_overlay_fg_and_bg() {
1105 let mut pool = GraphemePool::new();
1106 let mut frame = Frame::new(3, 1, &mut pool);
1107 frame.buffer.set(
1108 0,
1109 0,
1110 Cell::from_char('x').with_fg(PackedRgba::rgb(200, 40, 10)),
1111 );
1112 set_style_area(
1113 &mut frame.buffer,
1114 Rect::new(0, 0, 1, 1),
1115 Style::new().bg(PackedRgba::rgb(20, 30, 40)),
1116 );
1117
1118 draw_text_span(&mut frame, 0, 0, "A", Style::default(), 1);
1119
1120 let cell = frame.buffer.get(0, 0).unwrap();
1121 assert_eq!(cell.content.as_char(), Some('A'));
1122 assert_eq!(cell.fg, PackedRgba::rgb(200, 40, 10));
1123 assert_eq!(cell.bg, PackedRgba::rgb(20, 30, 40));
1124 }
1125
1126 #[test]
1127 fn draw_text_span_drops_stale_link_id_but_keeps_style_flags() {
1128 use ftui_render::cell::{CellAttrs, StyleFlags as CellFlags};
1129
1130 let mut pool = GraphemePool::new();
1131 let mut frame = Frame::new(3, 1, &mut pool);
1132 frame.buffer.set(
1133 0,
1134 0,
1135 Cell::from_char('x').with_attrs(CellAttrs::new(CellFlags::UNDERLINE, 42)),
1136 );
1137
1138 draw_text_span(&mut frame, 0, 0, "A", Style::default(), 1);
1139
1140 let cell = frame.buffer.get(0, 0).unwrap();
1141 assert_eq!(cell.content.as_char(), Some('A'));
1142 assert!(cell.attrs.has_flag(CellFlags::UNDERLINE));
1143 assert_eq!(cell.attrs.link_id(), 0);
1144 }
1145
1146 #[test]
1147 fn draw_text_span_max_x_at_start_draws_nothing() {
1148 let mut pool = GraphemePool::new();
1149 let mut frame = Frame::new(5, 1, &mut pool);
1150 let end_x = draw_text_span(&mut frame, 3, 0, "ABC", Style::default(), 3);
1151 assert_eq!(end_x, 3);
1152 assert!(frame.buffer.get(3, 0).unwrap().is_empty());
1153 }
1154
1155 #[test]
1156 fn widget_is_essential_default_false() {
1157 struct DummyWidget;
1158 impl Widget for DummyWidget {
1159 fn render(&self, _: Rect, _: &mut Frame) {}
1160 }
1161 assert!(!DummyWidget.is_essential());
1162 }
1163
1164 #[test]
1165 fn budgeted_new_and_inner() {
1166 struct TestW;
1167 impl Widget for TestW {
1168 fn render(&self, _: Rect, _: &mut Frame) {}
1169 }
1170 let b = Budgeted::new(42, TestW);
1171 assert_eq!(b.widget_id, 42);
1172 let _ = b.inner(); }
1174
1175 #[test]
1176 fn budgeted_with_signal() {
1177 struct TestW;
1178 impl Widget for TestW {
1179 fn render(&self, _: Rect, _: &mut Frame) {}
1180 }
1181 let sig = WidgetSignal::new(99);
1182 let b = Budgeted::new(42, TestW).with_signal(sig);
1183 assert_eq!(b.signal.widget_id, 42);
1185 }
1186
1187 #[test]
1188 fn set_style_area_transparent_bg_is_noop() {
1189 let mut buf = Buffer::new(1, 1);
1190 let base = PackedRgba::rgb(100, 100, 100);
1191 buf.set(0, 0, Cell::default().with_bg(base));
1192
1193 let transparent = PackedRgba::rgba(255, 0, 0, 0);
1195 set_style_area(
1196 &mut buf,
1197 Rect::new(0, 0, 1, 1),
1198 Style::new().bg(transparent),
1199 );
1200 assert_eq!(buf.get(0, 0).unwrap().bg, base);
1201 }
1202
1203 #[test]
1204 fn set_style_area_opaque_bg_replaces() {
1205 let mut buf = Buffer::new(1, 1);
1206 buf.set(
1207 0,
1208 0,
1209 Cell::default().with_bg(PackedRgba::rgb(100, 100, 100)),
1210 );
1211
1212 let opaque = PackedRgba::rgba(0, 255, 0, 255);
1213 set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().bg(opaque));
1214 assert_eq!(buf.get(0, 0).unwrap().bg, opaque);
1215 }
1216
1217 #[test]
1218 fn draw_text_span_scrolled_skips_chars() {
1219 let mut pool = GraphemePool::new();
1220 let mut frame = Frame::new(10, 1, &mut pool);
1221 let end_x =
1223 draw_text_span_scrolled(&mut frame, 0, 0, "ABCDE", Style::default(), 10, 2, None);
1224
1225 assert_eq!(end_x, 3);
1226 assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('C'));
1227 assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('D'));
1228 assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('E'));
1229 }
1230}