Skip to main content

ftui_widgets/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Core widgets for FrankenTUI.
4//!
5//! This crate provides the [`Widget`] and [`StatefulWidget`] traits, along with
6//! a collection of ready-to-use widgets for building terminal UIs.
7//!
8//! # Widget Trait Design
9//!
10//! Widgets render into a [`Frame`] rather than directly into a [`Buffer`]. The Frame
11//! provides access to several subsystems beyond the cell grid:
12//!
13//! - **`frame.buffer`** - The cell grid for drawing characters and styles
14//! - **`frame.hit_grid`** - Optional mouse hit testing (for interactive widgets)
15//! - **`frame.cursor_position`** - Cursor placement (for input widgets)
16//! - **`frame.cursor_visible`** - Cursor visibility control
17//! - **`frame.degradation`** - Performance budget hints (for adaptive rendering)
18//!
19//! # Role in FrankenTUI
20//! `ftui-widgets` is the standard widget library. It provides the reusable
21//! building blocks (tables, lists, inputs, graphs, etc.) that most apps will
22//! render inside their `view()` functions.
23//!
24//! # How it fits in the system
25//! Widgets render into `ftui-render::Frame` using `ftui-style` for appearance
26//! and `ftui-text` for text measurement and wrapping. The runtime drives these
27//! widgets by calling your model's `view()` on each frame.
28//!
29//! # Widget Categories
30//!
31//! Widgets fall into four categories based on which Frame features they use:
32//!
33//! ## Category A: Simple Buffer-Only Widgets
34//!
35//! Most widgets only need buffer access. These are the simplest to implement:
36//!
37//! ```ignore
38//! impl Widget for MyWidget {
39//!     fn render(&self, area: Rect, frame: &mut Frame) {
40//!         // Just write to the buffer
41//!         frame.buffer.set(area.x, area.y, Cell::from_char('X'));
42//!     }
43//! }
44//! ```
45//!
46//! Examples: [`block::Block`], [`paragraph::Paragraph`], [`rule::Rule`], [`StatusLine`]
47//!
48//! ## Category B: Interactive Widgets with Hit Testing
49//!
50//! Widgets that handle mouse clicks register hit regions:
51//!
52//! ```ignore
53//! impl Widget for ClickableList {
54//!     fn render(&self, area: Rect, frame: &mut Frame) {
55//!         // Draw items...
56//!         for (i, item) in self.items.iter().enumerate() {
57//!             let row_area = Rect::new(area.x, area.y + i as u16, area.width, 1);
58//!             // Draw item to buffer...
59//!
60//!             // Register hit region for mouse interaction
61//!             if let Some(id) = self.hit_id {
62//!                 frame.register_hit(row_area, id, HitRegion::Content, i as u64);
63//!             }
64//!         }
65//!     }
66//! }
67//! ```
68//!
69//! Examples: [`list::List`], [`table::Table`], [`scrollbar::Scrollbar`]
70//!
71//! ## Category C: Input Widgets with Cursor Control
72//!
73//! Text input widgets need to position the cursor:
74//!
75//! ```ignore
76//! impl Widget for TextInput {
77//!     fn render(&self, area: Rect, frame: &mut Frame) {
78//!         // Draw the input content...
79//!
80//!         // Position cursor when focused
81//!         if self.focused {
82//!             let cursor_x = area.x + self.cursor_offset as u16;
83//!             frame.cursor_position = Some((cursor_x, area.y));
84//!             frame.cursor_visible = true;
85//!         }
86//!     }
87//! }
88//! ```
89//!
90//! Examples: [`TextInput`](input::TextInput)
91//!
92//! ## Category D: Adaptive Widgets with Degradation Support
93//!
94//! Complex widgets can adapt their rendering based on performance budget:
95//!
96//! ```ignore
97//! impl Widget for FancyProgressBar {
98//!     fn render(&self, area: Rect, frame: &mut Frame) {
99//!         let deg = frame.buffer.degradation;
100//!
101//!         if !deg.render_decorative() {
102//!             // Skip decorative elements at reduced budgets
103//!             return;
104//!         }
105//!
106//!         if deg.apply_styling() {
107//!             // Use full styling and effects
108//!         } else {
109//!             // Use simplified ASCII rendering
110//!         }
111//!     }
112//! }
113//! ```
114//!
115//! Examples: [`ProgressBar`](progress::ProgressBar), [`Spinner`](spinner::Spinner)
116//!
117//! # Essential vs Decorative Widgets
118//!
119//! The [`Widget::is_essential`] method indicates whether a widget should always render,
120//! even at `EssentialOnly` degradation level:
121//!
122//! - **Essential**: Text inputs, primary content, status information
123//! - **Decorative**: Borders, scrollbars, spinners, visual separators
124//!
125//! [`Frame`]: ftui_render::frame::Frame
126//! [`Buffer`]: ftui_render::buffer::Buffer
127
128pub mod adaptive_radix;
129pub mod align;
130/// Badge widget (status/priority pills).
131pub mod badge;
132/// Block widget with borders, titles, and padding.
133pub 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;
142/// Galaxy-brain decision card widget with progressive-disclosure transparency.
143pub mod decision_card;
144/// Reusable diagnostic logging and telemetry substrate for JSONL diagnostics.
145pub mod diagnostics;
146/// Drag-and-drop protocol: [`Draggable`](drag::Draggable) sources, [`DropTarget`](drag::DropTarget) targets, and [`DragPayload`](drag::DragPayload).
147pub mod drag;
148/// Drift-triggered fallback visualization with per-domain confidence sparklines.
149pub mod drift_visualization;
150/// Elias-Fano encoding for monotone integer sequences (succinct prefix sums).
151pub mod elias_fano;
152pub mod emoji;
153pub mod error_boundary;
154/// Fenwick tree (Binary Indexed Tree) for O(log n) prefix sum queries.
155pub mod fenwick;
156pub mod file_picker;
157/// Focus management: navigation graph for keyboard-driven focus traversal.
158pub mod focus;
159pub mod group;
160/// Bayesian height prediction with conformal bounds for virtualized lists.
161pub mod height_predictor;
162pub mod help;
163pub mod help_registry;
164/// Utility-based keybinding hint ranking with Bayesian posteriors.
165pub mod hint_ranker;
166/// Undo/redo history panel widget for displaying command history.
167pub mod history_panel;
168pub mod input;
169/// UI Inspector overlay for debugging widget trees and hit-test regions.
170pub 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;
179/// Intrinsic sizing support for content-aware layout.
180pub mod measurable;
181/// Measure cache for memoizing widget measure results.
182pub mod measure_cache;
183pub mod modal;
184/// Shared mouse event result type for widget mouse handling.
185pub mod mouse;
186/// Notification queue for managing multiple toast notifications.
187pub mod notification_queue;
188pub mod padding;
189pub mod paginator;
190pub mod panel;
191/// Multi-line styled text paragraph widget.
192pub mod paragraph;
193pub mod popover;
194pub mod pretty;
195pub mod progress;
196/// Receipt verifier panel — renders signed-decision-receipt verifier verdicts.
197pub mod receipt_verifier_panel;
198pub mod rule;
199pub mod scrollbar;
200pub mod sparkline;
201pub mod spinner;
202/// Opt-in persistable state trait for widgets.
203pub mod stateful;
204pub mod status_line;
205pub mod stopwatch;
206/// Table widget with rows, columns, and selection.
207pub mod table;
208pub mod tabs;
209pub mod textarea;
210pub mod timer;
211/// Toast widget for transient notifications.
212pub mod toast;
213pub mod tree;
214/// Undo support for widgets.
215pub mod undo_support;
216/// Inline validation error display widget.
217pub 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    /// Serialize tests that install tracing subscribers and rebuild the
226    /// callsite interest cache.
227    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        // Preserve serialization even if an earlier tracing assertion panicked.
241        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
281// Toast notification widget
282pub 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
288// Notification queue manager
289pub use notification_queue::{
290    NotificationPriority, NotificationQueue, QueueAction, QueueConfig, QueueStats,
291};
292
293// Re-export accessibility trait and types for widget implementations.
294pub use ftui_a11y::Accessible;
295
296// Shared mouse result type for widget mouse handling
297pub use mouse::MouseResult;
298
299// Measurable widget support for intrinsic sizing
300pub use measurable::{MeasurableWidget, SizeConstraints};
301
302// Measure cache for memoizing measure() results
303pub 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
309// UI Inspector for debugging
310pub 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
317// Focus management
318pub use focus::{
319    FocusEvent, FocusGraph, FocusGroup, FocusId, FocusIndicator, FocusIndicatorKind, FocusManager,
320    FocusNode, FocusTrap, NavDirection,
321};
322
323// Drag-and-drop protocol (source + target)
324pub use drag::{
325    DragConfig, DragPayload, DragState, Draggable, DropPosition, DropResult, DropTarget,
326};
327
328// Stateful persistence trait
329pub use stateful::{StateKey, Stateful, VersionedState};
330
331// Widget persist state types for state-persistence
332pub use list::ListPersistState;
333pub use table::TablePersistState;
334pub use tree::TreePersistState;
335pub use virtualized::VirtualizedListPersistState;
336
337// Undo support for widgets
338pub use undo_support::{
339    ListOperation, ListUndoExt, SelectionOperation, TableOperation, TableUndoExt,
340    TextEditOperation, TextInputUndoExt, TreeOperation, TreeUndoExt, UndoSupport, UndoWidgetId,
341    WidgetTextEditCmd,
342};
343
344// Inline validation error display
345pub 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/// Generate a deterministic accessibility node ID from a widget's bounding rect.
358///
359/// Uses FNV-1a to hash the area coordinates. Stable across frames for widgets
360/// rendered at the same position, enabling efficient A11yTree diffing.
361#[must_use]
362pub(crate) fn a11y_node_id(area: Rect) -> u64 {
363    // FNV-1a 64-bit
364    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
381/// A widget that can render itself into a [`Frame`].
382///
383/// # Frame vs Buffer
384///
385/// Widgets render into a `Frame` rather than directly into a `Buffer`. This provides:
386///
387/// - **Buffer access**: `frame.buffer` for drawing cells
388/// - **Hit testing**: `frame.register_hit()` for mouse interaction
389/// - **Cursor control**: `frame.cursor_position` for input widgets
390/// - **Performance hints**: `frame.buffer.degradation` for adaptive rendering
391///
392/// # Implementation Guide
393///
394/// Most widgets only need buffer access:
395///
396/// ```ignore
397/// fn render(&self, area: Rect, frame: &mut Frame) {
398///     for y in area.y..area.bottom() {
399///         for x in area.x..area.right() {
400///             frame.buffer.set(x, y, Cell::from_char('.'));
401///         }
402///     }
403/// }
404/// ```
405///
406/// Interactive widgets should register hit regions when a `hit_id` is set.
407/// Input widgets should set `frame.cursor_position` when focused.
408///
409/// # Degradation Levels
410///
411/// Check `frame.buffer.degradation` to adapt rendering:
412///
413/// - `Full`: All features enabled
414/// - `SimpleBorders`: Skip fancy borders, use ASCII
415/// - `NoStyling`: Skip colors and attributes
416/// - `EssentialOnly`: Only render essential widgets
417/// - `Skeleton`: Minimal placeholder rendering
418///
419/// [`Frame`]: ftui_render::frame::Frame
420pub trait Widget {
421    /// Render the widget into the frame at the given area.
422    ///
423    /// The `area` defines the bounding rectangle within which the widget
424    /// should render. Widgets should respect the area bounds and not
425    /// draw outside them (the buffer's scissor stack enforces this).
426    fn render(&self, area: Rect, frame: &mut Frame);
427
428    /// Whether this widget is essential and should always render.
429    ///
430    /// Essential widgets render even at `EssentialOnly` degradation level.
431    /// Override this to return `true` for:
432    ///
433    /// - Text inputs (user needs to see what they're typing)
434    /// - Primary content areas (main information display)
435    /// - Critical status indicators
436    ///
437    /// Returns `false` by default, appropriate for decorative widgets.
438    fn is_essential(&self) -> bool {
439        false
440    }
441}
442
443/// Budget-aware wrapper that registers widget signals and respects refresh budgets.
444pub struct Budgeted<W> {
445    widget_id: u64,
446    signal: WidgetSignal,
447    inner: W,
448}
449
450impl<W> Budgeted<W> {
451    /// Wrap a widget with a stable identifier and default signal values.
452    #[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    /// Override the widget signal template.
462    #[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    /// Access the wrapped widget.
470    #[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
510/// A widget that renders based on mutable state.
511///
512/// Use `StatefulWidget` when the widget needs to:
513///
514/// - Update scroll position during render
515/// - Track selection state
516/// - Cache computed layout information
517/// - Synchronize view with external model
518///
519/// # Example
520///
521/// ```ignore
522/// pub struct ListState {
523///     pub selected: Option<usize>,
524///     pub offset: usize,
525/// }
526///
527/// impl StatefulWidget for List<'_> {
528///     type State = ListState;
529///
530///     fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
531///         // Adjust offset to keep selection visible
532///         if let Some(sel) = state.selected {
533///             if sel < state.offset {
534///                 state.offset = sel;
535///             }
536///         }
537///         // Render items starting from offset...
538///     }
539/// }
540/// ```
541///
542/// # Stateful vs Stateless
543///
544/// Prefer stateless [`Widget`] when possible. Use `StatefulWidget` only when
545/// the render pass genuinely needs to modify state (e.g., scroll adjustment).
546pub trait StatefulWidget {
547    /// The state type associated with this widget.
548    type State;
549
550    /// Render the widget into the frame, potentially modifying state.
551    ///
552    /// State modifications should be limited to:
553    /// - Scroll offset adjustments
554    /// - Selection clamping
555    /// - Layout caching
556    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State);
557}
558
559/// Merge a [`Style`] into a cell, preserving existing properties for unset fields.
560///
561/// - **Foreground / Background:** Only overwritten when the style explicitly sets
562///   the field (`Some`).  Background colours with alpha < 255 are composited via
563///   Porter-Duff SourceOver so semi-transparent overlays blend correctly.
564/// - **Attributes:** New flags are OR-ed on top of existing flags (never cleared).
565pub(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 => {}                          // Fully transparent: no-op
572            255 => cell.bg = bg,             // Fully opaque: replace
573            _ => cell.bg = bg.over(cell.bg), // Composite src-over-dst
574        }
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
582/// Apply a style to all cells in a rectangular area using **merge** semantics.
583///
584/// Only fields that are explicitly set in `style` (i.e. `Some`) are applied;
585/// unset fields leave the existing cell values intact.  This is the correct
586/// behaviour for selection / highlight overlays that specify only a background
587/// colour — per-cell foreground colours from earlier text rendering are preserved.
588///
589/// - **Background:** Alpha-aware compositing (Porter-Duff SourceOver).
590/// - **Attributes:** OR-ed on top of existing flags (never cleared).
591pub(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 => {}                          // Fully transparent: no-op
615                    255 => cell.bg = bg,             // Fully opaque: replace
616                    _ => cell.bg = bg.over(cell.bg), // Composite src-over-dst
617                }
618            }
619            if let Some(attrs) = attrs {
620                cell.attrs = cell.attrs.merged_flags(attrs);
621            }
622        }
623    }
624}
625
626/// Clear a text area with styled spaces before rendering new content.
627pub(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
637/// Clear a single text row with styled spaces before rendering new content.
638pub(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
642/// Build a text cell that inherits existing visual styling from the buffer.
643///
644/// This preserves foreground/background/style flags applied by prior area-wide
645/// overlays (for example selection/highlight passes) while intentionally
646/// dropping any stale hyperlink ID before new text is written.
647fn 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
659/// Draw a text span into a frame at the given position.
660///
661/// Returns the x position after the last drawn character.
662/// Stops at `max_x` (exclusive).
663pub(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        // Intern grapheme if needed
686        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        // set_fast() skips scissor/opacity/compositing checks for common
699        // single-width opaque cells; falls back to set() otherwise.
700        frame.buffer.set_fast(x, y, cell);
701
702        x = x.saturating_add(w as u16);
703    }
704    x
705}
706
707/// Draw a text span, optionally attaching a hyperlink.
708#[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/// Draw a text span with horizontal scrolling (skip first `scroll_x` visual cells).
722#[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    // Register link if present
736    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        // Check if this grapheme is visible
756        if next_visual_pos <= scroll_x {
757            // Fully scrolled out
758            visual_pos = next_visual_pos;
759            continue;
760        }
761
762        if visual_pos < scroll_x {
763            // Partially scrolled out (e.g. wide char starting at scroll_x - 1)
764            // We skip the whole character because we can't render half a cell.
765            visual_pos = next_visual_pos;
766            continue;
767        }
768
769        if x.saturating_add(w as u16) > max_x {
770            break;
771        }
772
773        // Intern grapheme if needed
774        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        // Apply link ID if present
787        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
799/// Helper for allocation-free case-insensitive containment check.
800pub(crate) fn contains_ignore_case(haystack: &str, needle_lower: &str) -> bool {
801    if needle_lower.is_empty() {
802        return true;
803    }
804    // Fast path for ASCII
805    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        // Naive byte-by-byte scan is fast enough for short strings (UI labels)
812        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    // Fallback for Unicode (allocates, but correct)
827    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        // Simulate: cell already has syntax-highlighted fg, selection overlay sets only bg.
872        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        // fg must survive — the selection style didn't set a fg.
876        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        // Cell starts with BOLD.
901        let mut cell = Cell::default();
902        cell.attrs = cell.attrs.with_flags(CellFlags::BOLD);
903        // Overlay adds ITALIC — should NOT clear BOLD.
904        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        // A 3-cell buffer where each cell has a distinct fg.
916        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        // Selection highlight sets only bg.
922        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        // All fg colours must be preserved.
926        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        // bg should be the highlight colour.
930        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        // Inside area should be styled
990        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        // Outside area should be default
994        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        // Should not have changed
1006        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        // 'D' should not be drawn
1067        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(); // Should not panic
1173    }
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        // with_signal should override the signal's widget_id to match
1184        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        // Alpha=0 means fully transparent, should leave bg unchanged
1194        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        // Scroll past first 2 chars of "ABCDE"
1222        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}