Skip to main content

ftui_render/
frame.rs

1#![forbid(unsafe_code)]
2
3//! Frame = Buffer + metadata for a render pass.
4//!
5//! The `Frame` is the render target that `Model::view()` methods write to.
6//! It bundles the cell grid ([`Buffer`]) with metadata for cursor and
7//! mouse hit testing.
8//!
9//! # Design Rationale
10//!
11//! Frame does NOT own pools (GraphemePool, LinkRegistry) - those are passed
12//! separately or accessed via RenderContext to allow sharing across frames.
13//!
14//! # Usage
15//!
16//! ```
17//! use ftui_render::frame::Frame;
18//! use ftui_render::cell::Cell;
19//! use ftui_render::grapheme_pool::GraphemePool;
20//!
21//! let mut pool = GraphemePool::new();
22//! let mut frame = Frame::new(80, 24, &mut pool);
23//!
24//! // Draw content
25//! frame.buffer.set_raw(0, 0, Cell::from_char('H'));
26//! frame.buffer.set_raw(1, 0, Cell::from_char('i'));
27//!
28//! // Set cursor
29//! frame.set_cursor(Some((2, 0)));
30//! ```
31
32use crate::arena::FrameArena;
33use crate::budget::DegradationLevel;
34use crate::buffer::Buffer;
35use crate::cell::{Cell, CellContent, GraphemeId};
36use crate::drawing::{BorderChars, Draw};
37use crate::grapheme_pool::GraphemePool;
38use crate::{display_width, grapheme_width};
39use ftui_core::geometry::Rect;
40use unicode_segmentation::UnicodeSegmentation;
41
42/// Identifier for a clickable region in the hit grid.
43///
44/// Widgets register hit regions with unique IDs to enable mouse interaction.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
46pub struct HitId(pub u32);
47
48impl HitId {
49    /// Create a new hit ID from a raw value.
50    #[inline]
51    pub const fn new(id: u32) -> Self {
52        Self(id)
53    }
54
55    /// Get the raw ID value.
56    #[inline]
57    pub const fn id(self) -> u32 {
58        self.0
59    }
60}
61
62/// Opaque user data for hit callbacks.
63pub type HitData = u64;
64
65/// Regions within a widget for mouse interaction.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
67pub enum HitRegion {
68    /// No interactive region.
69    #[default]
70    None,
71    /// Main content area.
72    Content,
73    /// Widget border area.
74    Border,
75    /// Scrollbar track or thumb.
76    Scrollbar,
77    /// Resize handle or drag target.
78    Handle,
79    /// Clickable button.
80    Button,
81    /// Hyperlink.
82    Link,
83    /// Custom region tag.
84    Custom(u8),
85}
86
87/// A single hit cell in the grid.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub struct HitCell {
90    /// Widget that registered this cell, if any.
91    pub widget_id: Option<HitId>,
92    /// Region tag for the hit area.
93    pub region: HitRegion,
94    /// Extra data attached to this hit cell.
95    pub data: HitData,
96}
97
98impl HitCell {
99    /// Create a populated hit cell.
100    #[inline]
101    pub const fn new(widget_id: HitId, region: HitRegion, data: HitData) -> Self {
102        Self {
103            widget_id: Some(widget_id),
104            region,
105            data,
106        }
107    }
108
109    /// Check if the cell is empty.
110    #[inline]
111    pub const fn is_empty(&self) -> bool {
112        self.widget_id.is_none()
113    }
114}
115
116/// Hit testing grid for mouse interaction.
117///
118/// Maps screen positions to widget IDs, enabling widgets to receive
119/// mouse events for their regions.
120#[derive(Debug, Clone)]
121pub struct HitGrid {
122    width: u16,
123    height: u16,
124    cells: Vec<HitCell>,
125}
126
127impl HitGrid {
128    /// Create a new hit grid with the given dimensions.
129    pub fn new(width: u16, height: u16) -> Self {
130        let size = width as usize * height as usize;
131        Self {
132            width,
133            height,
134            cells: vec![HitCell::default(); size],
135        }
136    }
137
138    /// Grid width.
139    #[inline]
140    pub const fn width(&self) -> u16 {
141        self.width
142    }
143
144    /// Grid height.
145    #[inline]
146    pub const fn height(&self) -> u16 {
147        self.height
148    }
149
150    /// Convert (x, y) to linear index.
151    #[inline]
152    fn index(&self, x: u16, y: u16) -> Option<usize> {
153        if x < self.width && y < self.height {
154            Some(y as usize * self.width as usize + x as usize)
155        } else {
156            None
157        }
158    }
159
160    /// Get the hit cell at (x, y).
161    #[inline]
162    #[must_use]
163    pub fn get(&self, x: u16, y: u16) -> Option<&HitCell> {
164        self.index(x, y).map(|i| &self.cells[i])
165    }
166
167    /// Get mutable reference to hit cell at (x, y).
168    #[inline]
169    #[must_use]
170    pub fn get_mut(&mut self, x: u16, y: u16) -> Option<&mut HitCell> {
171        self.index(x, y).map(|i| &mut self.cells[i])
172    }
173
174    /// Register a clickable region with the given hit metadata.
175    ///
176    /// All cells within the rectangle will map to this hit cell.
177    pub fn register(&mut self, rect: Rect, widget_id: HitId, region: HitRegion, data: HitData) {
178        // Use usize to avoid overflow for large coordinates
179        let x_end = (rect.x as usize + rect.width as usize).min(self.width as usize);
180        let y_end = (rect.y as usize + rect.height as usize).min(self.height as usize);
181
182        // Check if there's anything to do
183        if rect.x as usize >= x_end || rect.y as usize >= y_end {
184            return;
185        }
186
187        let hit_cell = HitCell::new(widget_id, region, data);
188
189        for y in rect.y as usize..y_end {
190            let row_start = y * self.width as usize;
191            let start = row_start + rect.x as usize;
192            let end = row_start + x_end;
193
194            // Optimize: use slice fill for contiguous memory access
195            self.cells[start..end].fill(hit_cell);
196        }
197    }
198
199    /// Hit test at the given position.
200    ///
201    /// Returns the hit tuple if a region is registered at (x, y).
202    #[must_use]
203    pub fn hit_test(&self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
204        self.get(x, y)
205            .and_then(|cell| cell.widget_id.map(|id| (id, cell.region, cell.data)))
206    }
207
208    /// Return all hits within the given rectangle.
209    pub fn hits_in(&self, rect: Rect) -> Vec<(HitId, HitRegion, HitData)> {
210        let x_end = (rect.x as usize + rect.width as usize).min(self.width as usize) as u16;
211        let y_end = (rect.y as usize + rect.height as usize).min(self.height as usize) as u16;
212        let mut hits = Vec::new();
213
214        for y in rect.y..y_end {
215            for x in rect.x..x_end {
216                if let Some((id, region, data)) = self.hit_test(x, y) {
217                    hits.push((id, region, data));
218                }
219            }
220        }
221
222        hits
223    }
224
225    /// Clear all hit regions.
226    pub fn clear(&mut self) {
227        self.cells.fill(HitCell::default());
228    }
229}
230
231use crate::link_registry::LinkRegistry;
232
233/// Source of the cost estimate for widget scheduling.
234#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
235pub enum CostEstimateSource {
236    /// Measured from recent render timings.
237    Measured,
238    /// Derived from area-based fallback (cells * cost_per_cell).
239    AreaFallback,
240    /// Fixed default when no signals exist.
241    #[default]
242    FixedDefault,
243}
244
245/// Per-widget scheduling signals captured during rendering.
246///
247/// These signals are used by runtime policies (budgeted refresh, greedy
248/// selection) to prioritize which widgets to render when budget is tight.
249#[derive(Debug, Clone)]
250pub struct WidgetSignal {
251    /// Stable widget identifier.
252    pub widget_id: u64,
253    /// Whether this widget is essential.
254    pub essential: bool,
255    /// Base priority in [0, 1].
256    pub priority: f32,
257    /// Milliseconds since last render.
258    pub staleness_ms: u64,
259    /// Focus boost in [0, 1].
260    pub focus_boost: f32,
261    /// Interaction boost in [0, 1].
262    pub interaction_boost: f32,
263    /// Widget area in cells (width * height).
264    pub area_cells: u32,
265    /// Estimated render cost in microseconds.
266    pub cost_estimate_us: f32,
267    /// Recent measured cost (EMA), if available.
268    pub recent_cost_us: f32,
269    /// Cost estimate provenance.
270    pub estimate_source: CostEstimateSource,
271}
272
273impl Default for WidgetSignal {
274    fn default() -> Self {
275        Self {
276            widget_id: 0,
277            essential: false,
278            priority: 0.5,
279            staleness_ms: 0,
280            focus_boost: 0.0,
281            interaction_boost: 0.0,
282            area_cells: 1,
283            cost_estimate_us: 5.0,
284            recent_cost_us: 5.0,
285            estimate_source: CostEstimateSource::FixedDefault,
286        }
287    }
288}
289
290impl WidgetSignal {
291    /// Create a widget signal with neutral defaults.
292    #[must_use]
293    pub fn new(widget_id: u64) -> Self {
294        Self {
295            widget_id,
296            ..Self::default()
297        }
298    }
299}
300
301/// Widget render budget policy for a single frame.
302#[derive(Debug, Clone)]
303pub struct WidgetBudget {
304    allow_list: Option<Vec<u64>>,
305}
306
307impl Default for WidgetBudget {
308    fn default() -> Self {
309        Self::allow_all()
310    }
311}
312
313impl WidgetBudget {
314    /// Allow all widgets to render.
315    #[must_use]
316    pub fn allow_all() -> Self {
317        Self { allow_list: None }
318    }
319
320    /// Allow only a specific set of widget IDs.
321    #[must_use]
322    pub fn allow_only(mut ids: Vec<u64>) -> Self {
323        ids.sort_unstable();
324        ids.dedup();
325        Self {
326            allow_list: Some(ids),
327        }
328    }
329
330    /// Check whether a widget should be rendered.
331    #[inline]
332    pub fn allows(&self, widget_id: u64, essential: bool) -> bool {
333        if essential {
334            return true;
335        }
336        match &self.allow_list {
337            None => true,
338            Some(ids) => ids.binary_search(&widget_id).is_ok(),
339        }
340    }
341}
342
343/// Frame = Buffer + metadata for a render pass.
344///
345/// The Frame is passed to `Model::view()` and contains everything needed
346/// to render a single frame. The Buffer holds cells; metadata controls
347/// cursor and enables mouse hit testing.
348///
349/// # Lifetime
350///
351/// The frame borrows the `GraphemePool` from the runtime, so it cannot outlive
352/// the render pass. This is correct because frames are ephemeral render targets.
353#[derive(Debug)]
354pub struct Frame<'a> {
355    /// The cell grid for this render pass.
356    pub buffer: Buffer,
357
358    /// Reference to the grapheme pool for interning strings.
359    pub pool: &'a mut GraphemePool,
360
361    /// Optional reference to link registry for hyperlinks.
362    pub links: Option<&'a mut LinkRegistry>,
363
364    /// Optional hit grid for mouse hit testing.
365    ///
366    /// When `Some`, widgets can register clickable regions.
367    pub hit_grid: Option<HitGrid>,
368
369    /// Widget render budget policy for this frame.
370    pub widget_budget: WidgetBudget,
371
372    /// Collected per-widget scheduling signals for this frame.
373    pub widget_signals: Vec<WidgetSignal>,
374
375    /// Cursor position (if app wants to show cursor).
376    ///
377    /// Coordinates are relative to buffer (0-indexed).
378    pub cursor_position: Option<(u16, u16)>,
379
380    /// Whether cursor should be visible.
381    pub cursor_visible: bool,
382
383    /// Current degradation level from the render budget.
384    ///
385    /// Widgets can read this to skip expensive operations when the
386    /// budget is constrained (e.g., use ASCII borders instead of
387    /// Unicode, skip decorative rendering, etc.).
388    pub degradation: DegradationLevel,
389
390    /// Optional per-frame bump arena for temporary allocations.
391    ///
392    /// When set, widgets can use this arena for scratch allocations that
393    /// only live for the current frame (e.g., formatted strings, temporary
394    /// slices). The arena is reset at frame boundaries, eliminating
395    /// allocator churn on the hot render path.
396    pub arena: Option<&'a FrameArena>,
397}
398
399impl<'a> Frame<'a> {
400    /// Create a new frame with given dimensions and grapheme pool.
401    ///
402    /// The frame starts with no hit grid and visible cursor at no position.
403    pub fn new(width: u16, height: u16, pool: &'a mut GraphemePool) -> Self {
404        Self {
405            buffer: Buffer::new(width, height),
406            pool,
407            links: None,
408            hit_grid: None,
409            widget_budget: WidgetBudget::default(),
410            widget_signals: Vec::new(),
411            cursor_position: None,
412            cursor_visible: true,
413            degradation: DegradationLevel::Full,
414            arena: None,
415        }
416    }
417
418    /// Create a frame from an existing buffer.
419    ///
420    /// This avoids per-frame buffer allocation when callers reuse buffers.
421    pub fn from_buffer(buffer: Buffer, pool: &'a mut GraphemePool) -> Self {
422        Self {
423            buffer,
424            pool,
425            links: None,
426            hit_grid: None,
427            widget_budget: WidgetBudget::default(),
428            widget_signals: Vec::new(),
429            cursor_position: None,
430            cursor_visible: true,
431            degradation: DegradationLevel::Full,
432            arena: None,
433        }
434    }
435
436    /// Create a new frame with grapheme pool and link registry.
437    ///
438    /// This avoids double-borrowing issues when both pool and links
439    /// come from the same parent struct.
440    pub fn with_links(
441        width: u16,
442        height: u16,
443        pool: &'a mut GraphemePool,
444        links: &'a mut LinkRegistry,
445    ) -> Self {
446        Self {
447            buffer: Buffer::new(width, height),
448            pool,
449            links: Some(links),
450            hit_grid: None,
451            widget_budget: WidgetBudget::default(),
452            widget_signals: Vec::new(),
453            cursor_position: None,
454            cursor_visible: true,
455            degradation: DegradationLevel::Full,
456            arena: None,
457        }
458    }
459
460    /// Create a frame with hit testing enabled.
461    ///
462    /// The hit grid allows widgets to register clickable regions.
463    pub fn with_hit_grid(width: u16, height: u16, pool: &'a mut GraphemePool) -> Self {
464        Self {
465            buffer: Buffer::new(width, height),
466            pool,
467            links: None,
468            hit_grid: Some(HitGrid::new(width, height)),
469            widget_budget: WidgetBudget::default(),
470            widget_signals: Vec::new(),
471            cursor_position: None,
472            cursor_visible: true,
473            degradation: DegradationLevel::Full,
474            arena: None,
475        }
476    }
477
478    /// Set the link registry for this frame.
479    pub fn set_links(&mut self, links: &'a mut LinkRegistry) {
480        self.links = Some(links);
481    }
482
483    /// Set the per-frame bump arena for temporary allocations.
484    ///
485    /// Widgets can access the arena via [`arena()`](Self::arena) to
486    /// perform scratch allocations that only live for the current frame.
487    pub fn set_arena(&mut self, arena: &'a FrameArena) {
488        self.arena = Some(arena);
489    }
490
491    /// Returns the per-frame bump arena, if set.
492    ///
493    /// Widgets should use this for temporary allocations (formatted strings,
494    /// scratch slices) to avoid per-frame allocator churn.
495    pub fn arena(&self) -> Option<&FrameArena> {
496        self.arena
497    }
498
499    /// Register a hyperlink URL and return its ID.
500    ///
501    /// Returns 0 if link registry is not available or full.
502    pub fn register_link(&mut self, url: &str) -> u32 {
503        if let Some(ref mut links) = self.links {
504            links.register(url)
505        } else {
506            0
507        }
508    }
509
510    /// Set the widget render budget for this frame.
511    pub fn set_widget_budget(&mut self, budget: WidgetBudget) {
512        self.widget_budget = budget;
513    }
514
515    /// Check whether a widget should be rendered under the current budget.
516    #[inline]
517    pub fn should_render_widget(&self, widget_id: u64, essential: bool) -> bool {
518        self.widget_budget.allows(widget_id, essential)
519    }
520
521    /// Register a widget scheduling signal for this frame.
522    pub fn register_widget_signal(&mut self, signal: WidgetSignal) {
523        self.widget_signals.push(signal);
524    }
525
526    /// Borrow the collected widget signals.
527    #[inline]
528    pub fn widget_signals(&self) -> &[WidgetSignal] {
529        &self.widget_signals
530    }
531
532    /// Take the collected widget signals, leaving an empty list.
533    #[inline]
534    pub fn take_widget_signals(&mut self) -> Vec<WidgetSignal> {
535        std::mem::take(&mut self.widget_signals)
536    }
537
538    /// Intern a string in the grapheme pool.
539    ///
540    /// Returns a `GraphemeId` that can be used to create a `Cell`.
541    /// The width is calculated automatically or can be provided if already known.
542    ///
543    /// # Panics
544    ///
545    /// Panics if width > 127.
546    pub fn intern(&mut self, text: &str) -> GraphemeId {
547        let width = display_width(text).min(127) as u8;
548        self.pool.intern(text, width)
549    }
550
551    /// Intern a string with explicit width.
552    pub fn intern_with_width(&mut self, text: &str, width: u8) -> GraphemeId {
553        self.pool.intern(text, width)
554    }
555
556    /// Enable hit testing on an existing frame.
557    pub fn enable_hit_testing(&mut self) {
558        if self.hit_grid.is_none() {
559            self.hit_grid = Some(HitGrid::new(self.width(), self.height()));
560        }
561    }
562
563    /// Frame width in cells.
564    #[inline]
565    pub fn width(&self) -> u16 {
566        self.buffer.width()
567    }
568
569    /// Frame height in cells.
570    #[inline]
571    pub fn height(&self) -> u16 {
572        self.buffer.height()
573    }
574
575    /// Clear frame for next render.
576    ///
577    /// Resets both the buffer and hit grid (if present).
578    pub fn clear(&mut self) {
579        self.buffer.clear();
580        if let Some(ref mut grid) = self.hit_grid {
581            grid.clear();
582        }
583        self.cursor_position = None;
584        self.widget_signals.clear();
585    }
586
587    /// Set cursor position.
588    ///
589    /// Pass `None` to indicate no cursor should be shown at a specific position.
590    #[inline]
591    pub fn set_cursor(&mut self, position: Option<(u16, u16)>) {
592        self.cursor_position = position;
593    }
594
595    /// Set cursor visibility.
596    #[inline]
597    pub fn set_cursor_visible(&mut self, visible: bool) {
598        self.cursor_visible = visible;
599    }
600
601    /// Set the degradation level for this frame.
602    ///
603    /// Propagates to the buffer so widgets can read `buf.degradation`
604    /// during rendering without needing access to the full Frame.
605    #[inline]
606    pub fn set_degradation(&mut self, level: DegradationLevel) {
607        self.degradation = level;
608        self.buffer.degradation = level;
609    }
610
611    /// Get the bounding rectangle of the frame.
612    #[inline]
613    pub fn bounds(&self) -> Rect {
614        self.buffer.bounds()
615    }
616
617    /// Register a hit region (if hit grid is enabled).
618    ///
619    /// Returns `true` if the region was registered, `false` if no hit grid.
620    ///
621    /// # Clipping
622    ///
623    /// The region is intersected with the current scissor stack of the
624    /// internal buffer. Parts of the region outside the scissor are
625    /// ignored.
626    pub fn register_hit(
627        &mut self,
628        rect: Rect,
629        id: HitId,
630        region: HitRegion,
631        data: HitData,
632    ) -> bool {
633        if let Some(ref mut grid) = self.hit_grid {
634            // Clip against current scissor
635            let clipped = rect.intersection(&self.buffer.current_scissor());
636            if !clipped.is_empty() {
637                grid.register(clipped, id, region, data);
638            }
639            true
640        } else {
641            false
642        }
643    }
644
645    /// Hit test at the given position (if hit grid is enabled).
646    #[must_use]
647    pub fn hit_test(&self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
648        self.hit_grid.as_ref().and_then(|grid| grid.hit_test(x, y))
649    }
650
651    /// Register a hit region with default metadata (Content, data=0).
652    pub fn register_hit_region(&mut self, rect: Rect, id: HitId) -> bool {
653        self.register_hit(rect, id, HitRegion::Content, 0)
654    }
655}
656
657impl<'a> Draw for Frame<'a> {
658    fn draw_horizontal_line(&mut self, x: u16, y: u16, width: u16, cell: Cell) {
659        self.buffer.draw_horizontal_line(x, y, width, cell);
660    }
661
662    fn draw_vertical_line(&mut self, x: u16, y: u16, height: u16, cell: Cell) {
663        self.buffer.draw_vertical_line(x, y, height, cell);
664    }
665
666    fn draw_rect_filled(&mut self, rect: Rect, cell: Cell) {
667        self.buffer.draw_rect_filled(rect, cell);
668    }
669
670    fn draw_rect_outline(&mut self, rect: Rect, cell: Cell) {
671        self.buffer.draw_rect_outline(rect, cell);
672    }
673
674    fn print_text(&mut self, x: u16, y: u16, text: &str, base_cell: Cell) -> u16 {
675        self.print_text_clipped(x, y, text, base_cell, self.width())
676    }
677
678    fn print_text_clipped(
679        &mut self,
680        x: u16,
681        y: u16,
682        text: &str,
683        base_cell: Cell,
684        max_x: u16,
685    ) -> u16 {
686        let mut cx = x;
687        for grapheme in text.graphemes(true) {
688            let width = grapheme_width(grapheme);
689            if width == 0 {
690                continue;
691            }
692
693            if cx >= max_x {
694                break;
695            }
696
697            // Don't start a wide char if it won't fit
698            if cx as u32 + width as u32 > max_x as u32 {
699                break;
700            }
701
702            // Intern grapheme if needed (unlike Buffer::print_text, we have the pool!)
703            let content = if width > 1 || grapheme.chars().count() > 1 {
704                let id = self.intern_with_width(grapheme, width as u8);
705                CellContent::from_grapheme(id)
706            } else if let Some(c) = grapheme.chars().next() {
707                CellContent::from_char(c)
708            } else {
709                continue;
710            };
711
712            let cell = Cell {
713                content,
714                fg: base_cell.fg,
715                bg: base_cell.bg,
716                attrs: base_cell.attrs,
717            };
718            self.buffer.set_fast(cx, y, cell);
719
720            cx = cx.saturating_add(width as u16);
721        }
722        cx
723    }
724
725    fn draw_border(&mut self, rect: Rect, chars: BorderChars, base_cell: Cell) {
726        self.buffer.draw_border(rect, chars, base_cell);
727    }
728
729    fn draw_box(&mut self, rect: Rect, chars: BorderChars, border_cell: Cell, fill_cell: Cell) {
730        self.buffer.draw_box(rect, chars, border_cell, fill_cell);
731    }
732
733    fn paint_area(
734        &mut self,
735        rect: Rect,
736        fg: Option<crate::cell::PackedRgba>,
737        bg: Option<crate::cell::PackedRgba>,
738    ) {
739        self.buffer.paint_area(rect, fg, bg);
740    }
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use crate::cell::Cell;
747
748    #[test]
749    fn frame_creation() {
750        let mut pool = GraphemePool::new();
751        let frame = Frame::new(80, 24, &mut pool);
752        assert_eq!(frame.width(), 80);
753        assert_eq!(frame.height(), 24);
754        assert!(frame.hit_grid.is_none());
755        assert!(frame.cursor_position.is_none());
756        assert!(frame.cursor_visible);
757    }
758
759    #[test]
760    fn frame_with_hit_grid() {
761        let mut pool = GraphemePool::new();
762        let frame = Frame::with_hit_grid(80, 24, &mut pool);
763        assert!(frame.hit_grid.is_some());
764        assert_eq!(frame.width(), 80);
765        assert_eq!(frame.height(), 24);
766    }
767
768    #[test]
769    fn frame_cursor() {
770        let mut pool = GraphemePool::new();
771        let mut frame = Frame::new(80, 24, &mut pool);
772        assert!(frame.cursor_position.is_none());
773        assert!(frame.cursor_visible);
774
775        frame.set_cursor(Some((10, 5)));
776        assert_eq!(frame.cursor_position, Some((10, 5)));
777
778        frame.set_cursor_visible(false);
779        assert!(!frame.cursor_visible);
780
781        frame.set_cursor(None);
782        assert!(frame.cursor_position.is_none());
783    }
784
785    #[test]
786    fn frame_clear() {
787        let mut pool = GraphemePool::new();
788        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
789
790        // Add some content
791        frame.buffer.set_raw(5, 5, Cell::from_char('X'));
792        frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
793
794        // Verify content exists
795        assert_eq!(frame.buffer.get(5, 5).unwrap().content.as_char(), Some('X'));
796        assert_eq!(
797            frame.hit_test(2, 2),
798            Some((HitId::new(1), HitRegion::Content, 0))
799        );
800
801        // Clear
802        frame.clear();
803
804        // Verify cleared
805        assert!(frame.buffer.get(5, 5).unwrap().is_empty());
806        assert!(frame.hit_test(2, 2).is_none());
807    }
808
809    #[test]
810    fn frame_bounds() {
811        let mut pool = GraphemePool::new();
812        let frame = Frame::new(80, 24, &mut pool);
813        let bounds = frame.bounds();
814        assert_eq!(bounds.x, 0);
815        assert_eq!(bounds.y, 0);
816        assert_eq!(bounds.width, 80);
817        assert_eq!(bounds.height, 24);
818    }
819
820    #[test]
821    fn hit_grid_creation() {
822        let grid = HitGrid::new(80, 24);
823        assert_eq!(grid.width(), 80);
824        assert_eq!(grid.height(), 24);
825    }
826
827    #[test]
828    fn hit_grid_registration() {
829        let mut pool = GraphemePool::new();
830        let mut frame = Frame::with_hit_grid(80, 24, &mut pool);
831        let hit_id = HitId::new(42);
832        let rect = Rect::new(10, 5, 20, 3);
833
834        frame.register_hit(rect, hit_id, HitRegion::Button, 99);
835
836        // Inside rect
837        assert_eq!(frame.hit_test(15, 6), Some((hit_id, HitRegion::Button, 99)));
838        assert_eq!(frame.hit_test(10, 5), Some((hit_id, HitRegion::Button, 99))); // Top-left corner
839        assert_eq!(frame.hit_test(29, 7), Some((hit_id, HitRegion::Button, 99))); // Bottom-right corner
840
841        // Outside rect
842        assert!(frame.hit_test(5, 5).is_none()); // Left of rect
843        assert!(frame.hit_test(30, 6).is_none()); // Right of rect (exclusive)
844        assert!(frame.hit_test(15, 8).is_none()); // Below rect
845        assert!(frame.hit_test(15, 4).is_none()); // Above rect
846    }
847
848    #[test]
849    fn hit_grid_overlapping_regions() {
850        let mut pool = GraphemePool::new();
851        let mut frame = Frame::with_hit_grid(20, 20, &mut pool);
852
853        // Register two overlapping regions
854        frame.register_hit(
855            Rect::new(0, 0, 10, 10),
856            HitId::new(1),
857            HitRegion::Content,
858            1,
859        );
860        frame.register_hit(Rect::new(5, 5, 10, 10), HitId::new(2), HitRegion::Border, 2);
861
862        // Non-overlapping region from first
863        assert_eq!(
864            frame.hit_test(2, 2),
865            Some((HitId::new(1), HitRegion::Content, 1))
866        );
867
868        // Overlapping region - second wins (last registered)
869        assert_eq!(
870            frame.hit_test(7, 7),
871            Some((HitId::new(2), HitRegion::Border, 2))
872        );
873
874        // Non-overlapping region from second
875        assert_eq!(
876            frame.hit_test(12, 12),
877            Some((HitId::new(2), HitRegion::Border, 2))
878        );
879    }
880
881    #[test]
882    fn hit_grid_out_of_bounds() {
883        let mut pool = GraphemePool::new();
884        let frame = Frame::with_hit_grid(10, 10, &mut pool);
885
886        // Out of bounds returns None
887        assert!(frame.hit_test(100, 100).is_none());
888        assert!(frame.hit_test(10, 0).is_none()); // Exclusive bound
889        assert!(frame.hit_test(0, 10).is_none()); // Exclusive bound
890    }
891
892    #[test]
893    fn hit_id_properties() {
894        let id = HitId::new(42);
895        assert_eq!(id.id(), 42);
896        assert_eq!(id, HitId(42));
897    }
898
899    #[test]
900    fn register_hit_region_no_grid() {
901        let mut pool = GraphemePool::new();
902        let mut frame = Frame::new(10, 10, &mut pool);
903        let result = frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
904        assert!(!result); // No hit grid, returns false
905    }
906
907    #[test]
908    fn register_hit_region_with_grid() {
909        let mut pool = GraphemePool::new();
910        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
911        let result = frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
912        assert!(result); // Has hit grid, returns true
913    }
914
915    #[test]
916    fn hit_grid_clear() {
917        let mut grid = HitGrid::new(10, 10);
918        grid.register(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
919
920        assert_eq!(
921            grid.hit_test(2, 2),
922            Some((HitId::new(1), HitRegion::Content, 0))
923        );
924
925        grid.clear();
926
927        assert!(grid.hit_test(2, 2).is_none());
928    }
929
930    #[test]
931    fn hit_grid_boundary_clipping() {
932        let mut grid = HitGrid::new(10, 10);
933
934        // Register region that extends beyond grid
935        grid.register(
936            Rect::new(8, 8, 10, 10),
937            HitId::new(1),
938            HitRegion::Content,
939            0,
940        );
941
942        // Inside clipped region
943        assert_eq!(
944            grid.hit_test(9, 9),
945            Some((HitId::new(1), HitRegion::Content, 0))
946        );
947
948        // Outside grid
949        assert!(grid.hit_test(10, 10).is_none());
950    }
951
952    #[test]
953    fn hit_grid_edge_and_corner_cells() {
954        let mut grid = HitGrid::new(4, 4);
955        grid.register(Rect::new(3, 0, 1, 4), HitId::new(7), HitRegion::Border, 11);
956
957        // Right-most column corners
958        assert_eq!(
959            grid.hit_test(3, 0),
960            Some((HitId::new(7), HitRegion::Border, 11))
961        );
962        assert_eq!(
963            grid.hit_test(3, 3),
964            Some((HitId::new(7), HitRegion::Border, 11))
965        );
966
967        // Neighboring cells remain empty
968        assert!(grid.hit_test(2, 0).is_none());
969        assert!(grid.hit_test(4, 0).is_none());
970        assert!(grid.hit_test(3, 4).is_none());
971
972        let mut grid = HitGrid::new(4, 4);
973        grid.register(Rect::new(0, 3, 4, 1), HitId::new(9), HitRegion::Content, 21);
974
975        // Bottom row corners
976        assert_eq!(
977            grid.hit_test(0, 3),
978            Some((HitId::new(9), HitRegion::Content, 21))
979        );
980        assert_eq!(
981            grid.hit_test(3, 3),
982            Some((HitId::new(9), HitRegion::Content, 21))
983        );
984
985        // Outside bottom row
986        assert!(grid.hit_test(0, 2).is_none());
987        assert!(grid.hit_test(0, 4).is_none());
988    }
989
990    #[test]
991    fn frame_register_hit_respects_nested_scissor() {
992        let mut pool = GraphemePool::new();
993        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
994
995        let outer = Rect::new(1, 1, 8, 8);
996        frame.buffer.push_scissor(outer);
997        assert_eq!(frame.buffer.current_scissor(), outer);
998
999        let inner = Rect::new(4, 4, 10, 10);
1000        frame.buffer.push_scissor(inner);
1001        let clipped = outer.intersection(&inner);
1002        let current = frame.buffer.current_scissor();
1003        assert_eq!(current, clipped);
1004
1005        // Monotonic intersection: inner scissor must stay within outer.
1006        assert!(outer.contains(current.x, current.y));
1007        assert!(outer.contains(
1008            current.right().saturating_sub(1),
1009            current.bottom().saturating_sub(1)
1010        ));
1011
1012        frame.register_hit(
1013            Rect::new(0, 0, 10, 10),
1014            HitId::new(3),
1015            HitRegion::Button,
1016            99,
1017        );
1018
1019        assert_eq!(
1020            frame.hit_test(4, 4),
1021            Some((HitId::new(3), HitRegion::Button, 99))
1022        );
1023        assert_eq!(
1024            frame.hit_test(8, 8),
1025            Some((HitId::new(3), HitRegion::Button, 99))
1026        );
1027        assert!(frame.hit_test(3, 3).is_none()); // inside outer, outside inner
1028        assert!(frame.hit_test(0, 0).is_none()); // outside all scissor
1029
1030        frame.buffer.pop_scissor();
1031        assert_eq!(frame.buffer.current_scissor(), outer);
1032    }
1033
1034    #[test]
1035    fn hit_grid_hits_in_area() {
1036        let mut grid = HitGrid::new(5, 5);
1037        grid.register(Rect::new(0, 0, 2, 2), HitId::new(1), HitRegion::Content, 10);
1038        grid.register(Rect::new(1, 1, 2, 2), HitId::new(2), HitRegion::Button, 20);
1039
1040        let hits = grid.hits_in(Rect::new(0, 0, 3, 3));
1041        assert!(hits.contains(&(HitId::new(1), HitRegion::Content, 10)));
1042        assert!(hits.contains(&(HitId::new(2), HitRegion::Button, 20)));
1043    }
1044
1045    #[test]
1046    fn frame_intern() {
1047        let mut pool = GraphemePool::new();
1048        let mut frame = Frame::new(10, 10, &mut pool);
1049
1050        let id = frame.intern("👋");
1051        assert_eq!(frame.pool.get(id), Some("👋"));
1052    }
1053
1054    #[test]
1055    fn frame_intern_with_width() {
1056        let mut pool = GraphemePool::new();
1057        let mut frame = Frame::new(10, 10, &mut pool);
1058
1059        let id = frame.intern_with_width("🧪", 2);
1060        assert_eq!(id.width(), 2);
1061        assert_eq!(frame.pool.get(id), Some("🧪"));
1062    }
1063
1064    #[test]
1065    fn frame_print_text_emoji_presentation_sets_continuation() {
1066        let mut pool = GraphemePool::new();
1067        let mut frame = Frame::new(5, 1, &mut pool);
1068
1069        // Use a skin-tone modifier sequence (width 2, multi-codepoint, no VS16
1070        // dependency) so the test is independent of ftui-core's VS16 policy.
1071        frame.print_text(0, 0, "👍🏽", Cell::from_char(' '));
1072
1073        let head = frame.buffer.get(0, 0).unwrap();
1074        let tail = frame.buffer.get(1, 0).unwrap();
1075
1076        assert_eq!(head.content.width(), 2);
1077        assert!(tail.content.is_continuation());
1078    }
1079
1080    #[test]
1081    fn frame_enable_hit_testing() {
1082        let mut pool = GraphemePool::new();
1083        let mut frame = Frame::new(10, 10, &mut pool);
1084        assert!(frame.hit_grid.is_none());
1085
1086        frame.enable_hit_testing();
1087        assert!(frame.hit_grid.is_some());
1088
1089        // Calling again is idempotent
1090        frame.enable_hit_testing();
1091        assert!(frame.hit_grid.is_some());
1092    }
1093
1094    #[test]
1095    fn frame_enable_hit_testing_then_register() {
1096        let mut pool = GraphemePool::new();
1097        let mut frame = Frame::new(10, 10, &mut pool);
1098        frame.enable_hit_testing();
1099
1100        let registered = frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
1101        assert!(registered);
1102        assert_eq!(
1103            frame.hit_test(2, 2),
1104            Some((HitId::new(1), HitRegion::Content, 0))
1105        );
1106    }
1107
1108    #[test]
1109    fn hit_cell_default_is_empty() {
1110        let cell = HitCell::default();
1111        assert!(cell.is_empty());
1112        assert_eq!(cell.widget_id, None);
1113        assert_eq!(cell.region, HitRegion::None);
1114        assert_eq!(cell.data, 0);
1115    }
1116
1117    #[test]
1118    fn hit_cell_new_is_not_empty() {
1119        let cell = HitCell::new(HitId::new(1), HitRegion::Button, 42);
1120        assert!(!cell.is_empty());
1121        assert_eq!(cell.widget_id, Some(HitId::new(1)));
1122        assert_eq!(cell.region, HitRegion::Button);
1123        assert_eq!(cell.data, 42);
1124    }
1125
1126    #[test]
1127    fn hit_region_variants() {
1128        assert_eq!(HitRegion::default(), HitRegion::None);
1129
1130        // All variants are distinct
1131        let variants = [
1132            HitRegion::None,
1133            HitRegion::Content,
1134            HitRegion::Border,
1135            HitRegion::Scrollbar,
1136            HitRegion::Handle,
1137            HitRegion::Button,
1138            HitRegion::Link,
1139            HitRegion::Custom(0),
1140            HitRegion::Custom(1),
1141            HitRegion::Custom(255),
1142        ];
1143        for i in 0..variants.len() {
1144            for j in (i + 1)..variants.len() {
1145                assert_ne!(
1146                    variants[i], variants[j],
1147                    "variants {i} and {j} should differ"
1148                );
1149            }
1150        }
1151    }
1152
1153    #[test]
1154    fn hit_id_default() {
1155        let id = HitId::default();
1156        assert_eq!(id.id(), 0);
1157    }
1158
1159    #[test]
1160    fn hit_grid_initial_cells_empty() {
1161        let grid = HitGrid::new(5, 5);
1162        for y in 0..5 {
1163            for x in 0..5 {
1164                let cell = grid.get(x, y).unwrap();
1165                assert!(cell.is_empty());
1166            }
1167        }
1168    }
1169
1170    #[test]
1171    fn hit_grid_zero_dimensions() {
1172        let grid = HitGrid::new(0, 0);
1173        assert_eq!(grid.width(), 0);
1174        assert_eq!(grid.height(), 0);
1175        assert!(grid.get(0, 0).is_none());
1176        assert!(grid.hit_test(0, 0).is_none());
1177    }
1178
1179    #[test]
1180    fn hit_grid_hits_in_empty_area() {
1181        let grid = HitGrid::new(10, 10);
1182        let hits = grid.hits_in(Rect::new(0, 0, 5, 5));
1183        // All cells are empty, so no actual HitId hits
1184        assert!(hits.is_empty());
1185    }
1186
1187    #[test]
1188    fn hit_grid_hits_in_clipped_area() {
1189        let mut grid = HitGrid::new(5, 5);
1190        grid.register(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
1191
1192        // Query area extends beyond grid — should be clipped
1193        let hits = grid.hits_in(Rect::new(3, 3, 10, 10));
1194        assert_eq!(hits.len(), 4); // 2x2 cells inside grid
1195    }
1196
1197    #[test]
1198    fn hit_test_no_grid_returns_none() {
1199        let mut pool = GraphemePool::new();
1200        let frame = Frame::new(10, 10, &mut pool);
1201        assert!(frame.hit_test(0, 0).is_none());
1202    }
1203
1204    #[test]
1205    fn frame_cursor_operations() {
1206        let mut pool = GraphemePool::new();
1207        let mut frame = Frame::new(80, 24, &mut pool);
1208
1209        // Set position at edge of frame
1210        frame.set_cursor(Some((79, 23)));
1211        assert_eq!(frame.cursor_position, Some((79, 23)));
1212
1213        // Set position at origin
1214        frame.set_cursor(Some((0, 0)));
1215        assert_eq!(frame.cursor_position, Some((0, 0)));
1216
1217        // Toggle visibility
1218        frame.set_cursor_visible(false);
1219        assert!(!frame.cursor_visible);
1220        frame.set_cursor_visible(true);
1221        assert!(frame.cursor_visible);
1222    }
1223
1224    #[test]
1225    fn hit_data_large_values() {
1226        let mut grid = HitGrid::new(5, 5);
1227        // HitData is u64, test max value
1228        grid.register(
1229            Rect::new(0, 0, 1, 1),
1230            HitId::new(1),
1231            HitRegion::Content,
1232            u64::MAX,
1233        );
1234        let result = grid.hit_test(0, 0);
1235        assert_eq!(result, Some((HitId::new(1), HitRegion::Content, u64::MAX)));
1236    }
1237
1238    #[test]
1239    fn hit_id_large_value() {
1240        let id = HitId::new(u32::MAX);
1241        assert_eq!(id.id(), u32::MAX);
1242    }
1243
1244    #[test]
1245    fn frame_print_text_interns_complex_graphemes() {
1246        let mut pool = GraphemePool::new();
1247        let mut frame = Frame::new(10, 1, &mut pool);
1248
1249        // Flag emoji (complex grapheme)
1250        let flag = "🇺🇸";
1251        assert!(flag.chars().count() > 1);
1252
1253        frame.print_text(0, 0, flag, Cell::default());
1254
1255        let cell = frame.buffer.get(0, 0).unwrap();
1256        assert!(cell.content.is_grapheme());
1257
1258        let id = cell.content.grapheme_id().unwrap();
1259        assert_eq!(frame.pool.get(id), Some(flag));
1260    }
1261
1262    // --- HitId trait coverage ---
1263
1264    #[test]
1265    fn hit_id_debug_clone_copy_hash() {
1266        let id = HitId::new(99);
1267        let dbg = format!("{:?}", id);
1268        assert!(dbg.contains("99"), "Debug: {dbg}");
1269        let copied: HitId = id; // Copy
1270        assert_eq!(id, copied);
1271        // Hash: insert into set
1272        use std::collections::HashSet;
1273        let mut set = HashSet::new();
1274        set.insert(id);
1275        set.insert(HitId::new(99));
1276        assert_eq!(set.len(), 1);
1277        set.insert(HitId::new(100));
1278        assert_eq!(set.len(), 2);
1279    }
1280
1281    #[test]
1282    fn hit_id_eq_and_ne() {
1283        assert_eq!(HitId::new(0), HitId::new(0));
1284        assert_ne!(HitId::new(0), HitId::new(1));
1285        assert_ne!(HitId::new(u32::MAX), HitId::default());
1286    }
1287
1288    // --- HitRegion trait coverage ---
1289
1290    #[test]
1291    fn hit_region_debug_clone_copy_hash() {
1292        let r = HitRegion::Custom(42);
1293        let dbg = format!("{:?}", r);
1294        assert!(dbg.contains("Custom"), "Debug: {dbg}");
1295        let copied: HitRegion = r; // Copy
1296        assert_eq!(r, copied);
1297        use std::collections::HashSet;
1298        let mut set = HashSet::new();
1299        set.insert(r);
1300        set.insert(HitRegion::Custom(42));
1301        assert_eq!(set.len(), 1);
1302    }
1303
1304    // --- HitCell trait coverage ---
1305
1306    #[test]
1307    fn hit_cell_debug_clone_copy_eq() {
1308        let cell = HitCell::new(HitId::new(5), HitRegion::Link, 123);
1309        let dbg = format!("{:?}", cell);
1310        assert!(dbg.contains("Link"), "Debug: {dbg}");
1311        let copied: HitCell = cell; // Copy
1312        assert_eq!(cell, copied);
1313        // ne
1314        assert_ne!(cell, HitCell::default());
1315    }
1316
1317    // --- HitGrid edge cases ---
1318
1319    #[test]
1320    fn hit_grid_clone() {
1321        let mut grid = HitGrid::new(5, 5);
1322        grid.register(Rect::new(0, 0, 2, 2), HitId::new(1), HitRegion::Content, 7);
1323        let clone = grid.clone();
1324        assert_eq!(clone.width(), 5);
1325        assert_eq!(
1326            clone.hit_test(0, 0),
1327            Some((HitId::new(1), HitRegion::Content, 7))
1328        );
1329    }
1330
1331    #[test]
1332    fn hit_grid_get_mut() {
1333        let mut grid = HitGrid::new(5, 5);
1334        // Mutate a cell directly
1335        if let Some(cell) = grid.get_mut(2, 3) {
1336            *cell = HitCell::new(HitId::new(77), HitRegion::Handle, 55);
1337        }
1338        assert_eq!(
1339            grid.hit_test(2, 3),
1340            Some((HitId::new(77), HitRegion::Handle, 55))
1341        );
1342        // Out of bounds returns None
1343        assert!(grid.get_mut(5, 5).is_none());
1344    }
1345
1346    #[test]
1347    fn hit_grid_zero_width_nonzero_height() {
1348        let grid = HitGrid::new(0, 10);
1349        assert_eq!(grid.width(), 0);
1350        assert_eq!(grid.height(), 10);
1351        assert!(grid.get(0, 0).is_none());
1352        assert!(grid.hit_test(0, 5).is_none());
1353    }
1354
1355    #[test]
1356    fn hit_grid_nonzero_width_zero_height() {
1357        let grid = HitGrid::new(10, 0);
1358        assert_eq!(grid.width(), 10);
1359        assert_eq!(grid.height(), 0);
1360        assert!(grid.get(0, 0).is_none());
1361    }
1362
1363    #[test]
1364    fn hit_grid_register_zero_width_rect() {
1365        let mut grid = HitGrid::new(10, 10);
1366        grid.register(Rect::new(2, 2, 0, 5), HitId::new(1), HitRegion::Content, 0);
1367        // Nothing should be registered
1368        assert!(grid.hit_test(2, 2).is_none());
1369    }
1370
1371    #[test]
1372    fn hit_grid_register_zero_height_rect() {
1373        let mut grid = HitGrid::new(10, 10);
1374        grid.register(Rect::new(2, 2, 5, 0), HitId::new(1), HitRegion::Content, 0);
1375        assert!(grid.hit_test(2, 2).is_none());
1376    }
1377
1378    #[test]
1379    fn hit_grid_register_past_bounds() {
1380        let mut grid = HitGrid::new(10, 10);
1381        // Rect starts past the grid boundary
1382        grid.register(
1383            Rect::new(10, 10, 5, 5),
1384            HitId::new(1),
1385            HitRegion::Content,
1386            0,
1387        );
1388        assert!(grid.hit_test(9, 9).is_none());
1389    }
1390
1391    #[test]
1392    fn hit_grid_full_coverage() {
1393        let mut grid = HitGrid::new(3, 3);
1394        grid.register(Rect::new(0, 0, 3, 3), HitId::new(1), HitRegion::Content, 0);
1395        // Every cell should be filled
1396        for y in 0..3 {
1397            for x in 0..3 {
1398                assert_eq!(
1399                    grid.hit_test(x, y),
1400                    Some((HitId::new(1), HitRegion::Content, 0))
1401                );
1402            }
1403        }
1404    }
1405
1406    #[test]
1407    fn hit_grid_single_cell() {
1408        let mut grid = HitGrid::new(1, 1);
1409        grid.register(Rect::new(0, 0, 1, 1), HitId::new(1), HitRegion::Button, 42);
1410        assert_eq!(
1411            grid.hit_test(0, 0),
1412            Some((HitId::new(1), HitRegion::Button, 42))
1413        );
1414        assert!(grid.hit_test(1, 0).is_none());
1415        assert!(grid.hit_test(0, 1).is_none());
1416    }
1417
1418    #[test]
1419    fn hit_grid_hits_in_outside_rect() {
1420        let mut grid = HitGrid::new(5, 5);
1421        grid.register(Rect::new(0, 0, 2, 2), HitId::new(1), HitRegion::Content, 0);
1422        // Query area completely outside registered region
1423        let hits = grid.hits_in(Rect::new(3, 3, 2, 2));
1424        assert!(hits.is_empty());
1425    }
1426
1427    #[test]
1428    fn hit_grid_hits_in_zero_rect() {
1429        let mut grid = HitGrid::new(5, 5);
1430        grid.register(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
1431        let hits = grid.hits_in(Rect::new(2, 2, 0, 0));
1432        assert!(hits.is_empty());
1433    }
1434
1435    // --- CostEstimateSource ---
1436
1437    #[test]
1438    fn cost_estimate_source_traits() {
1439        let a = CostEstimateSource::Measured;
1440        let b = CostEstimateSource::AreaFallback;
1441        let c = CostEstimateSource::FixedDefault;
1442        let dbg = format!("{:?}", a);
1443        assert!(dbg.contains("Measured"), "Debug: {dbg}");
1444
1445        // Default
1446        assert_eq!(
1447            CostEstimateSource::default(),
1448            CostEstimateSource::FixedDefault
1449        );
1450
1451        // Clone/Copy
1452        let copied: CostEstimateSource = a;
1453        assert_eq!(a, copied);
1454
1455        // All variants distinct
1456        assert_ne!(a, b);
1457        assert_ne!(b, c);
1458        assert_ne!(a, c);
1459    }
1460
1461    // --- WidgetSignal ---
1462
1463    #[test]
1464    fn widget_signal_default() {
1465        let sig = WidgetSignal::default();
1466        assert_eq!(sig.widget_id, 0);
1467        assert!(!sig.essential);
1468        assert!((sig.priority - 0.5).abs() < f32::EPSILON);
1469        assert_eq!(sig.staleness_ms, 0);
1470        assert!((sig.focus_boost - 0.0).abs() < f32::EPSILON);
1471        assert!((sig.interaction_boost - 0.0).abs() < f32::EPSILON);
1472        assert_eq!(sig.area_cells, 1);
1473        assert!((sig.cost_estimate_us - 5.0).abs() < f32::EPSILON);
1474        assert!((sig.recent_cost_us - 5.0).abs() < f32::EPSILON);
1475        assert_eq!(sig.estimate_source, CostEstimateSource::FixedDefault);
1476    }
1477
1478    #[test]
1479    fn widget_signal_new() {
1480        let sig = WidgetSignal::new(42);
1481        assert_eq!(sig.widget_id, 42);
1482        // Other fields should be default
1483        assert!(!sig.essential);
1484        assert!((sig.priority - 0.5).abs() < f32::EPSILON);
1485    }
1486
1487    #[test]
1488    fn widget_signal_debug_clone() {
1489        let sig = WidgetSignal::new(7);
1490        let dbg = format!("{:?}", sig);
1491        assert!(dbg.contains("widget_id"), "Debug: {dbg}");
1492        let cloned = sig.clone();
1493        assert_eq!(cloned.widget_id, 7);
1494    }
1495
1496    // --- WidgetBudget ---
1497
1498    #[test]
1499    fn widget_budget_default_is_allow_all() {
1500        let budget = WidgetBudget::default();
1501        assert!(budget.allows(0, false));
1502        assert!(budget.allows(u64::MAX, false));
1503        assert!(budget.allows(42, true));
1504    }
1505
1506    #[test]
1507    fn widget_budget_allow_only() {
1508        let budget = WidgetBudget::allow_only(vec![10, 20, 30]);
1509        assert!(budget.allows(10, false));
1510        assert!(budget.allows(20, false));
1511        assert!(budget.allows(30, false));
1512        assert!(!budget.allows(15, false));
1513        assert!(!budget.allows(0, false));
1514    }
1515
1516    #[test]
1517    fn widget_budget_essential_always_allowed() {
1518        let budget = WidgetBudget::allow_only(vec![10]);
1519        // Essential widgets bypass the allow list
1520        assert!(budget.allows(999, true));
1521        assert!(budget.allows(0, true));
1522    }
1523
1524    #[test]
1525    fn widget_budget_allow_only_dedup() {
1526        let budget = WidgetBudget::allow_only(vec![5, 5, 5, 10, 10]);
1527        assert!(budget.allows(5, false));
1528        assert!(budget.allows(10, false));
1529        assert!(!budget.allows(7, false));
1530    }
1531
1532    #[test]
1533    fn widget_budget_allow_only_empty() {
1534        let budget = WidgetBudget::allow_only(vec![]);
1535        // No widgets allowed (except essential)
1536        assert!(!budget.allows(0, false));
1537        assert!(!budget.allows(1, false));
1538        assert!(budget.allows(1, true)); // essential always passes
1539    }
1540
1541    #[test]
1542    fn widget_budget_debug_clone() {
1543        let budget = WidgetBudget::allow_only(vec![1, 2, 3]);
1544        let dbg = format!("{:?}", budget);
1545        assert!(dbg.contains("allow_list"), "Debug: {dbg}");
1546        let cloned = budget.clone();
1547        assert!(cloned.allows(2, false));
1548    }
1549
1550    // --- Frame construction variants ---
1551
1552    #[test]
1553    #[should_panic(expected = "buffer width must be > 0")]
1554    fn frame_zero_dimensions_panics() {
1555        let mut pool = GraphemePool::new();
1556        let _frame = Frame::new(0, 0, &mut pool);
1557    }
1558
1559    #[test]
1560    fn frame_from_buffer() {
1561        let mut pool = GraphemePool::new();
1562        let mut buf = Buffer::new(20, 10);
1563        buf.set_raw(5, 5, Cell::from_char('Z'));
1564        let frame = Frame::from_buffer(buf, &mut pool);
1565        assert_eq!(frame.width(), 20);
1566        assert_eq!(frame.height(), 10);
1567        assert_eq!(frame.buffer.get(5, 5).unwrap().content.as_char(), Some('Z'));
1568        assert!(frame.hit_grid.is_none());
1569        assert!(frame.cursor_visible);
1570    }
1571
1572    #[test]
1573    fn frame_with_links() {
1574        let mut pool = GraphemePool::new();
1575        let mut links = LinkRegistry::new();
1576        let frame = Frame::with_links(10, 5, &mut pool, &mut links);
1577        assert!(frame.links.is_some());
1578        assert_eq!(frame.width(), 10);
1579        assert_eq!(frame.height(), 5);
1580    }
1581
1582    #[test]
1583    fn frame_set_links() {
1584        let mut pool = GraphemePool::new();
1585        let mut links = LinkRegistry::new();
1586        let mut frame = Frame::new(10, 5, &mut pool);
1587        assert!(frame.links.is_none());
1588        frame.set_links(&mut links);
1589        assert!(frame.links.is_some());
1590    }
1591
1592    #[test]
1593    fn frame_register_link_no_registry() {
1594        let mut pool = GraphemePool::new();
1595        let mut frame = Frame::new(10, 5, &mut pool);
1596        // No link registry => returns 0
1597        let id = frame.register_link("https://example.com");
1598        assert_eq!(id, 0);
1599    }
1600
1601    #[test]
1602    fn frame_register_link_with_registry() {
1603        let mut pool = GraphemePool::new();
1604        let mut links = LinkRegistry::new();
1605        let mut frame = Frame::with_links(10, 5, &mut pool, &mut links);
1606        let id = frame.register_link("https://example.com");
1607        assert!(id > 0);
1608        // Same URL should return same ID
1609        let id2 = frame.register_link("https://example.com");
1610        assert_eq!(id, id2);
1611        // Different URL should return different ID
1612        let id3 = frame.register_link("https://other.com");
1613        assert_ne!(id, id3);
1614    }
1615
1616    // --- Frame widget budget integration ---
1617
1618    #[test]
1619    fn frame_set_widget_budget() {
1620        let mut pool = GraphemePool::new();
1621        let mut frame = Frame::new(10, 10, &mut pool);
1622
1623        // Default allows all
1624        assert!(frame.should_render_widget(42, false));
1625
1626        // Set restricted budget
1627        frame.set_widget_budget(WidgetBudget::allow_only(vec![1, 2]));
1628        assert!(frame.should_render_widget(1, false));
1629        assert!(!frame.should_render_widget(42, false));
1630        assert!(frame.should_render_widget(42, true)); // essential
1631    }
1632
1633    // --- Frame widget signals ---
1634
1635    #[test]
1636    fn frame_widget_signals_lifecycle() {
1637        let mut pool = GraphemePool::new();
1638        let mut frame = Frame::new(10, 10, &mut pool);
1639        assert!(frame.widget_signals().is_empty());
1640
1641        frame.register_widget_signal(WidgetSignal::new(1));
1642        frame.register_widget_signal(WidgetSignal::new(2));
1643        assert_eq!(frame.widget_signals().len(), 2);
1644        assert_eq!(frame.widget_signals()[0].widget_id, 1);
1645        assert_eq!(frame.widget_signals()[1].widget_id, 2);
1646
1647        let taken = frame.take_widget_signals();
1648        assert_eq!(taken.len(), 2);
1649        assert!(frame.widget_signals().is_empty());
1650    }
1651
1652    #[test]
1653    fn frame_clear_resets_signals_and_cursor() {
1654        let mut pool = GraphemePool::new();
1655        let mut frame = Frame::new(10, 10, &mut pool);
1656        frame.set_cursor(Some((5, 5)));
1657        frame.register_widget_signal(WidgetSignal::new(1));
1658        assert!(frame.cursor_position.is_some());
1659        assert!(!frame.widget_signals().is_empty());
1660
1661        frame.clear();
1662        assert!(frame.cursor_position.is_none());
1663        assert!(frame.widget_signals().is_empty());
1664    }
1665
1666    // --- Frame degradation ---
1667
1668    #[test]
1669    fn frame_set_degradation_propagates_to_buffer() {
1670        let mut pool = GraphemePool::new();
1671        let mut frame = Frame::new(10, 10, &mut pool);
1672        assert_eq!(frame.degradation, DegradationLevel::Full);
1673        assert_eq!(frame.buffer.degradation, DegradationLevel::Full);
1674
1675        frame.set_degradation(DegradationLevel::SimpleBorders);
1676        assert_eq!(frame.degradation, DegradationLevel::SimpleBorders);
1677        assert_eq!(frame.buffer.degradation, DegradationLevel::SimpleBorders);
1678
1679        frame.set_degradation(DegradationLevel::EssentialOnly);
1680        assert_eq!(frame.degradation, DegradationLevel::EssentialOnly);
1681        assert_eq!(frame.buffer.degradation, DegradationLevel::EssentialOnly);
1682    }
1683
1684    // --- Frame hit grid with zero-size screen ---
1685
1686    #[test]
1687    #[should_panic(expected = "buffer width must be > 0")]
1688    fn frame_with_hit_grid_zero_size_panics() {
1689        let mut pool = GraphemePool::new();
1690        let _frame = Frame::with_hit_grid(0, 0, &mut pool);
1691    }
1692
1693    // --- Frame register_hit returns true/false correctly ---
1694
1695    #[test]
1696    fn frame_register_hit_with_all_regions() {
1697        let mut pool = GraphemePool::new();
1698        let mut frame = Frame::with_hit_grid(20, 20, &mut pool);
1699        let regions = [
1700            HitRegion::Content,
1701            HitRegion::Border,
1702            HitRegion::Scrollbar,
1703            HitRegion::Handle,
1704            HitRegion::Button,
1705            HitRegion::Link,
1706            HitRegion::Custom(0),
1707            HitRegion::Custom(255),
1708        ];
1709        for (i, &region) in regions.iter().enumerate() {
1710            let y = i as u16;
1711            frame.register_hit(Rect::new(0, y, 1, 1), HitId::new(i as u32), region, 0);
1712        }
1713        for (i, &region) in regions.iter().enumerate() {
1714            let y = i as u16;
1715            assert_eq!(
1716                frame.hit_test(0, y),
1717                Some((HitId::new(i as u32), region, 0))
1718            );
1719        }
1720    }
1721
1722    // --- Frame Draw trait ---
1723
1724    #[test]
1725    fn frame_draw_horizontal_line() {
1726        let mut pool = GraphemePool::new();
1727        let mut frame = Frame::new(10, 5, &mut pool);
1728        let cell = Cell::from_char('-');
1729        frame.draw_horizontal_line(2, 1, 5, cell);
1730        for x in 2..7 {
1731            assert_eq!(frame.buffer.get(x, 1).unwrap().content.as_char(), Some('-'));
1732        }
1733        // Neighbors untouched
1734        assert!(frame.buffer.get(1, 1).unwrap().is_empty());
1735        assert!(frame.buffer.get(7, 1).unwrap().is_empty());
1736    }
1737
1738    #[test]
1739    fn frame_draw_vertical_line() {
1740        let mut pool = GraphemePool::new();
1741        let mut frame = Frame::new(10, 10, &mut pool);
1742        let cell = Cell::from_char('|');
1743        frame.draw_vertical_line(3, 2, 4, cell);
1744        for y in 2..6 {
1745            assert_eq!(frame.buffer.get(3, y).unwrap().content.as_char(), Some('|'));
1746        }
1747        assert!(frame.buffer.get(3, 1).unwrap().is_empty());
1748        assert!(frame.buffer.get(3, 6).unwrap().is_empty());
1749    }
1750
1751    #[test]
1752    fn frame_draw_rect_filled() {
1753        let mut pool = GraphemePool::new();
1754        let mut frame = Frame::new(10, 10, &mut pool);
1755        let cell = Cell::from_char('#');
1756        frame.draw_rect_filled(Rect::new(1, 1, 3, 3), cell);
1757        for y in 1..4 {
1758            for x in 1..4 {
1759                assert_eq!(frame.buffer.get(x, y).unwrap().content.as_char(), Some('#'));
1760            }
1761        }
1762        // Outside
1763        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
1764        assert!(frame.buffer.get(4, 4).unwrap().is_empty());
1765    }
1766
1767    #[test]
1768    fn frame_paint_area() {
1769        use crate::cell::PackedRgba;
1770        let mut pool = GraphemePool::new();
1771        let mut frame = Frame::new(5, 5, &mut pool);
1772        let red = PackedRgba::rgb(255, 0, 0);
1773        frame.paint_area(Rect::new(0, 0, 2, 2), Some(red), None);
1774        let cell = frame.buffer.get(0, 0).unwrap();
1775        assert_eq!(cell.fg, red);
1776    }
1777
1778    // --- Frame print_text_clipped ---
1779
1780    #[test]
1781    fn frame_print_text_clipped_at_boundary() {
1782        let mut pool = GraphemePool::new();
1783        let mut frame = Frame::new(5, 1, &mut pool);
1784        // "Hello World" should be clipped at width 5
1785        let end = frame.print_text(0, 0, "Hello World", Cell::from_char(' '));
1786        assert_eq!(end, 5);
1787        for x in 0..5 {
1788            assert!(!frame.buffer.get(x, 0).unwrap().is_empty());
1789        }
1790    }
1791
1792    #[test]
1793    fn frame_print_text_empty_string() {
1794        let mut pool = GraphemePool::new();
1795        let mut frame = Frame::new(10, 1, &mut pool);
1796        let end = frame.print_text(0, 0, "", Cell::from_char(' '));
1797        assert_eq!(end, 0);
1798    }
1799
1800    #[test]
1801    fn frame_print_text_at_right_edge() {
1802        let mut pool = GraphemePool::new();
1803        let mut frame = Frame::new(5, 1, &mut pool);
1804        // Start at x=4, only 1 cell fits
1805        let end = frame.print_text(4, 0, "AB", Cell::from_char(' '));
1806        assert_eq!(end, 5);
1807        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('A'));
1808    }
1809
1810    // --- Frame Debug ---
1811
1812    #[test]
1813    fn frame_debug() {
1814        let mut pool = GraphemePool::new();
1815        let frame = Frame::new(5, 3, &mut pool);
1816        let dbg = format!("{:?}", frame);
1817        assert!(dbg.contains("Frame"), "Debug: {dbg}");
1818    }
1819
1820    // --- HitGrid Debug ---
1821
1822    #[test]
1823    fn hit_grid_debug() {
1824        let grid = HitGrid::new(3, 3);
1825        let dbg = format!("{:?}", grid);
1826        assert!(dbg.contains("HitGrid"), "Debug: {dbg}");
1827    }
1828
1829    // --- Frame cursor beyond bounds ---
1830
1831    #[test]
1832    fn frame_cursor_beyond_bounds() {
1833        let mut pool = GraphemePool::new();
1834        let mut frame = Frame::new(10, 10, &mut pool);
1835        // Setting cursor beyond frame is allowed (no clipping)
1836        frame.set_cursor(Some((100, 200)));
1837        assert_eq!(frame.cursor_position, Some((100, 200)));
1838    }
1839
1840    // --- HitGrid large data values ---
1841
1842    #[test]
1843    fn hit_grid_register_overwrite() {
1844        let mut grid = HitGrid::new(5, 5);
1845        grid.register(Rect::new(0, 0, 3, 3), HitId::new(1), HitRegion::Content, 10);
1846        grid.register(Rect::new(0, 0, 3, 3), HitId::new(2), HitRegion::Button, 20);
1847        // Second registration overwrites first
1848        assert_eq!(
1849            grid.hit_test(1, 1),
1850            Some((HitId::new(2), HitRegion::Button, 20))
1851        );
1852    }
1853}