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