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 align;
129/// Badge widget (status/priority pills).
130pub mod badge;
131/// Block widget with borders, titles, and padding.
132pub mod block;
133pub mod borders;
134pub mod cached;
135pub mod columns;
136pub mod command_palette;
137pub mod constraint_overlay;
138#[cfg(feature = "debug-overlay")]
139pub mod debug_overlay;
140/// Drag-and-drop protocol: [`Draggable`](drag::Draggable) sources, [`DropTarget`](drag::DropTarget) targets, and [`DragPayload`](drag::DragPayload).
141pub mod drag;
142pub mod emoji;
143pub mod error_boundary;
144/// Fenwick tree (Binary Indexed Tree) for O(log n) prefix sum queries.
145pub mod fenwick;
146pub mod file_picker;
147/// Focus management: navigation graph for keyboard-driven focus traversal.
148pub mod focus;
149pub mod group;
150/// Bayesian height prediction with conformal bounds for virtualized lists.
151pub mod height_predictor;
152pub mod help;
153pub mod help_registry;
154/// Utility-based keybinding hint ranking with Bayesian posteriors.
155pub mod hint_ranker;
156/// Undo/redo history panel widget for displaying command history.
157pub mod history_panel;
158pub mod input;
159/// UI Inspector overlay for debugging widget trees and hit-test regions.
160pub mod inspector;
161pub mod json_view;
162pub mod keyboard_drag;
163pub mod layout;
164pub mod layout_debugger;
165pub mod list;
166pub mod log_ring;
167pub mod log_viewer;
168/// Intrinsic sizing support for content-aware layout.
169pub mod measurable;
170/// Measure cache for memoizing widget measure results.
171pub mod measure_cache;
172pub mod modal;
173/// Shared mouse event result type for widget mouse handling.
174pub mod mouse;
175/// Notification queue for managing multiple toast notifications.
176pub mod notification_queue;
177pub mod padding;
178pub mod paginator;
179pub mod panel;
180/// Multi-line styled text paragraph widget.
181pub mod paragraph;
182pub mod pretty;
183pub mod progress;
184pub mod rule;
185pub mod scrollbar;
186pub mod sparkline;
187pub mod spinner;
188/// Opt-in persistable state trait for widgets.
189pub mod stateful;
190pub mod status_line;
191pub mod stopwatch;
192/// Table widget with rows, columns, and selection.
193pub mod table;
194pub mod textarea;
195pub mod timer;
196/// Toast widget for transient notifications.
197pub mod toast;
198pub mod tree;
199/// Undo support for widgets.
200pub mod undo_support;
201/// Inline validation error display widget.
202pub mod validation_error;
203pub mod virtualized;
204pub mod voi_debug_overlay;
205
206pub use align::{Align, VerticalAlignment};
207pub use badge::Badge;
208pub use cached::{CacheKey, CachedWidget, CachedWidgetState, FnKey, HashKey, NoCacheKey};
209pub use columns::{Column, Columns};
210pub use constraint_overlay::{ConstraintOverlay, ConstraintOverlayStyle};
211#[cfg(feature = "debug-overlay")]
212pub use debug_overlay::{
213    DebugOverlay, DebugOverlayOptions, DebugOverlayState, DebugOverlayStateful,
214    DebugOverlayStatefulState,
215};
216pub use group::Group;
217pub use help_registry::{HelpContent, HelpId, HelpRegistry, Keybinding};
218pub use history_panel::{HistoryEntry, HistoryPanel, HistoryPanelMode};
219pub use layout_debugger::{LayoutConstraints, LayoutDebugger, LayoutRecord};
220pub use log_ring::LogRing;
221pub use log_viewer::{LogViewer, LogViewerState, LogWrapMode, SearchConfig, SearchMode};
222pub use paginator::{Paginator, PaginatorMode};
223pub use panel::Panel;
224pub use sparkline::Sparkline;
225pub use status_line::{StatusItem, StatusLine};
226pub use virtualized::{
227    HeightCache, ItemHeight, RenderItem, Virtualized, VirtualizedList, VirtualizedListState,
228    VirtualizedStorage,
229};
230pub use voi_debug_overlay::{
231    VoiDebugOverlay, VoiDecisionSummary, VoiLedgerEntry, VoiObservationSummary, VoiOverlayData,
232    VoiOverlayStyle, VoiPosteriorSummary,
233};
234
235// Toast notification widget
236pub use toast::{
237    KeyEvent as ToastKeyEvent, Toast, ToastAction, ToastAnimationConfig, ToastAnimationPhase,
238    ToastAnimationState, ToastConfig, ToastContent, ToastEasing, ToastEntranceAnimation,
239    ToastEvent, ToastExitAnimation, ToastIcon, ToastId, ToastPosition, ToastState, ToastStyle,
240};
241
242// Notification queue manager
243pub use notification_queue::{
244    NotificationPriority, NotificationQueue, QueueAction, QueueConfig, QueueStats,
245};
246
247// Shared mouse result type for widget mouse handling
248pub use mouse::MouseResult;
249
250// Measurable widget support for intrinsic sizing
251pub use measurable::{MeasurableWidget, SizeConstraints};
252
253// Measure cache for memoizing measure() results
254pub use measure_cache::{CacheStats, MeasureCache, WidgetId};
255pub use modal::{
256    BackdropConfig, MODAL_HIT_BACKDROP, MODAL_HIT_CONTENT, Modal, ModalAction, ModalConfig,
257    ModalPosition, ModalSizeConstraints, ModalState,
258};
259
260// UI Inspector for debugging
261pub use inspector::{
262    DiagnosticEntry, DiagnosticEventKind, DiagnosticLog, HitInfo, InspectorMode, InspectorOverlay,
263    InspectorState, InspectorStyle, TelemetryCallback, TelemetryHooks, WidgetInfo,
264    diagnostics_enabled, init_diagnostics, is_deterministic_mode, reset_event_counter,
265    set_diagnostics_enabled,
266};
267
268// Focus management
269pub use focus::{
270    FocusEvent, FocusGraph, FocusGroup, FocusId, FocusIndicator, FocusIndicatorKind, FocusManager,
271    FocusNode, FocusTrap, NavDirection,
272};
273
274// Drag-and-drop protocol (source + target)
275pub use drag::{
276    DragConfig, DragPayload, DragState, Draggable, DropPosition, DropResult, DropTarget,
277};
278
279// Stateful persistence trait
280pub use stateful::{StateKey, Stateful, VersionedState};
281
282// Widget persist state types for state-persistence
283pub use list::ListPersistState;
284pub use table::TablePersistState;
285pub use tree::TreePersistState;
286pub use virtualized::VirtualizedListPersistState;
287
288// Undo support for widgets
289pub use undo_support::{
290    ListOperation, ListUndoExt, SelectionOperation, TableOperation, TableUndoExt,
291    TextEditOperation, TextInputUndoExt, TreeOperation, TreeUndoExt, UndoSupport, UndoWidgetId,
292    WidgetTextEditCmd,
293};
294
295// Inline validation error display
296pub use validation_error::{
297    ANIMATION_DURATION_MS, ERROR_BG_DEFAULT, ERROR_FG_DEFAULT, ERROR_ICON_DEFAULT,
298    ValidationErrorDisplay, ValidationErrorState,
299};
300
301use ftui_core::geometry::Rect;
302use ftui_render::buffer::Buffer;
303use ftui_render::cell::Cell;
304use ftui_render::frame::{Frame, WidgetSignal};
305use ftui_style::Style;
306use ftui_text::grapheme_width;
307
308/// A widget that can render itself into a [`Frame`].
309///
310/// # Frame vs Buffer
311///
312/// Widgets render into a `Frame` rather than directly into a `Buffer`. This provides:
313///
314/// - **Buffer access**: `frame.buffer` for drawing cells
315/// - **Hit testing**: `frame.register_hit()` for mouse interaction
316/// - **Cursor control**: `frame.cursor_position` for input widgets
317/// - **Performance hints**: `frame.buffer.degradation` for adaptive rendering
318///
319/// # Implementation Guide
320///
321/// Most widgets only need buffer access:
322///
323/// ```ignore
324/// fn render(&self, area: Rect, frame: &mut Frame) {
325///     for y in area.y..area.bottom() {
326///         for x in area.x..area.right() {
327///             frame.buffer.set(x, y, Cell::from_char('.'));
328///         }
329///     }
330/// }
331/// ```
332///
333/// Interactive widgets should register hit regions when a `hit_id` is set.
334/// Input widgets should set `frame.cursor_position` when focused.
335///
336/// # Degradation Levels
337///
338/// Check `frame.buffer.degradation` to adapt rendering:
339///
340/// - `Full`: All features enabled
341/// - `SimpleBorders`: Skip fancy borders, use ASCII
342/// - `NoStyling`: Skip colors and attributes
343/// - `EssentialOnly`: Only render essential widgets
344/// - `Skeleton`: Minimal placeholder rendering
345///
346/// [`Frame`]: ftui_render::frame::Frame
347pub trait Widget {
348    /// Render the widget into the frame at the given area.
349    ///
350    /// The `area` defines the bounding rectangle within which the widget
351    /// should render. Widgets should respect the area bounds and not
352    /// draw outside them (the buffer's scissor stack enforces this).
353    fn render(&self, area: Rect, frame: &mut Frame);
354
355    /// Whether this widget is essential and should always render.
356    ///
357    /// Essential widgets render even at `EssentialOnly` degradation level.
358    /// Override this to return `true` for:
359    ///
360    /// - Text inputs (user needs to see what they're typing)
361    /// - Primary content areas (main information display)
362    /// - Critical status indicators
363    ///
364    /// Returns `false` by default, appropriate for decorative widgets.
365    fn is_essential(&self) -> bool {
366        false
367    }
368}
369
370/// Budget-aware wrapper that registers widget signals and respects refresh budgets.
371pub struct Budgeted<W> {
372    widget_id: u64,
373    signal: WidgetSignal,
374    inner: W,
375}
376
377impl<W> Budgeted<W> {
378    /// Wrap a widget with a stable identifier and default signal values.
379    #[must_use]
380    pub fn new(widget_id: u64, inner: W) -> Self {
381        Self {
382            widget_id,
383            signal: WidgetSignal::new(widget_id),
384            inner,
385        }
386    }
387
388    /// Override the widget signal template.
389    #[must_use]
390    pub fn with_signal(mut self, mut signal: WidgetSignal) -> Self {
391        signal.widget_id = self.widget_id;
392        self.signal = signal;
393        self
394    }
395
396    /// Access the wrapped widget.
397    #[must_use]
398    pub fn inner(&self) -> &W {
399        &self.inner
400    }
401}
402
403impl<W: Widget> Widget for Budgeted<W> {
404    fn render(&self, area: Rect, frame: &mut Frame) {
405        let mut signal = self.signal.clone();
406        signal.widget_id = self.widget_id;
407        signal.essential = self.inner.is_essential();
408        signal.area_cells = area.width as u32 * area.height as u32;
409        frame.register_widget_signal(signal);
410
411        if frame.should_render_widget(self.widget_id, self.inner.is_essential()) {
412            self.inner.render(area, frame);
413        }
414    }
415
416    fn is_essential(&self) -> bool {
417        self.inner.is_essential()
418    }
419}
420
421impl<W: StatefulWidget + Widget> StatefulWidget for Budgeted<W> {
422    type State = W::State;
423
424    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
425        let mut signal = self.signal.clone();
426        signal.widget_id = self.widget_id;
427        signal.essential = self.inner.is_essential();
428        signal.area_cells = area.width as u32 * area.height as u32;
429        frame.register_widget_signal(signal);
430
431        if frame.should_render_widget(self.widget_id, self.inner.is_essential()) {
432            StatefulWidget::render(&self.inner, area, frame, state);
433        }
434    }
435}
436
437/// A widget that renders based on mutable state.
438///
439/// Use `StatefulWidget` when the widget needs to:
440///
441/// - Update scroll position during render
442/// - Track selection state
443/// - Cache computed layout information
444/// - Synchronize view with external model
445///
446/// # Example
447///
448/// ```ignore
449/// pub struct ListState {
450///     pub selected: Option<usize>,
451///     pub offset: usize,
452/// }
453///
454/// impl StatefulWidget for List<'_> {
455///     type State = ListState;
456///
457///     fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State) {
458///         // Adjust offset to keep selection visible
459///         if let Some(sel) = state.selected {
460///             if sel < state.offset {
461///                 state.offset = sel;
462///             }
463///         }
464///         // Render items starting from offset...
465///     }
466/// }
467/// ```
468///
469/// # Stateful vs Stateless
470///
471/// Prefer stateless [`Widget`] when possible. Use `StatefulWidget` only when
472/// the render pass genuinely needs to modify state (e.g., scroll adjustment).
473pub trait StatefulWidget {
474    /// The state type associated with this widget.
475    type State;
476
477    /// Render the widget into the frame, potentially modifying state.
478    ///
479    /// State modifications should be limited to:
480    /// - Scroll offset adjustments
481    /// - Selection clamping
482    /// - Layout caching
483    fn render(&self, area: Rect, frame: &mut Frame, state: &mut Self::State);
484}
485
486/// Helper to apply style to a cell.
487pub(crate) fn apply_style(cell: &mut Cell, style: Style) {
488    if let Some(fg) = style.fg {
489        cell.fg = fg;
490    }
491    if let Some(bg) = style.bg {
492        cell.bg = bg;
493    }
494    if let Some(attrs) = style.attrs {
495        // Convert ftui_style::StyleFlags to ftui_render::cell::StyleFlags
496        // Assuming they are compatible or the same type re-exported.
497        // If not, we might need conversion logic.
498        // ftui_style::StyleFlags is u16 (likely), ftui_render is u8.
499        // Let's assume the From implementation exists as per previous code.
500        let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
501        cell.attrs = cell.attrs.with_flags(cell_flags);
502    }
503}
504
505/// Apply a style to all cells in a rectangular area.
506///
507/// This modifies existing cells, preserving their content.
508pub(crate) fn set_style_area(buf: &mut Buffer, area: Rect, style: Style) {
509    if style.is_empty() {
510        return;
511    }
512    // Apply styles in-place so we don't disturb wide-character continuation cells.
513    //
514    // Important: `Style` backgrounds can carry alpha for internal compositing, but terminals
515    // are opaque. When applying a semi-transparent background we must composite it over the
516    // existing cell background (src-over) so the buffer ends up with the final opaque color.
517    let fg = style.fg;
518    let bg = style.bg;
519    let attrs = style.attrs;
520    for y in area.y..area.bottom() {
521        for x in area.x..area.right() {
522            if let Some(cell) = buf.get_mut(x, y) {
523                if let Some(fg) = fg {
524                    cell.fg = fg;
525                }
526                if let Some(bg) = bg {
527                    match bg.a() {
528                        0 => {} // Fully transparent: no-op
529                        255 => cell.bg = bg,
530                        _ => cell.bg = bg.over(cell.bg),
531                    }
532                }
533                if let Some(attrs) = attrs {
534                    let cell_flags: ftui_render::cell::StyleFlags = attrs.into();
535                    cell.attrs = cell.attrs.with_flags(cell_flags);
536                }
537            }
538        }
539    }
540}
541
542/// Draw a text span into a frame at the given position.
543///
544/// Returns the x position after the last drawn character.
545/// Stops at `max_x` (exclusive).
546pub(crate) fn draw_text_span(
547    frame: &mut Frame,
548    mut x: u16,
549    y: u16,
550    content: &str,
551    style: Style,
552    max_x: u16,
553) -> u16 {
554    use unicode_segmentation::UnicodeSegmentation;
555
556    for grapheme in content.graphemes(true) {
557        if x >= max_x {
558            break;
559        }
560        let w = grapheme_width(grapheme);
561        if w == 0 {
562            continue;
563        }
564        if x.saturating_add(w as u16) > max_x {
565            break;
566        }
567
568        // Intern grapheme if needed
569        let cell_content = if w > 1 || grapheme.chars().count() > 1 {
570            let id = frame.intern_with_width(grapheme, w as u8);
571            ftui_render::cell::CellContent::from_grapheme(id)
572        } else if let Some(c) = grapheme.chars().next() {
573            ftui_render::cell::CellContent::from_char(c)
574        } else {
575            continue;
576        };
577
578        let mut cell = Cell::new(cell_content);
579        apply_style(&mut cell, style);
580
581        // set_fast() skips scissor/opacity/compositing checks for common
582        // single-width opaque cells; falls back to set() otherwise.
583        frame.buffer.set_fast(x, y, cell);
584
585        x = x.saturating_add(w as u16);
586    }
587    x
588}
589
590/// Draw a text span, optionally attaching a hyperlink.
591#[allow(dead_code)]
592pub(crate) fn draw_text_span_with_link(
593    frame: &mut Frame,
594    x: u16,
595    y: u16,
596    content: &str,
597    style: Style,
598    max_x: u16,
599    link_url: Option<&str>,
600) -> u16 {
601    draw_text_span_scrolled(frame, x, y, content, style, max_x, 0, link_url)
602}
603
604/// Draw a text span with horizontal scrolling (skip first `scroll_x` visual cells).
605#[allow(dead_code, clippy::too_many_arguments)]
606pub(crate) fn draw_text_span_scrolled(
607    frame: &mut Frame,
608    mut x: u16,
609    y: u16,
610    content: &str,
611    style: Style,
612    max_x: u16,
613    scroll_x: u16,
614    link_url: Option<&str>,
615) -> u16 {
616    use unicode_segmentation::UnicodeSegmentation;
617
618    // Register link if present
619    let link_id = if let Some(url) = link_url {
620        frame.register_link(url)
621    } else {
622        0
623    };
624
625    let mut visual_pos: u16 = 0;
626
627    for grapheme in content.graphemes(true) {
628        if x >= max_x {
629            break;
630        }
631        let w = grapheme_width(grapheme);
632        if w == 0 {
633            continue;
634        }
635
636        let next_visual_pos = visual_pos.saturating_add(w as u16);
637
638        // Check if this grapheme is visible
639        if next_visual_pos <= scroll_x {
640            // Fully scrolled out
641            visual_pos = next_visual_pos;
642            continue;
643        }
644
645        if visual_pos < scroll_x {
646            // Partially scrolled out (e.g. wide char starting at scroll_x - 1)
647            // We skip the whole character because we can't render half a cell.
648            visual_pos = next_visual_pos;
649            continue;
650        }
651
652        if x.saturating_add(w as u16) > max_x {
653            break;
654        }
655
656        // Intern grapheme if needed
657        let cell_content = if w > 1 || grapheme.chars().count() > 1 {
658            let id = frame.intern_with_width(grapheme, w as u8);
659            ftui_render::cell::CellContent::from_grapheme(id)
660        } else if let Some(c) = grapheme.chars().next() {
661            ftui_render::cell::CellContent::from_char(c)
662        } else {
663            continue;
664        };
665
666        let mut cell = Cell::new(cell_content);
667        apply_style(&mut cell, style);
668
669        // Apply link ID if present
670        if link_id != 0 {
671            cell.attrs = cell.attrs.with_link(link_id);
672        }
673
674        frame.buffer.set_fast(x, y, cell);
675
676        x = x.saturating_add(w as u16);
677        visual_pos = next_visual_pos;
678    }
679    x
680}
681
682#[cfg(test)]
683mod tests {
684    use super::*;
685    use ftui_render::cell::PackedRgba;
686    use ftui_render::grapheme_pool::GraphemePool;
687
688    #[test]
689    fn apply_style_sets_fg() {
690        let mut cell = Cell::default();
691        let style = Style::new().fg(PackedRgba::rgb(255, 0, 0));
692        apply_style(&mut cell, style);
693        assert_eq!(cell.fg, PackedRgba::rgb(255, 0, 0));
694    }
695
696    #[test]
697    fn apply_style_sets_bg() {
698        let mut cell = Cell::default();
699        let style = Style::new().bg(PackedRgba::rgb(0, 255, 0));
700        apply_style(&mut cell, style);
701        assert_eq!(cell.bg, PackedRgba::rgb(0, 255, 0));
702    }
703
704    #[test]
705    fn apply_style_preserves_content() {
706        let mut cell = Cell::from_char('Z');
707        let style = Style::new().fg(PackedRgba::rgb(1, 2, 3));
708        apply_style(&mut cell, style);
709        assert_eq!(cell.content.as_char(), Some('Z'));
710    }
711
712    #[test]
713    fn apply_style_empty_is_noop() {
714        let original = Cell::default();
715        let mut cell = Cell::default();
716        apply_style(&mut cell, Style::default());
717        assert_eq!(cell.fg, original.fg);
718        assert_eq!(cell.bg, original.bg);
719    }
720
721    #[test]
722    fn set_style_area_applies_to_all_cells() {
723        let mut buf = Buffer::new(3, 2);
724        let area = Rect::new(0, 0, 3, 2);
725        let style = Style::new().bg(PackedRgba::rgb(10, 20, 30));
726        set_style_area(&mut buf, area, style);
727
728        for y in 0..2 {
729            for x in 0..3 {
730                assert_eq!(
731                    buf.get(x, y).unwrap().bg,
732                    PackedRgba::rgb(10, 20, 30),
733                    "cell ({x},{y}) should have style applied"
734                );
735            }
736        }
737    }
738
739    #[test]
740    fn set_style_area_composites_alpha_bg_over_existing_bg() {
741        let mut buf = Buffer::new(1, 1);
742        let base = PackedRgba::rgb(200, 0, 0);
743        buf.set(0, 0, Cell::default().with_bg(base));
744
745        let overlay = PackedRgba::rgba(0, 0, 200, 128);
746        set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().bg(overlay));
747
748        let expected = overlay.over(base);
749        assert_eq!(buf.get(0, 0).unwrap().bg, expected);
750    }
751
752    #[test]
753    fn set_style_area_partial_rect() {
754        let mut buf = Buffer::new(5, 5);
755        let area = Rect::new(1, 1, 2, 2);
756        let style = Style::new().fg(PackedRgba::rgb(99, 99, 99));
757        set_style_area(&mut buf, area, style);
758
759        // Inside area should be styled
760        assert_eq!(buf.get(1, 1).unwrap().fg, PackedRgba::rgb(99, 99, 99));
761        assert_eq!(buf.get(2, 2).unwrap().fg, PackedRgba::rgb(99, 99, 99));
762
763        // Outside area should be default
764        assert_ne!(buf.get(0, 0).unwrap().fg, PackedRgba::rgb(99, 99, 99));
765    }
766
767    #[test]
768    fn set_style_area_empty_style_is_noop() {
769        let mut buf = Buffer::new(3, 3);
770        buf.set(0, 0, Cell::from_char('A'));
771        let original_fg = buf.get(0, 0).unwrap().fg;
772
773        set_style_area(&mut buf, Rect::new(0, 0, 3, 3), Style::default());
774
775        // Should not have changed
776        assert_eq!(buf.get(0, 0).unwrap().fg, original_fg);
777        assert_eq!(buf.get(0, 0).unwrap().content.as_char(), Some('A'));
778    }
779
780    #[test]
781    fn draw_text_span_basic() {
782        let mut pool = GraphemePool::new();
783        let mut frame = Frame::new(10, 1, &mut pool);
784        let end_x = draw_text_span(&mut frame, 0, 0, "ABC", Style::default(), 10);
785
786        assert_eq!(end_x, 3);
787        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
788        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('B'));
789        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
790    }
791
792    #[test]
793    fn draw_text_span_clipped_at_max_x() {
794        let mut pool = GraphemePool::new();
795        let mut frame = Frame::new(10, 1, &mut pool);
796        let end_x = draw_text_span(&mut frame, 0, 0, "ABCDEF", Style::default(), 3);
797
798        assert_eq!(end_x, 3);
799        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('A'));
800        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('C'));
801        // 'D' should not be drawn
802        assert!(frame.buffer.get(3, 0).unwrap().is_empty());
803    }
804
805    #[test]
806    fn draw_text_span_starts_at_offset() {
807        let mut pool = GraphemePool::new();
808        let mut frame = Frame::new(10, 1, &mut pool);
809        let end_x = draw_text_span(&mut frame, 5, 0, "XY", Style::default(), 10);
810
811        assert_eq!(end_x, 7);
812        assert_eq!(frame.buffer.get(5, 0).unwrap().content.as_char(), Some('X'));
813        assert_eq!(frame.buffer.get(6, 0).unwrap().content.as_char(), Some('Y'));
814        assert!(frame.buffer.get(4, 0).unwrap().is_empty());
815    }
816
817    #[test]
818    fn draw_text_span_empty_string() {
819        let mut pool = GraphemePool::new();
820        let mut frame = Frame::new(5, 1, &mut pool);
821        let end_x = draw_text_span(&mut frame, 0, 0, "", Style::default(), 5);
822        assert_eq!(end_x, 0);
823    }
824
825    #[test]
826    fn draw_text_span_applies_style() {
827        let mut pool = GraphemePool::new();
828        let mut frame = Frame::new(5, 1, &mut pool);
829        let style = Style::new().fg(PackedRgba::rgb(255, 128, 0));
830        draw_text_span(&mut frame, 0, 0, "A", style, 5);
831
832        assert_eq!(
833            frame.buffer.get(0, 0).unwrap().fg,
834            PackedRgba::rgb(255, 128, 0)
835        );
836    }
837
838    #[test]
839    fn draw_text_span_max_x_at_start_draws_nothing() {
840        let mut pool = GraphemePool::new();
841        let mut frame = Frame::new(5, 1, &mut pool);
842        let end_x = draw_text_span(&mut frame, 3, 0, "ABC", Style::default(), 3);
843        assert_eq!(end_x, 3);
844        assert!(frame.buffer.get(3, 0).unwrap().is_empty());
845    }
846
847    #[test]
848    fn widget_is_essential_default_false() {
849        struct DummyWidget;
850        impl Widget for DummyWidget {
851            fn render(&self, _: Rect, _: &mut Frame) {}
852        }
853        assert!(!DummyWidget.is_essential());
854    }
855
856    #[test]
857    fn budgeted_new_and_inner() {
858        struct TestW;
859        impl Widget for TestW {
860            fn render(&self, _: Rect, _: &mut Frame) {}
861        }
862        let b = Budgeted::new(42, TestW);
863        assert_eq!(b.widget_id, 42);
864        let _ = b.inner(); // Should not panic
865    }
866
867    #[test]
868    fn budgeted_with_signal() {
869        struct TestW;
870        impl Widget for TestW {
871            fn render(&self, _: Rect, _: &mut Frame) {}
872        }
873        let sig = WidgetSignal::new(99);
874        let b = Budgeted::new(42, TestW).with_signal(sig);
875        // with_signal should override the signal's widget_id to match
876        assert_eq!(b.signal.widget_id, 42);
877    }
878
879    #[test]
880    fn set_style_area_transparent_bg_is_noop() {
881        let mut buf = Buffer::new(1, 1);
882        let base = PackedRgba::rgb(100, 100, 100);
883        buf.set(0, 0, Cell::default().with_bg(base));
884
885        // Alpha=0 means fully transparent, should leave bg unchanged
886        let transparent = PackedRgba::rgba(255, 0, 0, 0);
887        set_style_area(
888            &mut buf,
889            Rect::new(0, 0, 1, 1),
890            Style::new().bg(transparent),
891        );
892        assert_eq!(buf.get(0, 0).unwrap().bg, base);
893    }
894
895    #[test]
896    fn set_style_area_opaque_bg_replaces() {
897        let mut buf = Buffer::new(1, 1);
898        buf.set(
899            0,
900            0,
901            Cell::default().with_bg(PackedRgba::rgb(100, 100, 100)),
902        );
903
904        let opaque = PackedRgba::rgba(0, 255, 0, 255);
905        set_style_area(&mut buf, Rect::new(0, 0, 1, 1), Style::new().bg(opaque));
906        assert_eq!(buf.get(0, 0).unwrap().bg, opaque);
907    }
908
909    #[test]
910    fn draw_text_span_scrolled_skips_chars() {
911        let mut pool = GraphemePool::new();
912        let mut frame = Frame::new(10, 1, &mut pool);
913        // Scroll past first 2 chars of "ABCDE"
914        let end_x =
915            draw_text_span_scrolled(&mut frame, 0, 0, "ABCDE", Style::default(), 10, 2, None);
916
917        assert_eq!(end_x, 3);
918        assert_eq!(frame.buffer.get(0, 0).unwrap().content.as_char(), Some('C'));
919        assert_eq!(frame.buffer.get(1, 0).unwrap().content.as_char(), Some('D'));
920        assert_eq!(frame.buffer.get(2, 0).unwrap().content.as_char(), Some('E'));
921    }
922}