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