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;
196pub mod rule;
197pub mod scrollbar;
198pub mod sparkline;
199pub mod spinner;
200/// Opt-in persistable state trait for widgets.
201pub mod stateful;
202pub mod status_line;
203pub mod stopwatch;
204/// Table widget with rows, columns, and selection.
205pub mod table;
206pub mod tabs;
207pub mod textarea;
208pub mod timer;
209/// Toast widget for transient notifications.
210pub mod toast;
211pub mod tree;
212/// Undo support for widgets.
213pub mod undo_support;
214/// Inline validation error display widget.
215pub mod validation_error;
216pub mod virtualized;
217pub mod voi_debug_overlay;
218
219#[cfg(all(test, feature = "tracing"))]
220pub(crate) mod tracing_test_support {
221    use std::sync::{Mutex, MutexGuard, OnceLock};
222
223    /// Serialize tests that install tracing subscribers and rebuild the
224    /// callsite interest cache.
225    pub(crate) struct TraceTestGuard {
226        _lock: MutexGuard<'static, ()>,
227    }
228
229    impl Drop for TraceTestGuard {
230        fn drop(&mut self) {
231            tracing::callsite::rebuild_interest_cache();
232        }
233    }
234
235    pub(crate) fn acquire() -> TraceTestGuard {
236        static TRACE_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
237
238        // Preserve serialization even if an earlier tracing assertion panicked.
239        let lock = TRACE_TEST_LOCK
240            .get_or_init(|| Mutex::new(()))
241            .lock()
242            .unwrap_or_else(|e| e.into_inner());
243        tracing::callsite::rebuild_interest_cache();
244        TraceTestGuard { _lock: lock }
245    }
246}
247
248pub use align::{Align, VerticalAlignment};
249pub use badge::Badge;
250pub use cached::{CacheKey, CachedWidget, CachedWidgetState, FnKey, HashKey, NoCacheKey};
251pub use columns::{Column, Columns};
252pub use constraint_overlay::{ConstraintOverlay, ConstraintOverlayStyle};
253#[cfg(feature = "debug-overlay")]
254pub use debug_overlay::{
255    DebugOverlay, DebugOverlayOptions, DebugOverlayState, DebugOverlayStateful,
256    DebugOverlayStatefulState,
257};
258pub use decision_card::DecisionCard;
259pub use group::Group;
260pub use help_registry::{HelpContent, HelpId, HelpRegistry, Keybinding};
261pub use history_panel::{HistoryEntry, HistoryPanel, HistoryPanelMode};
262pub use layout_debugger::{LayoutConstraints, LayoutDebugger, LayoutRecord};
263pub use log_ring::LogRing;
264pub use log_viewer::{LogViewer, LogViewerState, LogWrapMode, SearchConfig, SearchMode};
265pub use paginator::{Paginator, PaginatorMode};
266pub use panel::Panel;
267pub use sparkline::Sparkline;
268pub use status_line::{StatusItem, StatusLine};
269pub use tabs::{Tab, Tabs, TabsState};
270pub use virtualized::{
271    HeightCache, ItemHeight, RenderItem, Virtualized, VirtualizedList, VirtualizedListState,
272    VirtualizedStorage,
273};
274pub use voi_debug_overlay::{
275    VoiDebugOverlay, VoiDecisionSummary, VoiLedgerEntry, VoiObservationSummary, VoiOverlayData,
276    VoiOverlayStyle, VoiPosteriorSummary,
277};
278
279// Toast notification widget
280pub use toast::{
281    KeyEvent as ToastKeyEvent, Toast, ToastAction, ToastAnimationConfig, ToastAnimationPhase,
282    ToastAnimationState, ToastConfig, ToastContent, ToastEasing, ToastEntranceAnimation,
283    ToastEvent, ToastExitAnimation, ToastIcon, ToastId, ToastPosition, ToastState, ToastStyle,
284};
285
286// Notification queue manager
287pub use notification_queue::{
288    NotificationPriority, NotificationQueue, QueueAction, QueueConfig, QueueStats,
289};
290
291// Re-export accessibility trait and types for widget implementations.
292pub use ftui_a11y::Accessible;
293
294// Shared mouse result type for widget mouse handling
295pub use mouse::MouseResult;
296
297// Measurable widget support for intrinsic sizing
298pub use measurable::{MeasurableWidget, SizeConstraints};
299
300// Measure cache for memoizing measure() results
301pub use measure_cache::{CacheStats, MeasureCache, WidgetId};
302pub use modal::{
303    BackdropConfig, MODAL_HIT_BACKDROP, MODAL_HIT_CONTENT, Modal, ModalAction, ModalConfig,
304    ModalPosition, ModalSizeConstraints, ModalState,
305};
306
307// UI Inspector for debugging
308pub use inspector::{
309    DiagnosticEntry, DiagnosticEventKind, DiagnosticLog, HitInfo, InspectorMode, InspectorOverlay,
310    InspectorState, InspectorStyle, TelemetryCallback, TelemetryHooks, WidgetInfo,
311    diagnostics_enabled, init_diagnostics, is_deterministic_mode, reset_event_counter,
312    set_diagnostics_enabled,
313};
314
315// Focus management
316pub use focus::{
317    FocusEvent, FocusGraph, FocusGroup, FocusId, FocusIndicator, FocusIndicatorKind, FocusManager,
318    FocusNode, FocusTrap, NavDirection,
319};
320
321// Drag-and-drop protocol (source + target)
322pub use drag::{
323    DragConfig, DragPayload, DragState, Draggable, DropPosition, DropResult, DropTarget,
324};
325
326// Stateful persistence trait
327pub use stateful::{StateKey, Stateful, VersionedState};
328
329// Widget persist state types for state-persistence
330pub use list::ListPersistState;
331pub use table::TablePersistState;
332pub use tree::TreePersistState;
333pub use virtualized::VirtualizedListPersistState;
334
335// Undo support for widgets
336pub use undo_support::{
337    ListOperation, ListUndoExt, SelectionOperation, TableOperation, TableUndoExt,
338    TextEditOperation, TextInputUndoExt, TreeOperation, TreeUndoExt, UndoSupport, UndoWidgetId,
339    WidgetTextEditCmd,
340};
341
342// Inline validation error display
343pub use validation_error::{
344    ANIMATION_DURATION_MS, ERROR_BG_DEFAULT, ERROR_FG_DEFAULT, ERROR_ICON_DEFAULT,
345    ValidationErrorDisplay, ValidationErrorState,
346};
347
348use ftui_core::geometry::Rect;
349use ftui_render::buffer::Buffer;
350use ftui_render::cell::Cell;
351use ftui_render::frame::{Frame, WidgetSignal};
352use ftui_style::Style;
353use ftui_text::grapheme_width;
354
355/// Generate a deterministic accessibility node ID from a widget's bounding rect.
356///
357/// Uses FNV-1a to hash the area coordinates. Stable across frames for widgets
358/// rendered at the same position, enabling efficient A11yTree diffing.
359#[must_use]
360pub(crate) fn a11y_node_id(area: Rect) -> u64 {
361    // FNV-1a 64-bit
362    const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
363    const FNV_PRIME: u64 = 1_099_511_628_211;
364    let mut h = FNV_OFFSET;
365    for byte in area
366        .x
367        .to_le_bytes()
368        .iter()
369        .chain(&area.y.to_le_bytes())
370        .chain(&area.width.to_le_bytes())
371        .chain(&area.height.to_le_bytes())
372    {
373        h ^= u64::from(*byte);
374        h = h.wrapping_mul(FNV_PRIME);
375    }
376    h
377}
378
379/// A widget that can render itself into a [`Frame`].
380///
381/// # Frame vs Buffer
382///
383/// Widgets render into a `Frame` rather than directly into a `Buffer`. This provides:
384///
385/// - **Buffer access**: `frame.buffer` for drawing cells
386/// - **Hit testing**: `frame.register_hit()` for mouse interaction
387/// - **Cursor control**: `frame.cursor_position` for input widgets
388/// - **Performance hints**: `frame.buffer.degradation` for adaptive rendering
389///
390/// # Implementation Guide
391///
392/// Most widgets only need buffer access:
393///
394/// ```ignore
395/// fn render(&self, area: Rect, frame: &mut Frame) {
396///     for y in area.y..area.bottom() {
397///         for x in area.x..area.right() {
398///             frame.buffer.set(x, y, Cell::from_char('.'));
399///         }
400///     }
401/// }
402/// ```
403///
404/// Interactive widgets should register hit regions when a `hit_id` is set.
405/// Input widgets should set `frame.cursor_position` when focused.
406///
407/// # Degradation Levels
408///
409/// Check `frame.buffer.degradation` to adapt rendering:
410///
411/// - `Full`: All features enabled
412/// - `SimpleBorders`: Skip fancy borders, use ASCII
413/// - `NoStyling`: Skip colors and attributes
414/// - `EssentialOnly`: Only render essential widgets
415/// - `Skeleton`: Minimal placeholder rendering
416///
417/// [`Frame`]: ftui_render::frame::Frame
418pub trait Widget {
419    /// Render the widget into the frame at the given area.
420    ///
421    /// The `area` defines the bounding rectangle within which the widget
422    /// should render. Widgets should respect the area bounds and not
423    /// draw outside them (the buffer's scissor stack enforces this).
424    fn render(&self, area: Rect, frame: &mut Frame);
425
426    /// Whether this widget is essential and should always render.
427    ///
428    /// Essential widgets render even at `EssentialOnly` degradation level.
429    /// Override this to return `true` for:
430    ///
431    /// - Text inputs (user needs to see what they're typing)
432    /// - Primary content areas (main information display)
433    /// - Critical status indicators
434    ///
435    /// Returns `false` by default, appropriate for decorative widgets.
436    fn is_essential(&self) -> bool {
437        false
438    }
439}
440
441/// Budget-aware wrapper that registers widget signals and respects refresh budgets.
442pub struct Budgeted<W> {
443    widget_id: u64,
444    signal: WidgetSignal,
445    inner: W,
446}
447
448impl<W> Budgeted<W> {
449    /// Wrap a widget with a stable identifier and default signal values.
450    #[must_use]
451    pub fn new(widget_id: u64, inner: W) -> Self {
452        Self {
453            widget_id,
454            signal: WidgetSignal::new(widget_id),
455            inner,
456        }
457    }
458
459    /// Override the widget signal template.
460    #[must_use]
461    pub fn with_signal(mut self, mut signal: WidgetSignal) -> Self {
462        signal.widget_id = self.widget_id;
463        self.signal = signal;
464        self
465    }
466
467    /// Access the wrapped widget.
468    #[must_use]
469    pub fn inner(&self) -> &W {
470        &self.inner
471    }
472}
473
474impl<W: Widget> Widget for Budgeted<W> {
475    fn render(&self, area: Rect, frame: &mut Frame) {
476        let mut signal = self.signal.clone();
477        signal.widget_id = self.widget_id;
478        signal.essential = self.inner.is_essential();
479        signal.area_cells = area.width as u32 * area.height as u32;
480        frame.register_widget_signal(signal);
481
482        if frame.should_render_widget(self.widget_id, self.inner.is_essential()) {
483            self.inner.render(area, frame);
484        }
485    }
486
487    fn is_essential(&self) -> bool {
488        self.inner.is_essential()
489    }
490}
491
492impl<W: StatefulWidget + Widget> StatefulWidget for Budgeted<W> {
493    type State = W::State;
494
495    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
496        let mut signal = self.signal.clone();
497        signal.widget_id = self.widget_id;
498        signal.essential = self.inner.is_essential();
499        signal.area_cells = area.width as u32 * area.height as u32;
500        frame.register_widget_signal(signal);
501
502        if frame.should_render_widget(self.widget_id, self.inner.is_essential()) {
503            StatefulWidget::render(&self.inner, area, frame, state);
504        }
505    }
506}
507
508/// A widget that renders based on mutable state.
509///
510/// Use `StatefulWidget` when the widget needs to:
511///
512/// - Update scroll position during render
513/// - Track selection state
514/// - Cache computed layout information
515/// - Synchronize view with external model
516///
517/// # Example
518///
519/// ```ignore
520/// pub struct ListState {
521///     pub selected: Option<usize>,
522///     pub offset: usize,
523/// }
524///
525/// impl StatefulWidget for List<'_> {
526///     type State = ListState;
527///
528///     fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
529///         // Adjust offset to keep selection visible
530///         if let Some(sel) = state.selected {
531///             if sel < state.offset {
532///                 state.offset = sel;
533///             }
534///         }
535///         // Render items starting from offset...
536///     }
537/// }
538/// ```
539///
540/// # Stateful vs Stateless
541///
542/// Prefer stateless [`Widget`] when possible. Use `StatefulWidget` only when
543/// the render pass genuinely needs to modify state (e.g., scroll adjustment).
544pub trait StatefulWidget {
545    /// The state type associated with this widget.
546    type State;
547
548    /// Render the widget into the frame, potentially modifying state.
549    ///
550    /// State modifications should be limited to:
551    /// - Scroll offset adjustments
552    /// - Selection clamping
553    /// - Layout caching
554    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State);
555}
556
557/// Merge a [`Style`] into a cell, preserving existing properties for unset fields.
558///
559/// - **Foreground / Background:** Only overwritten when the style explicitly sets
560///   the field (`Some`).  Background colours with alpha < 255 are composited via
561///   Porter-Duff SourceOver so semi-transparent overlays blend correctly.
562/// - **Attributes:** New flags are OR-ed on top of existing flags (never cleared).
563pub(crate) fn apply_style(cell: &mut Cell, style: Style) {
564    if let Some(fg) = style.fg {
565        cell.fg = fg;
566    }
567    if let Some(bg) = style.bg {
568        match bg.a() {
569            0 => {}                          // Fully transparent: no-op
570            255 => cell.bg = bg,             // Fully opaque: replace
571            _ => cell.bg = bg.over(cell.bg), // Composite src-over-dst
572        }
573    }
574    if let Some(attrs) = style.attrs {
575        let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
576        cell.attrs = cell.attrs.merged_flags(cell_flags);
577    }
578}
579
580/// Apply a style to all cells in a rectangular area using **merge** semantics.
581///
582/// Only fields that are explicitly set in `style` (i.e. `Some`) are applied;
583/// unset fields leave the existing cell values intact.  This is the correct
584/// behaviour for selection / highlight overlays that specify only a background
585/// colour — per-cell foreground colours from earlier text rendering are preserved.
586///
587/// - **Background:** Alpha-aware compositing (Porter-Duff SourceOver).
588/// - **Attributes:** OR-ed on top of existing flags (never cleared).
589pub(crate) fn set_style_area(buf: &mut Buffer, area: Rect, style: Style) {
590    if style.is_empty() {
591        return;
592    }
593    let clipped = area.intersection(&buf.current_scissor());
594    if clipped.is_empty() {
595        return;
596    }
597
598    let opacity = buf.current_opacity();
599    let fg = style.fg.map(|fg| fg.with_opacity(opacity));
600    let bg = style.bg.map(|bg| bg.with_opacity(opacity));
601    let attrs = style.attrs.map(ftui_render::cell::StyleFlags::from);
602    for y in clipped.y..clipped.bottom() {
603        let Some(row) = buf.row_cells_mut_span(y, clipped.x, clipped.right()) else {
604            continue;
605        };
606        for cell in row {
607            if let Some(fg) = fg {
608                cell.fg = fg;
609            }
610            if let Some(bg) = bg {
611                match bg.a() {
612                    0 => {}                          // Fully transparent: no-op
613                    255 => cell.bg = bg,             // Fully opaque: replace
614                    _ => cell.bg = bg.over(cell.bg), // Composite src-over-dst
615                }
616            }
617            if let Some(attrs) = attrs {
618                cell.attrs = cell.attrs.merged_flags(attrs);
619            }
620        }
621    }
622}
623
624/// Clear a text area with styled spaces before rendering new content.
625pub(crate) fn clear_text_area(frame: &mut Frame, area: Rect, style: Style) {
626    if area.width == 0 || area.height == 0 {
627        return;
628    }
629
630    let mut cell = Cell::from_char(' ');
631    apply_style(&mut cell, style);
632    frame.buffer.fill(area, cell);
633}
634
635/// Clear a single text row with styled spaces before rendering new content.
636pub(crate) fn clear_text_row(frame: &mut Frame, area: Rect, style: Style) {
637    clear_text_area(frame, Rect::new(area.x, area.y, area.width, 1), style);
638}
639
640/// Build a text cell that inherits existing visual styling from the buffer.
641///
642/// This preserves foreground/background/style flags applied by prior area-wide
643/// overlays (for example selection/highlight passes) while intentionally
644/// dropping any stale hyperlink ID before new text is written.
645fn inherited_text_cell(
646    frame: &Frame,
647    x: u16,
648    y: u16,
649    content: ftui_render::cell::CellContent,
650) -> Cell {
651    let mut cell = frame.buffer.get(x, y).copied().unwrap_or_default();
652    cell.content = content;
653    cell.attrs = ftui_render::cell::CellAttrs::new(cell.attrs.flags(), 0);
654    cell
655}
656
657/// Draw a text span into a frame at the given position.
658///
659/// Returns the x position after the last drawn character.
660/// Stops at `max_x` (exclusive).
661pub(crate) fn draw_text_span(
662    frame: &mut Frame,
663    mut x: u16,
664    y: u16,
665    content: &str,
666    style: Style,
667    max_x: u16,
668) -> u16 {
669    use unicode_segmentation::UnicodeSegmentation;
670
671    for grapheme in content.graphemes(true) {
672        if x >= max_x {
673            break;
674        }
675        let w = grapheme_width(grapheme);
676        if w == 0 {
677            continue;
678        }
679        if x.saturating_add(w as u16) > max_x {
680            break;
681        }
682
683        // Intern grapheme if needed
684        let cell_content = if w > 1 || grapheme.chars().count() > 1 {
685            let id = frame.intern_with_width(grapheme, w as u8);
686            ftui_render::cell::CellContent::from_grapheme(id)
687        } else if let Some(c) = grapheme.chars().next() {
688            ftui_render::cell::CellContent::from_char(c)
689        } else {
690            continue;
691        };
692
693        let mut cell = inherited_text_cell(frame, x, y, cell_content);
694        apply_style(&mut cell, style);
695
696        // set_fast() skips scissor/opacity/compositing checks for common
697        // single-width opaque cells; falls back to set() otherwise.
698        frame.buffer.set_fast(x, y, cell);
699
700        x = x.saturating_add(w as u16);
701    }
702    x
703}
704
705/// Draw a text span, optionally attaching a hyperlink.
706#[allow(dead_code)]
707pub(crate) fn draw_text_span_with_link(
708    frame: &mut Frame,
709    x: u16,
710    y: u16,
711    content: &str,
712    style: Style,
713    max_x: u16,
714    link_url: Option<&str>,
715) -> u16 {
716    draw_text_span_scrolled(frame, x, y, content, style, max_x, 0, link_url)
717}
718
719/// Draw a text span with horizontal scrolling (skip first `scroll_x` visual cells).
720#[allow(dead_code, clippy::too_many_arguments)]
721pub(crate) fn draw_text_span_scrolled(
722    frame: &mut Frame,
723    mut x: u16,
724    y: u16,
725    content: &str,
726    style: Style,
727    max_x: u16,
728    scroll_x: u16,
729    link_url: Option<&str>,
730) -> u16 {
731    use unicode_segmentation::UnicodeSegmentation;
732
733    // Register link if present
734    let link_id = if let Some(url) = link_url {
735        frame.register_link(url)
736    } else {
737        0
738    };
739
740    let mut visual_pos: u16 = 0;
741
742    for grapheme in content.graphemes(true) {
743        if x >= max_x {
744            break;
745        }
746        let w = grapheme_width(grapheme);
747        if w == 0 {
748            continue;
749        }
750
751        let next_visual_pos = visual_pos.saturating_add(w as u16);
752
753        // Check if this grapheme is visible
754        if next_visual_pos <= scroll_x {
755            // Fully scrolled out
756            visual_pos = next_visual_pos;
757            continue;
758        }
759
760        if visual_pos < scroll_x {
761            // Partially scrolled out (e.g. wide char starting at scroll_x - 1)
762            // We skip the whole character because we can't render half a cell.
763            visual_pos = next_visual_pos;
764            continue;
765        }
766
767        if x.saturating_add(w as u16) > max_x {
768            break;
769        }
770
771        // Intern grapheme if needed
772        let cell_content = if w > 1 || grapheme.chars().count() > 1 {
773            let id = frame.intern_with_width(grapheme, w as u8);
774            ftui_render::cell::CellContent::from_grapheme(id)
775        } else if let Some(c) = grapheme.chars().next() {
776            ftui_render::cell::CellContent::from_char(c)
777        } else {
778            continue;
779        };
780
781        let mut cell = inherited_text_cell(frame, x, y, cell_content);
782        apply_style(&mut cell, style);
783
784        // Apply link ID if present
785        if link_id != 0 {
786            cell.attrs = cell.attrs.with_link(link_id);
787        }
788
789        frame.buffer.set_fast(x, y, cell);
790
791        x = x.saturating_add(w as u16);
792        visual_pos = next_visual_pos;
793    }
794    x
795}
796
797/// Helper for allocation-free case-insensitive containment check.
798pub(crate) fn contains_ignore_case(haystack: &str, needle_lower: &str) -> bool {
799    if needle_lower.is_empty() {
800        return true;
801    }
802    // Fast path for ASCII
803    if haystack.is_ascii() && needle_lower.is_ascii() {
804        let haystack_bytes = haystack.as_bytes();
805        let needle_bytes = needle_lower.as_bytes();
806        if needle_bytes.len() > haystack_bytes.len() {
807            return false;
808        }
809        // Naive byte-by-byte scan is fast enough for short strings (UI labels)
810        for i in 0..=haystack_bytes.len() - needle_bytes.len() {
811            let mut match_found = true;
812            for (j, &b) in needle_bytes.iter().enumerate() {
813                if haystack_bytes[i + j].to_ascii_lowercase() != b {
814                    match_found = false;
815                    break;
816                }
817            }
818            if match_found {
819                return true;
820            }
821        }
822        return false;
823    }
824    // Fallback for Unicode (allocates, but correct)
825    haystack.to_lowercase().contains(needle_lower)
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831    use ftui_render::cell::PackedRgba;
832    use ftui_render::grapheme_pool::GraphemePool;
833
834    #[test]
835    fn apply_style_sets_fg() {
836        let mut cell = Cell::default();
837        let style = Style::new().fg(PackedRgba::rgb(255, 0, 0));
838        apply_style(&mut cell, style);
839        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
840    }
841
842    #[test]
843    fn apply_style_sets_bg() {
844        let mut cell = Cell::default();
845        let style = Style::new().bg(PackedRgba::rgb(0, 255, 0));
846        apply_style(&mut cell, style);
847        assert_eq!(cell.bg, PackedRgba::rgb(0, 255, 0));
848    }
849
850    #[test]
851    fn apply_style_preserves_content() {
852        let mut cell = Cell::from_char('Z');
853        let style = Style::new().fg(PackedRgba::rgb(1, 2, 3));
854        apply_style(&mut cell, style);
855        assert_eq!(cell.content.as_char(), Some('Z'));
856    }
857
858    #[test]
859    fn apply_style_empty_is_noop() {
860        let original = Cell::default();
861        let mut cell = Cell::default();
862        apply_style(&mut cell, Style::default());
863        assert_eq!(cell.fg, original.fg);
864        assert_eq!(cell.bg, original.bg);
865    }
866
867    #[test]
868    fn apply_style_bg_only_preserves_fg() {
869        // Simulate: cell already has syntax-highlighted fg, selection overlay sets only bg.
870        let mut cell = Cell::from_char('x').with_fg(PackedRgba::rgb(0, 200, 0));
871        let selection = Style::new().bg(PackedRgba::rgb(0, 0, 180));
872        apply_style(&mut cell, selection);
873        // fg must survive — the selection style didn't set a fg.
874        assert_eq!(cell.fg, PackedRgba::rgb(0, 200, 0));
875        assert_eq!(cell.bg, PackedRgba::rgb(0, 0, 180));
876    }
877
878    #[test]
879    fn apply_style_composites_alpha_bg() {
880        let base_bg = PackedRgba::rgb(200, 0, 0);
881        let mut cell = Cell::default().with_bg(base_bg);
882        let overlay = PackedRgba::rgba(0, 0, 200, 128);
883        apply_style(&mut cell, Style::new().bg(overlay));
884        assert_eq!(cell.bg, overlay.over(base_bg));
885    }
886
887    #[test]
888    fn apply_style_transparent_bg_is_noop() {
889        let base_bg = PackedRgba::rgb(100, 100, 100);
890        let mut cell = Cell::default().with_bg(base_bg);
891        apply_style(&mut cell, Style::new().bg(PackedRgba::rgba(255, 0, 0, 0)));
892        assert_eq!(cell.bg, base_bg);
893    }
894
895    #[test]
896    fn apply_style_merges_attrs_not_replaces() {
897        use ftui_render::cell::StyleFlags as CellFlags;
898        // Cell starts with BOLD.
899        let mut cell = Cell::default();
900        cell.attrs = cell.attrs.with_flags(CellFlags::BOLD);
901        // Overlay adds ITALIC — should NOT clear BOLD.
902        let overlay = Style::new().italic();
903        apply_style(&mut cell, overlay);
904        assert!(cell.attrs.has_flag(CellFlags::BOLD), "BOLD must survive");
905        assert!(
906            cell.attrs.has_flag(CellFlags::ITALIC),
907            "ITALIC must be added"
908        );
909    }
910
911    #[test]
912    fn set_style_area_bg_only_preserves_per_cell_fg() {
913        // A 3-cell buffer where each cell has a distinct fg.
914        let mut buf = Buffer::new(3, 1);
915        buf.set(0, 0, Cell::from_char('R').with_fg(PackedRgba::RED));
916        buf.set(1, 0, Cell::from_char('G').with_fg(PackedRgba::GREEN));
917        buf.set(2, 0, Cell::from_char('B').with_fg(PackedRgba::BLUE));
918
919        // Selection highlight sets only bg.
920        let highlight = Style::new().bg(PackedRgba::rgb(40, 40, 40));
921        set_style_area(&mut buf, Rect::new(0, 0, 3, 1), highlight);
922
923        // All fg colours must be preserved.
924        assert_eq!(buf.get(0, 0).unwrap().fg, PackedRgba::RED);
925        assert_eq!(buf.get(1, 0).unwrap().fg, PackedRgba::GREEN);
926        assert_eq!(buf.get(2, 0).unwrap().fg, PackedRgba::BLUE);
927        // bg should be the highlight colour.
928        assert_eq!(buf.get(0, 0).unwrap().bg, PackedRgba::rgb(40, 40, 40));
929    }
930
931    #[test]
932    fn set_style_area_merges_attrs_not_replaces() {
933        use ftui_render::cell::StyleFlags as CellFlags;
934        let mut buf = Buffer::new(1, 1);
935        let mut cell = Cell::from_char('X');
936        cell.attrs = cell.attrs.with_flags(CellFlags::BOLD);
937        buf.set(0, 0, cell);
938
939        set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().italic());
940
941        let result = buf.get(0, 0).unwrap();
942        assert!(result.attrs.has_flag(CellFlags::BOLD), "BOLD must survive");
943        assert!(
944            result.attrs.has_flag(CellFlags::ITALIC),
945            "ITALIC must be added"
946        );
947    }
948
949    #[test]
950    fn set_style_area_applies_to_all_cells() {
951        let mut buf = Buffer::new(3, 2);
952        let area = Rect::new(0, 0, 3, 2);
953        let style = Style::new().bg(PackedRgba::rgb(10, 20, 30));
954        set_style_area(&mut buf, area, style);
955
956        for y in 0..2 {
957            for x in 0..3 {
958                assert_eq!(
959                    buf.get(x, y).unwrap().bg,
960                    PackedRgba::rgb(10, 20, 30),
961                    "cell ({x},{y}) should have style applied"
962                );
963            }
964        }
965    }
966
967    #[test]
968    fn set_style_area_composites_alpha_bg_over_existing_bg() {
969        let mut buf = Buffer::new(1, 1);
970        let base = PackedRgba::rgb(200, 0, 0);
971        buf.set(0, 0, Cell::default().with_bg(base));
972
973        let overlay = PackedRgba::rgba(0, 0, 200, 128);
974        set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().bg(overlay));
975
976        let expected = overlay.over(base);
977        assert_eq!(buf.get(0, 0).unwrap().bg, expected);
978    }
979
980    #[test]
981    fn set_style_area_partial_rect() {
982        let mut buf = Buffer::new(5, 5);
983        let area = Rect::new(1, 1, 2, 2);
984        let style = Style::new().fg(PackedRgba::rgb(99, 99, 99));
985        set_style_area(&mut buf, area, style);
986
987        // Inside area should be styled
988        assert_eq!(buf.get(1, 1).unwrap().fg, PackedRgba::rgb(99, 99, 99));
989        assert_eq!(buf.get(2, 2).unwrap().fg, PackedRgba::rgb(99, 99, 99));
990
991        // Outside area should be default
992        assert_ne!(buf.get(0, 0).unwrap().fg, PackedRgba::rgb(99, 99, 99));
993    }
994
995    #[test]
996    fn set_style_area_empty_style_is_noop() {
997        let mut buf = Buffer::new(3, 3);
998        buf.set(0, 0, Cell::from_char('A'));
999        let original_fg = buf.get(0, 0).unwrap().fg;
1000
1001        set_style_area(&mut buf, Rect::new(0, 0, 3, 3), Style::default());
1002
1003        // Should not have changed
1004        assert_eq!(buf.get(0, 0).unwrap().fg, original_fg);
1005        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
1006    }
1007
1008    #[test]
1009    fn set_style_area_respects_scissor() {
1010        let mut buf = Buffer::new(3, 3);
1011        let style = Style::new().bg(PackedRgba::rgb(10, 20, 30));
1012
1013        buf.push_scissor(Rect::new(1, 1, 1, 1));
1014        set_style_area(&mut buf, Rect::new(0, 0, 3, 3), style);
1015
1016        assert_eq!(buf.get(1, 1).unwrap().bg, PackedRgba::rgb(10, 20, 30));
1017        assert_ne!(buf.get(0, 1).unwrap().bg, PackedRgba::rgb(10, 20, 30));
1018        assert_ne!(buf.get(1, 0).unwrap().bg, PackedRgba::rgb(10, 20, 30));
1019        assert_ne!(buf.get(2, 2).unwrap().bg, PackedRgba::rgb(10, 20, 30));
1020    }
1021
1022    #[test]
1023    fn set_style_area_respects_opacity_stack() {
1024        let mut buf = Buffer::new(1, 1);
1025        let base_fg = PackedRgba::rgb(20, 30, 40);
1026        let base_bg = PackedRgba::rgb(50, 60, 70);
1027        buf.set(0, 0, Cell::from_char('X').with_fg(base_fg).with_bg(base_bg));
1028
1029        let overlay_fg = PackedRgba::rgb(200, 100, 0);
1030        let overlay_bg = PackedRgba::rgb(0, 0, 200);
1031        buf.push_opacity(0.5);
1032        set_style_area(
1033            &mut buf,
1034            Rect::new(0, 0, 1, 1),
1035            Style::new().fg(overlay_fg).bg(overlay_bg),
1036        );
1037
1038        let cell = buf.get(0, 0).unwrap();
1039        assert_eq!(cell.fg, overlay_fg.with_opacity(0.5));
1040        assert_eq!(cell.bg, overlay_bg.with_opacity(0.5).over(base_bg));
1041    }
1042
1043    #[test]
1044    fn draw_text_span_basic() {
1045        let mut pool = GraphemePool::new();
1046        let mut frame = Frame::new(10, 1, &mut pool);
1047        let end_x = draw_text_span(&mut frame, 0, 0, "ABC", Style::default(), 10);
1048
1049        assert_eq!(end_x, 3);
1050        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
1051        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('B'));
1052        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
1053    }
1054
1055    #[test]
1056    fn draw_text_span_clipped_at_max_x() {
1057        let mut pool = GraphemePool::new();
1058        let mut frame = Frame::new(10, 1, &mut pool);
1059        let end_x = draw_text_span(&mut frame, 0, 0, "ABCDEF", Style::default(), 3);
1060
1061        assert_eq!(end_x, 3);
1062        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
1063        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
1064        // 'D' should not be drawn
1065        assert!(frame.buffer.get(3, 0).unwrap().is_empty());
1066    }
1067
1068    #[test]
1069    fn draw_text_span_starts_at_offset() {
1070        let mut pool = GraphemePool::new();
1071        let mut frame = Frame::new(10, 1, &mut pool);
1072        let end_x = draw_text_span(&mut frame, 5, 0, "XY", Style::default(), 10);
1073
1074        assert_eq!(end_x, 7);
1075        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('X'));
1076        assert_eq!(frame.buffer.get(6, 0).unwrap().content.as_char(), Some('Y'));
1077        assert!(frame.buffer.get(4, 0).unwrap().is_empty());
1078    }
1079
1080    #[test]
1081    fn draw_text_span_empty_string() {
1082        let mut pool = GraphemePool::new();
1083        let mut frame = Frame::new(5, 1, &mut pool);
1084        let end_x = draw_text_span(&mut frame, 0, 0, "", Style::default(), 5);
1085        assert_eq!(end_x, 0);
1086    }
1087
1088    #[test]
1089    fn draw_text_span_applies_style() {
1090        let mut pool = GraphemePool::new();
1091        let mut frame = Frame::new(5, 1, &mut pool);
1092        let style = Style::new().fg(PackedRgba::rgb(255, 128, 0));
1093        draw_text_span(&mut frame, 0, 0, "A", style, 5);
1094
1095        assert_eq!(
1096            frame.buffer.get(0, 0).unwrap().fg,
1097            PackedRgba::rgb(255, 128, 0)
1098        );
1099    }
1100
1101    #[test]
1102    fn draw_text_span_preserves_existing_overlay_fg_and_bg() {
1103        let mut pool = GraphemePool::new();
1104        let mut frame = Frame::new(3, 1, &mut pool);
1105        frame.buffer.set(
1106            0,
1107            0,
1108            Cell::from_char('x').with_fg(PackedRgba::rgb(200, 40, 10)),
1109        );
1110        set_style_area(
1111            &mut frame.buffer,
1112            Rect::new(0, 0, 1, 1),
1113            Style::new().bg(PackedRgba::rgb(20, 30, 40)),
1114        );
1115
1116        draw_text_span(&mut frame, 0, 0, "A", Style::default(), 1);
1117
1118        let cell = frame.buffer.get(0, 0).unwrap();
1119        assert_eq!(cell.content.as_char(), Some('A'));
1120        assert_eq!(cell.fg, PackedRgba::rgb(200, 40, 10));
1121        assert_eq!(cell.bg, PackedRgba::rgb(20, 30, 40));
1122    }
1123
1124    #[test]
1125    fn draw_text_span_drops_stale_link_id_but_keeps_style_flags() {
1126        use ftui_render::cell::{CellAttrs, StyleFlags as CellFlags};
1127
1128        let mut pool = GraphemePool::new();
1129        let mut frame = Frame::new(3, 1, &mut pool);
1130        frame.buffer.set(
1131            0,
1132            0,
1133            Cell::from_char('x').with_attrs(CellAttrs::new(CellFlags::UNDERLINE, 42)),
1134        );
1135
1136        draw_text_span(&mut frame, 0, 0, "A", Style::default(), 1);
1137
1138        let cell = frame.buffer.get(0, 0).unwrap();
1139        assert_eq!(cell.content.as_char(), Some('A'));
1140        assert!(cell.attrs.has_flag(CellFlags::UNDERLINE));
1141        assert_eq!(cell.attrs.link_id(), 0);
1142    }
1143
1144    #[test]
1145    fn draw_text_span_max_x_at_start_draws_nothing() {
1146        let mut pool = GraphemePool::new();
1147        let mut frame = Frame::new(5, 1, &mut pool);
1148        let end_x = draw_text_span(&mut frame, 3, 0, "ABC", Style::default(), 3);
1149        assert_eq!(end_x, 3);
1150        assert!(frame.buffer.get(3, 0).unwrap().is_empty());
1151    }
1152
1153    #[test]
1154    fn widget_is_essential_default_false() {
1155        struct DummyWidget;
1156        impl Widget for DummyWidget {
1157            fn render(&self, _: Rect, _: &mut Frame) {}
1158        }
1159        assert!(!DummyWidget.is_essential());
1160    }
1161
1162    #[test]
1163    fn budgeted_new_and_inner() {
1164        struct TestW;
1165        impl Widget for TestW {
1166            fn render(&self, _: Rect, _: &mut Frame) {}
1167        }
1168        let b = Budgeted::new(42, TestW);
1169        assert_eq!(b.widget_id, 42);
1170        let _ = b.inner(); // Should not panic
1171    }
1172
1173    #[test]
1174    fn budgeted_with_signal() {
1175        struct TestW;
1176        impl Widget for TestW {
1177            fn render(&self, _: Rect, _: &mut Frame) {}
1178        }
1179        let sig = WidgetSignal::new(99);
1180        let b = Budgeted::new(42, TestW).with_signal(sig);
1181        // with_signal should override the signal's widget_id to match
1182        assert_eq!(b.signal.widget_id, 42);
1183    }
1184
1185    #[test]
1186    fn set_style_area_transparent_bg_is_noop() {
1187        let mut buf = Buffer::new(1, 1);
1188        let base = PackedRgba::rgb(100, 100, 100);
1189        buf.set(0, 0, Cell::default().with_bg(base));
1190
1191        // Alpha=0 means fully transparent, should leave bg unchanged
1192        let transparent = PackedRgba::rgba(255, 0, 0, 0);
1193        set_style_area(
1194            &mut buf,
1195            Rect::new(0, 0, 1, 1),
1196            Style::new().bg(transparent),
1197        );
1198        assert_eq!(buf.get(0, 0).unwrap().bg, base);
1199    }
1200
1201    #[test]
1202    fn set_style_area_opaque_bg_replaces() {
1203        let mut buf = Buffer::new(1, 1);
1204        buf.set(
1205            0,
1206            0,
1207            Cell::default().with_bg(PackedRgba::rgb(100, 100, 100)),
1208        );
1209
1210        let opaque = PackedRgba::rgba(0, 255, 0, 255);
1211        set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().bg(opaque));
1212        assert_eq!(buf.get(0, 0).unwrap().bg, opaque);
1213    }
1214
1215    #[test]
1216    fn draw_text_span_scrolled_skips_chars() {
1217        let mut pool = GraphemePool::new();
1218        let mut frame = Frame::new(10, 1, &mut pool);
1219        // Scroll past first 2 chars of "ABCDE"
1220        let end_x =
1221            draw_text_span_scrolled(&mut frame, 0, 0, "ABCDE", Style::default(), 10, 2, None);
1222
1223        assert_eq!(end_x, 3);
1224        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('C'));
1225        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('D'));
1226        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('E'));
1227    }
1228}