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        Self {
542            buffer: Buffer::new(width, height),
543            pool,
544            links: None,
545            hit_grid: Some(HitGrid::new(width, height)),
546            hit_owner_stack: Vec::new(),
547            widget_budget: WidgetBudget::default(),
548            widget_signals: Vec::new(),
549            cursor_position: None,
550            cursor_visible: true,
551            degradation: DegradationLevel::Full,
552            arena: None,
553        }
554    }
555
556    /// Set the link registry for this frame.
557    pub fn set_links(&mut self, links: &'a mut LinkRegistry) {
558        self.links = Some(links);
559    }
560
561    /// Set the per-frame bump arena for temporary allocations.
562    ///
563    /// Widgets can access the arena via [`arena()`](Self::arena) to
564    /// perform scratch allocations that only live for the current frame.
565    pub fn set_arena(&mut self, arena: &'a FrameArena) {
566        self.arena = Some(arena);
567    }
568
569    /// Returns the per-frame bump arena, if set.
570    ///
571    /// Widgets should use this for temporary allocations (formatted strings,
572    /// scratch slices) to avoid per-frame allocator churn.
573    pub fn arena(&self) -> Option<&FrameArena> {
574        self.arena
575    }
576
577    /// Register a hyperlink URL and return its ID.
578    ///
579    /// Returns 0 if link registry is not available or full.
580    pub fn register_link(&mut self, url: &str) -> u32 {
581        if let Some(ref mut links) = self.links {
582            links.register(url)
583        } else {
584            0
585        }
586    }
587
588    /// Set the widget render budget for this frame.
589    pub fn set_widget_budget(&mut self, budget: WidgetBudget) {
590        self.widget_budget = budget;
591    }
592
593    /// Check whether a widget should be rendered under the current budget.
594    #[inline]
595    pub fn should_render_widget(&self, widget_id: u64, essential: bool) -> bool {
596        self.widget_budget.allows(widget_id, essential)
597    }
598
599    /// Register a widget scheduling signal for this frame.
600    pub fn register_widget_signal(&mut self, signal: WidgetSignal) {
601        self.widget_signals.push(signal);
602    }
603
604    /// Borrow the collected widget signals.
605    #[inline]
606    pub fn widget_signals(&self) -> &[WidgetSignal] {
607        &self.widget_signals
608    }
609
610    /// Take the collected widget signals, leaving an empty list.
611    #[inline]
612    pub fn take_widget_signals(&mut self) -> Vec<WidgetSignal> {
613        std::mem::take(&mut self.widget_signals)
614    }
615
616    /// Intern a string in the grapheme pool.
617    ///
618    /// Returns a `GraphemeId` that can be used to create a `Cell`.
619    /// The width is calculated automatically or can be provided if already known.
620    ///
621    /// # Panics
622    ///
623    /// Panics if width exceeds `GraphemeId::MAX_WIDTH`.
624    pub fn intern(&mut self, text: &str) -> GraphemeId {
625        let width = display_width(text).min(GraphemeId::MAX_WIDTH as usize) as u8;
626        self.pool.intern(text, width)
627    }
628
629    /// Intern a string with explicit width.
630    pub fn intern_with_width(&mut self, text: &str, width: u8) -> GraphemeId {
631        self.pool.intern(text, width)
632    }
633
634    /// Enable hit testing on an existing frame.
635    pub fn enable_hit_testing(&mut self) {
636        if self.hit_grid.is_none() {
637            self.hit_grid = Some(HitGrid::new(self.width(), self.height()));
638        }
639    }
640
641    /// Frame width in cells.
642    #[inline]
643    pub fn width(&self) -> u16 {
644        self.buffer.width()
645    }
646
647    /// Frame height in cells.
648    #[inline]
649    pub fn height(&self) -> u16 {
650        self.buffer.height()
651    }
652
653    /// Clear frame for next render.
654    ///
655    /// Resets both the buffer and hit grid (if present).
656    pub fn clear(&mut self) {
657        self.buffer.clear();
658        if let Some(ref mut grid) = self.hit_grid {
659            grid.clear();
660        }
661        self.cursor_position = None;
662        self.widget_signals.clear();
663    }
664
665    /// Set cursor position.
666    ///
667    /// Pass `None` to indicate no cursor should be shown at a specific position.
668    #[inline]
669    pub fn set_cursor(&mut self, position: Option<(u16, u16)>) {
670        self.cursor_position = position;
671    }
672
673    /// Set cursor visibility.
674    #[inline]
675    pub fn set_cursor_visible(&mut self, visible: bool) {
676        self.cursor_visible = visible;
677    }
678
679    /// Set the degradation level for this frame.
680    ///
681    /// Propagates to the buffer so widgets can read `buf.degradation`
682    /// during rendering without needing access to the full Frame.
683    #[inline]
684    pub fn set_degradation(&mut self, level: DegradationLevel) {
685        self.degradation = level;
686        self.buffer.degradation = level;
687    }
688
689    /// Get the bounding rectangle of the frame.
690    #[inline]
691    pub fn bounds(&self) -> Rect {
692        self.buffer.bounds()
693    }
694
695    /// Register a hit region (if hit grid is enabled).
696    ///
697    /// Returns `true` if the region was registered, `false` if no hit grid.
698    ///
699    /// # Clipping
700    ///
701    /// The region is intersected with the current scissor stack of the
702    /// internal buffer. Parts of the region outside the scissor are
703    /// ignored.
704    pub fn register_hit(
705        &mut self,
706        rect: Rect,
707        id: HitId,
708        region: HitRegion,
709        data: HitData,
710    ) -> bool {
711        let owner = self.current_hit_owner();
712        if let Some(ref mut grid) = self.hit_grid {
713            // Clip against current scissor
714            let clipped = rect.intersection(&self.buffer.current_scissor());
715            if !clipped.is_empty() {
716                grid.register_with_owner(clipped, id, region, data, owner);
717            }
718            true
719        } else {
720            false
721        }
722    }
723
724    /// Temporarily attach ownership provenance to hit regions registered in `f`.
725    pub fn with_hit_owner<R>(&mut self, owner: HitOwner, f: impl FnOnce(&mut Self) -> R) -> R {
726        self.hit_owner_stack.push(owner);
727        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self)));
728        self.hit_owner_stack.pop();
729        match result {
730            Ok(result) => result,
731            Err(payload) => std::panic::resume_unwind(payload),
732        }
733    }
734
735    /// Hit test at the given position (if hit grid is enabled).
736    #[must_use]
737    pub fn hit_test(&self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
738        self.hit_grid.as_ref().and_then(|grid| grid.hit_test(x, y))
739    }
740
741    /// Hit test at the given position, preserving owner provenance.
742    #[must_use]
743    pub fn hit_test_detailed(&self, x: u16, y: u16) -> Option<HitTestResult> {
744        self.hit_grid
745            .as_ref()
746            .and_then(|grid| grid.hit_test_detailed(x, y))
747    }
748
749    /// Register a hit region with default metadata (Content, data=0).
750    pub fn register_hit_region(&mut self, rect: Rect, id: HitId) -> bool {
751        self.register_hit(rect, id, HitRegion::Content, 0)
752    }
753
754    #[inline]
755    fn current_hit_owner(&self) -> Option<HitOwner> {
756        self.hit_owner_stack.last().copied()
757    }
758}
759
760impl<'a> Draw for Frame<'a> {
761    fn draw_horizontal_line(&mut self, x: u16, y: u16, width: u16, cell: Cell) {
762        self.buffer.draw_horizontal_line(x, y, width, cell);
763    }
764
765    fn draw_vertical_line(&mut self, x: u16, y: u16, height: u16, cell: Cell) {
766        self.buffer.draw_vertical_line(x, y, height, cell);
767    }
768
769    fn draw_rect_filled(&mut self, rect: Rect, cell: Cell) {
770        self.buffer.draw_rect_filled(rect, cell);
771    }
772
773    fn draw_rect_outline(&mut self, rect: Rect, cell: Cell) {
774        self.buffer.draw_rect_outline(rect, cell);
775    }
776
777    fn print_text(&mut self, x: u16, y: u16, text: &str, base_cell: Cell) -> u16 {
778        self.print_text_clipped(x, y, text, base_cell, self.width())
779    }
780
781    fn print_text_clipped(
782        &mut self,
783        x: u16,
784        y: u16,
785        text: &str,
786        base_cell: Cell,
787        max_x: u16,
788    ) -> u16 {
789        let mut cx = x;
790        for grapheme in text.graphemes(true) {
791            let width = grapheme_width(grapheme);
792            if width == 0 {
793                continue;
794            }
795
796            if cx >= max_x {
797                break;
798            }
799
800            // Don't start a wide char if it won't fit
801            if cx as u32 + width as u32 > max_x as u32 {
802                break;
803            }
804
805            // Intern grapheme if needed (unlike Buffer::print_text, we have the pool!)
806            let content = if width > 1 || grapheme.chars().count() > 1 {
807                let id = self.intern_with_width(grapheme, width as u8);
808                CellContent::from_grapheme(id)
809            } else if let Some(c) = grapheme.chars().next() {
810                CellContent::from_char(c)
811            } else {
812                continue;
813            };
814
815            let cell = Cell {
816                content,
817                fg: base_cell.fg,
818                bg: base_cell.bg,
819                attrs: base_cell.attrs,
820            };
821            self.buffer.set_fast(cx, y, cell);
822
823            cx = cx.saturating_add(width as u16);
824        }
825        cx
826    }
827
828    fn draw_border(&mut self, rect: Rect, chars: BorderChars, base_cell: Cell) {
829        self.buffer.draw_border(rect, chars, base_cell);
830    }
831
832    fn draw_box(&mut self, rect: Rect, chars: BorderChars, border_cell: Cell, fill_cell: Cell) {
833        self.buffer.draw_box(rect, chars, border_cell, fill_cell);
834    }
835
836    fn paint_area(
837        &mut self,
838        rect: Rect,
839        fg: Option<crate::cell::PackedRgba>,
840        bg: Option<crate::cell::PackedRgba>,
841    ) {
842        self.buffer.paint_area(rect, fg, bg);
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849    use crate::cell::Cell;
850
851    #[test]
852    fn frame_creation() {
853        let mut pool = GraphemePool::new();
854        let frame = Frame::new(80, 24, &mut pool);
855        assert_eq!(frame.width(), 80);
856        assert_eq!(frame.height(), 24);
857        assert!(frame.hit_grid.is_none());
858        assert!(frame.cursor_position.is_none());
859        assert!(frame.cursor_visible);
860    }
861
862    #[test]
863    fn frame_with_hit_grid() {
864        let mut pool = GraphemePool::new();
865        let frame = Frame::with_hit_grid(80, 24, &mut pool);
866        assert!(frame.hit_grid.is_some());
867        assert_eq!(frame.width(), 80);
868        assert_eq!(frame.height(), 24);
869    }
870
871    #[test]
872    fn frame_cursor() {
873        let mut pool = GraphemePool::new();
874        let mut frame = Frame::new(80, 24, &mut pool);
875        assert!(frame.cursor_position.is_none());
876        assert!(frame.cursor_visible);
877
878        frame.set_cursor(Some((10, 5)));
879        assert_eq!(frame.cursor_position, Some((10, 5)));
880
881        frame.set_cursor_visible(false);
882        assert!(!frame.cursor_visible);
883
884        frame.set_cursor(None);
885        assert!(frame.cursor_position.is_none());
886    }
887
888    #[test]
889    fn frame_clear() {
890        let mut pool = GraphemePool::new();
891        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
892
893        // Add some content
894        frame.buffer.set_raw(5, 5, Cell::from_char('X'));
895        frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
896
897        // Verify content exists
898        assert_eq!(frame.buffer.get(5, 5).unwrap().content.as_char(), Some('X'));
899        assert_eq!(
900            frame.hit_test(2, 2),
901            Some((HitId::new(1), HitRegion::Content, 0))
902        );
903
904        // Clear
905        frame.clear();
906
907        // Verify cleared
908        assert!(frame.buffer.get(5, 5).unwrap().is_empty());
909        assert!(frame.hit_test(2, 2).is_none());
910    }
911
912    #[test]
913    fn frame_bounds() {
914        let mut pool = GraphemePool::new();
915        let frame = Frame::new(80, 24, &mut pool);
916        let bounds = frame.bounds();
917        assert_eq!(bounds.x, 0);
918        assert_eq!(bounds.y, 0);
919        assert_eq!(bounds.width, 80);
920        assert_eq!(bounds.height, 24);
921    }
922
923    #[test]
924    fn hit_grid_creation() {
925        let grid = HitGrid::new(80, 24);
926        assert_eq!(grid.width(), 80);
927        assert_eq!(grid.height(), 24);
928    }
929
930    #[test]
931    fn hit_grid_registration() {
932        let mut pool = GraphemePool::new();
933        let mut frame = Frame::with_hit_grid(80, 24, &mut pool);
934        let hit_id = HitId::new(42);
935        let rect = Rect::new(10, 5, 20, 3);
936
937        frame.register_hit(rect, hit_id, HitRegion::Button, 99);
938
939        // Inside rect
940        assert_eq!(frame.hit_test(15, 6), Some((hit_id, HitRegion::Button, 99)));
941        assert_eq!(frame.hit_test(10, 5), Some((hit_id, HitRegion::Button, 99))); // Top-left corner
942        assert_eq!(frame.hit_test(29, 7), Some((hit_id, HitRegion::Button, 99))); // Bottom-right corner
943
944        // Outside rect
945        assert!(frame.hit_test(5, 5).is_none()); // Left of rect
946        assert!(frame.hit_test(30, 6).is_none()); // Right of rect (exclusive)
947        assert!(frame.hit_test(15, 8).is_none()); // Below rect
948        assert!(frame.hit_test(15, 4).is_none()); // Above rect
949    }
950
951    #[test]
952    fn hit_grid_overlapping_regions() {
953        let mut pool = GraphemePool::new();
954        let mut frame = Frame::with_hit_grid(20, 20, &mut pool);
955
956        // Register two overlapping regions
957        frame.register_hit(
958            Rect::new(0, 0, 10, 10),
959            HitId::new(1),
960            HitRegion::Content,
961            1,
962        );
963        frame.register_hit(Rect::new(5, 5, 10, 10), HitId::new(2), HitRegion::Border, 2);
964
965        // Non-overlapping region from first
966        assert_eq!(
967            frame.hit_test(2, 2),
968            Some((HitId::new(1), HitRegion::Content, 1))
969        );
970
971        // Overlapping region - second wins (last registered)
972        assert_eq!(
973            frame.hit_test(7, 7),
974            Some((HitId::new(2), HitRegion::Border, 2))
975        );
976
977        // Non-overlapping region from second
978        assert_eq!(
979            frame.hit_test(12, 12),
980            Some((HitId::new(2), HitRegion::Border, 2))
981        );
982    }
983
984    #[test]
985    fn hit_grid_out_of_bounds() {
986        let mut pool = GraphemePool::new();
987        let frame = Frame::with_hit_grid(10, 10, &mut pool);
988
989        // Out of bounds returns None
990        assert!(frame.hit_test(100, 100).is_none());
991        assert!(frame.hit_test(10, 0).is_none()); // Exclusive bound
992        assert!(frame.hit_test(0, 10).is_none()); // Exclusive bound
993    }
994
995    #[test]
996    fn hit_id_properties() {
997        let id = HitId::new(42);
998        assert_eq!(id.id(), 42);
999        assert_eq!(id, HitId(42));
1000    }
1001
1002    #[test]
1003    fn register_hit_region_no_grid() {
1004        let mut pool = GraphemePool::new();
1005        let mut frame = Frame::new(10, 10, &mut pool);
1006        let result = frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
1007        assert!(!result); // No hit grid, returns false
1008    }
1009
1010    #[test]
1011    fn register_hit_region_with_grid() {
1012        let mut pool = GraphemePool::new();
1013        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
1014        let result = frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
1015        assert!(result); // Has hit grid, returns true
1016    }
1017
1018    #[test]
1019    fn hit_grid_clear() {
1020        let mut grid = HitGrid::new(10, 10);
1021        grid.register(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
1022
1023        assert_eq!(
1024            grid.hit_test(2, 2),
1025            Some((HitId::new(1), HitRegion::Content, 0))
1026        );
1027
1028        grid.clear();
1029
1030        assert!(grid.hit_test(2, 2).is_none());
1031    }
1032
1033    #[test]
1034    fn hit_grid_boundary_clipping() {
1035        let mut grid = HitGrid::new(10, 10);
1036
1037        // Register region that extends beyond grid
1038        grid.register(
1039            Rect::new(8, 8, 10, 10),
1040            HitId::new(1),
1041            HitRegion::Content,
1042            0,
1043        );
1044
1045        // Inside clipped region
1046        assert_eq!(
1047            grid.hit_test(9, 9),
1048            Some((HitId::new(1), HitRegion::Content, 0))
1049        );
1050
1051        // Outside grid
1052        assert!(grid.hit_test(10, 10).is_none());
1053    }
1054
1055    #[test]
1056    fn hit_grid_edge_and_corner_cells() {
1057        let mut grid = HitGrid::new(4, 4);
1058        grid.register(Rect::new(3, 0, 1, 4), HitId::new(7), HitRegion::Border, 11);
1059
1060        // Right-most column corners
1061        assert_eq!(
1062            grid.hit_test(3, 0),
1063            Some((HitId::new(7), HitRegion::Border, 11))
1064        );
1065        assert_eq!(
1066            grid.hit_test(3, 3),
1067            Some((HitId::new(7), HitRegion::Border, 11))
1068        );
1069
1070        // Neighboring cells remain empty
1071        assert!(grid.hit_test(2, 0).is_none());
1072        assert!(grid.hit_test(4, 0).is_none());
1073        assert!(grid.hit_test(3, 4).is_none());
1074
1075        let mut grid = HitGrid::new(4, 4);
1076        grid.register(Rect::new(0, 3, 4, 1), HitId::new(9), HitRegion::Content, 21);
1077
1078        // Bottom row corners
1079        assert_eq!(
1080            grid.hit_test(0, 3),
1081            Some((HitId::new(9), HitRegion::Content, 21))
1082        );
1083        assert_eq!(
1084            grid.hit_test(3, 3),
1085            Some((HitId::new(9), HitRegion::Content, 21))
1086        );
1087
1088        // Outside bottom row
1089        assert!(grid.hit_test(0, 2).is_none());
1090        assert!(grid.hit_test(0, 4).is_none());
1091    }
1092
1093    #[test]
1094    fn frame_register_hit_respects_nested_scissor() {
1095        let mut pool = GraphemePool::new();
1096        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
1097
1098        let outer = Rect::new(1, 1, 8, 8);
1099        frame.buffer.push_scissor(outer);
1100        assert_eq!(frame.buffer.current_scissor(), outer);
1101
1102        let inner = Rect::new(4, 4, 10, 10);
1103        frame.buffer.push_scissor(inner);
1104        let clipped = outer.intersection(&inner);
1105        let current = frame.buffer.current_scissor();
1106        assert_eq!(current, clipped);
1107
1108        // Monotonic intersection: inner scissor must stay within outer.
1109        assert!(outer.contains(current.x, current.y));
1110        assert!(outer.contains(
1111            current.right().saturating_sub(1),
1112            current.bottom().saturating_sub(1)
1113        ));
1114
1115        frame.register_hit(
1116            Rect::new(0, 0, 10, 10),
1117            HitId::new(3),
1118            HitRegion::Button,
1119            99,
1120        );
1121
1122        assert_eq!(
1123            frame.hit_test(4, 4),
1124            Some((HitId::new(3), HitRegion::Button, 99))
1125        );
1126        assert_eq!(
1127            frame.hit_test(8, 8),
1128            Some((HitId::new(3), HitRegion::Button, 99))
1129        );
1130        assert!(frame.hit_test(3, 3).is_none()); // inside outer, outside inner
1131        assert!(frame.hit_test(0, 0).is_none()); // outside all scissor
1132
1133        frame.buffer.pop_scissor();
1134        assert_eq!(frame.buffer.current_scissor(), outer);
1135    }
1136
1137    #[test]
1138    fn hit_grid_hits_in_area() {
1139        let mut grid = HitGrid::new(5, 5);
1140        grid.register(Rect::new(0, 0, 2, 2), HitId::new(1), HitRegion::Content, 10);
1141        grid.register(Rect::new(1, 1, 2, 2), HitId::new(2), HitRegion::Button, 20);
1142
1143        let hits = grid.hits_in(Rect::new(0, 0, 3, 3));
1144        assert!(hits.contains(&(HitId::new(1), HitRegion::Content, 10)));
1145        assert!(hits.contains(&(HitId::new(2), HitRegion::Button, 20)));
1146    }
1147
1148    #[test]
1149    fn frame_intern() {
1150        let mut pool = GraphemePool::new();
1151        let mut frame = Frame::new(10, 10, &mut pool);
1152
1153        let id = frame.intern("👋");
1154        assert_eq!(frame.pool.get(id), Some("👋"));
1155    }
1156
1157    #[test]
1158    fn frame_intern_with_width() {
1159        let mut pool = GraphemePool::new();
1160        let mut frame = Frame::new(10, 10, &mut pool);
1161
1162        let id = frame.intern_with_width("🧪", 2);
1163        assert_eq!(id.width(), 2);
1164        assert_eq!(frame.pool.get(id), Some("🧪"));
1165    }
1166
1167    #[test]
1168    fn frame_print_text_emoji_presentation_sets_continuation() {
1169        let mut pool = GraphemePool::new();
1170        let mut frame = Frame::new(5, 1, &mut pool);
1171
1172        // Use a skin-tone modifier sequence (width 2, multi-codepoint, no VS16
1173        // dependency) so the test is independent of ftui-core's VS16 policy.
1174        frame.print_text(0, 0, "👍🏽", Cell::from_char(' '));
1175
1176        let head = frame.buffer.get(0, 0).unwrap();
1177        let tail = frame.buffer.get(1, 0).unwrap();
1178
1179        assert_eq!(head.content.width(), 2);
1180        assert!(tail.content.is_continuation());
1181    }
1182
1183    #[test]
1184    fn frame_enable_hit_testing() {
1185        let mut pool = GraphemePool::new();
1186        let mut frame = Frame::new(10, 10, &mut pool);
1187        assert!(frame.hit_grid.is_none());
1188
1189        frame.enable_hit_testing();
1190        assert!(frame.hit_grid.is_some());
1191
1192        // Calling again is idempotent
1193        frame.enable_hit_testing();
1194        assert!(frame.hit_grid.is_some());
1195    }
1196
1197    #[test]
1198    fn frame_enable_hit_testing_then_register() {
1199        let mut pool = GraphemePool::new();
1200        let mut frame = Frame::new(10, 10, &mut pool);
1201        frame.enable_hit_testing();
1202
1203        let registered = frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
1204        assert!(registered);
1205        assert_eq!(
1206            frame.hit_test(2, 2),
1207            Some((HitId::new(1), HitRegion::Content, 0))
1208        );
1209    }
1210
1211    #[test]
1212    fn hit_cell_default_is_empty() {
1213        let cell = HitCell::default();
1214        assert!(cell.is_empty());
1215        assert_eq!(cell.widget_id, None);
1216        assert_eq!(cell.region, HitRegion::None);
1217        assert_eq!(cell.data, 0);
1218    }
1219
1220    #[test]
1221    fn hit_cell_new_is_not_empty() {
1222        let cell = HitCell::new(HitId::new(1), HitRegion::Button, 42);
1223        assert!(!cell.is_empty());
1224        assert_eq!(cell.widget_id, Some(HitId::new(1)));
1225        assert_eq!(cell.region, HitRegion::Button);
1226        assert_eq!(cell.data, 42);
1227    }
1228
1229    #[test]
1230    fn hit_region_variants() {
1231        assert_eq!(HitRegion::default(), HitRegion::None);
1232
1233        // All variants are distinct
1234        let variants = [
1235            HitRegion::None,
1236            HitRegion::Content,
1237            HitRegion::Border,
1238            HitRegion::Scrollbar,
1239            HitRegion::Handle,
1240            HitRegion::Button,
1241            HitRegion::Link,
1242            HitRegion::Custom(0),
1243            HitRegion::Custom(1),
1244            HitRegion::Custom(255),
1245        ];
1246        for i in 0..variants.len() {
1247            for j in (i + 1)..variants.len() {
1248                assert_ne!(
1249                    variants[i], variants[j],
1250                    "variants {i} and {j} should differ"
1251                );
1252            }
1253        }
1254    }
1255
1256    #[test]
1257    fn hit_id_default() {
1258        let id = HitId::default();
1259        assert_eq!(id.id(), 0);
1260    }
1261
1262    #[test]
1263    fn hit_grid_initial_cells_empty() {
1264        let grid = HitGrid::new(5, 5);
1265        for y in 0..5 {
1266            for x in 0..5 {
1267                let cell = grid.get(x, y).unwrap();
1268                assert!(cell.is_empty());
1269            }
1270        }
1271    }
1272
1273    #[test]
1274    fn hit_grid_zero_dimensions() {
1275        let grid = HitGrid::new(0, 0);
1276        assert_eq!(grid.width(), 0);
1277        assert_eq!(grid.height(), 0);
1278        assert!(grid.get(0, 0).is_none());
1279        assert!(grid.hit_test(0, 0).is_none());
1280    }
1281
1282    #[test]
1283    fn hit_grid_hits_in_empty_area() {
1284        let grid = HitGrid::new(10, 10);
1285        let hits = grid.hits_in(Rect::new(0, 0, 5, 5));
1286        // All cells are empty, so no actual HitId hits
1287        assert!(hits.is_empty());
1288    }
1289
1290    #[test]
1291    fn hit_grid_hits_in_clipped_area() {
1292        let mut grid = HitGrid::new(5, 5);
1293        grid.register(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
1294
1295        // Query area extends beyond grid — should be clipped
1296        let hits = grid.hits_in(Rect::new(3, 3, 10, 10));
1297        assert_eq!(hits.len(), 4); // 2x2 cells inside grid
1298    }
1299
1300    #[test]
1301    fn hit_test_no_grid_returns_none() {
1302        let mut pool = GraphemePool::new();
1303        let frame = Frame::new(10, 10, &mut pool);
1304        assert!(frame.hit_test(0, 0).is_none());
1305    }
1306
1307    #[test]
1308    fn frame_cursor_operations() {
1309        let mut pool = GraphemePool::new();
1310        let mut frame = Frame::new(80, 24, &mut pool);
1311
1312        // Set position at edge of frame
1313        frame.set_cursor(Some((79, 23)));
1314        assert_eq!(frame.cursor_position, Some((79, 23)));
1315
1316        // Set position at origin
1317        frame.set_cursor(Some((0, 0)));
1318        assert_eq!(frame.cursor_position, Some((0, 0)));
1319
1320        // Toggle visibility
1321        frame.set_cursor_visible(false);
1322        assert!(!frame.cursor_visible);
1323        frame.set_cursor_visible(true);
1324        assert!(frame.cursor_visible);
1325    }
1326
1327    #[test]
1328    fn hit_data_large_values() {
1329        let mut grid = HitGrid::new(5, 5);
1330        // HitData is u64, test max value
1331        grid.register(
1332            Rect::new(0, 0, 1, 1),
1333            HitId::new(1),
1334            HitRegion::Content,
1335            u64::MAX,
1336        );
1337        let result = grid.hit_test(0, 0);
1338        assert_eq!(result, Some((HitId::new(1), HitRegion::Content, u64::MAX)));
1339    }
1340
1341    #[test]
1342    fn hit_id_large_value() {
1343        let id = HitId::new(u32::MAX);
1344        assert_eq!(id.id(), u32::MAX);
1345    }
1346
1347    #[test]
1348    fn frame_print_text_interns_complex_graphemes() {
1349        let mut pool = GraphemePool::new();
1350        let mut frame = Frame::new(10, 1, &mut pool);
1351
1352        // Flag emoji (complex grapheme)
1353        let flag = "🇺🇸";
1354        assert!(flag.chars().count() > 1);
1355
1356        frame.print_text(0, 0, flag, Cell::default());
1357
1358        let cell = frame.buffer.get(0, 0).unwrap();
1359        assert!(cell.content.is_grapheme());
1360
1361        let id = cell.content.grapheme_id().unwrap();
1362        assert_eq!(frame.pool.get(id), Some(flag));
1363    }
1364
1365    // --- HitId trait coverage ---
1366
1367    #[test]
1368    fn hit_id_debug_clone_copy_hash() {
1369        let id = HitId::new(99);
1370        let dbg = format!("{:?}", id);
1371        assert!(dbg.contains("99"), "Debug: {dbg}");
1372        let copied: HitId = id; // Copy
1373        assert_eq!(id, copied);
1374        // Hash: insert into set
1375        use std::collections::HashSet;
1376        let mut set = HashSet::new();
1377        set.insert(id);
1378        set.insert(HitId::new(99));
1379        assert_eq!(set.len(), 1);
1380        set.insert(HitId::new(100));
1381        assert_eq!(set.len(), 2);
1382    }
1383
1384    #[test]
1385    fn hit_id_eq_and_ne() {
1386        assert_eq!(HitId::new(0), HitId::new(0));
1387        assert_ne!(HitId::new(0), HitId::new(1));
1388        assert_ne!(HitId::new(u32::MAX), HitId::default());
1389    }
1390
1391    // --- HitRegion trait coverage ---
1392
1393    #[test]
1394    fn hit_region_debug_clone_copy_hash() {
1395        let r = HitRegion::Custom(42);
1396        let dbg = format!("{:?}", r);
1397        assert!(dbg.contains("Custom"), "Debug: {dbg}");
1398        let copied: HitRegion = r; // Copy
1399        assert_eq!(r, copied);
1400        use std::collections::HashSet;
1401        let mut set = HashSet::new();
1402        set.insert(r);
1403        set.insert(HitRegion::Custom(42));
1404        assert_eq!(set.len(), 1);
1405    }
1406
1407    // --- HitCell trait coverage ---
1408
1409    #[test]
1410    fn hit_cell_debug_clone_copy_eq() {
1411        let cell = HitCell::new(HitId::new(5), HitRegion::Link, 123);
1412        let dbg = format!("{:?}", cell);
1413        assert!(dbg.contains("Link"), "Debug: {dbg}");
1414        let copied: HitCell = cell; // Copy
1415        assert_eq!(cell, copied);
1416        // ne
1417        assert_ne!(cell, HitCell::default());
1418    }
1419
1420    // --- HitGrid edge cases ---
1421
1422    #[test]
1423    fn hit_grid_clone() {
1424        let mut grid = HitGrid::new(5, 5);
1425        grid.register(Rect::new(0, 0, 2, 2), HitId::new(1), HitRegion::Content, 7);
1426        let clone = grid.clone();
1427        assert_eq!(clone.width(), 5);
1428        assert_eq!(
1429            clone.hit_test(0, 0),
1430            Some((HitId::new(1), HitRegion::Content, 7))
1431        );
1432    }
1433
1434    #[test]
1435    fn hit_grid_get_mut() {
1436        let mut grid = HitGrid::new(5, 5);
1437        // Mutate a cell directly
1438        if let Some(cell) = grid.get_mut(2, 3) {
1439            *cell = HitCell::new(HitId::new(77), HitRegion::Handle, 55);
1440        }
1441        assert_eq!(
1442            grid.hit_test(2, 3),
1443            Some((HitId::new(77), HitRegion::Handle, 55))
1444        );
1445        // Out of bounds returns None
1446        assert!(grid.get_mut(5, 5).is_none());
1447    }
1448
1449    #[test]
1450    fn hit_grid_zero_width_nonzero_height() {
1451        let grid = HitGrid::new(0, 10);
1452        assert_eq!(grid.width(), 0);
1453        assert_eq!(grid.height(), 10);
1454        assert!(grid.get(0, 0).is_none());
1455        assert!(grid.hit_test(0, 5).is_none());
1456    }
1457
1458    #[test]
1459    fn hit_grid_nonzero_width_zero_height() {
1460        let grid = HitGrid::new(10, 0);
1461        assert_eq!(grid.width(), 10);
1462        assert_eq!(grid.height(), 0);
1463        assert!(grid.get(0, 0).is_none());
1464    }
1465
1466    #[test]
1467    fn hit_grid_register_zero_width_rect() {
1468        let mut grid = HitGrid::new(10, 10);
1469        grid.register(Rect::new(2, 2, 0, 5), HitId::new(1), HitRegion::Content, 0);
1470        // Nothing should be registered
1471        assert!(grid.hit_test(2, 2).is_none());
1472    }
1473
1474    #[test]
1475    fn hit_grid_register_zero_height_rect() {
1476        let mut grid = HitGrid::new(10, 10);
1477        grid.register(Rect::new(2, 2, 5, 0), HitId::new(1), HitRegion::Content, 0);
1478        assert!(grid.hit_test(2, 2).is_none());
1479    }
1480
1481    #[test]
1482    fn hit_grid_register_past_bounds() {
1483        let mut grid = HitGrid::new(10, 10);
1484        // Rect starts past the grid boundary
1485        grid.register(
1486            Rect::new(10, 10, 5, 5),
1487            HitId::new(1),
1488            HitRegion::Content,
1489            0,
1490        );
1491        assert!(grid.hit_test(9, 9).is_none());
1492    }
1493
1494    #[test]
1495    fn hit_grid_full_coverage() {
1496        let mut grid = HitGrid::new(3, 3);
1497        grid.register(Rect::new(0, 0, 3, 3), HitId::new(1), HitRegion::Content, 0);
1498        // Every cell should be filled
1499        for y in 0..3 {
1500            for x in 0..3 {
1501                assert_eq!(
1502                    grid.hit_test(x, y),
1503                    Some((HitId::new(1), HitRegion::Content, 0))
1504                );
1505            }
1506        }
1507    }
1508
1509    #[test]
1510    fn hit_grid_single_cell() {
1511        let mut grid = HitGrid::new(1, 1);
1512        grid.register(Rect::new(0, 0, 1, 1), HitId::new(1), HitRegion::Button, 42);
1513        assert_eq!(
1514            grid.hit_test(0, 0),
1515            Some((HitId::new(1), HitRegion::Button, 42))
1516        );
1517        assert!(grid.hit_test(1, 0).is_none());
1518        assert!(grid.hit_test(0, 1).is_none());
1519    }
1520
1521    #[test]
1522    fn hit_grid_hits_in_outside_rect() {
1523        let mut grid = HitGrid::new(5, 5);
1524        grid.register(Rect::new(0, 0, 2, 2), HitId::new(1), HitRegion::Content, 0);
1525        // Query area completely outside registered region
1526        let hits = grid.hits_in(Rect::new(3, 3, 2, 2));
1527        assert!(hits.is_empty());
1528    }
1529
1530    #[test]
1531    fn hit_grid_hits_in_zero_rect() {
1532        let mut grid = HitGrid::new(5, 5);
1533        grid.register(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
1534        let hits = grid.hits_in(Rect::new(2, 2, 0, 0));
1535        assert!(hits.is_empty());
1536    }
1537
1538    // --- CostEstimateSource ---
1539
1540    #[test]
1541    fn cost_estimate_source_traits() {
1542        let a = CostEstimateSource::Measured;
1543        let b = CostEstimateSource::AreaFallback;
1544        let c = CostEstimateSource::FixedDefault;
1545        let dbg = format!("{:?}", a);
1546        assert!(dbg.contains("Measured"), "Debug: {dbg}");
1547
1548        // Default
1549        assert_eq!(
1550            CostEstimateSource::default(),
1551            CostEstimateSource::FixedDefault
1552        );
1553
1554        // Clone/Copy
1555        let copied: CostEstimateSource = a;
1556        assert_eq!(a, copied);
1557
1558        // All variants distinct
1559        assert_ne!(a, b);
1560        assert_ne!(b, c);
1561        assert_ne!(a, c);
1562    }
1563
1564    // --- WidgetSignal ---
1565
1566    #[test]
1567    fn widget_signal_default() {
1568        let sig = WidgetSignal::default();
1569        assert_eq!(sig.widget_id, 0);
1570        assert!(!sig.essential);
1571        assert!((sig.priority - 0.5).abs() < f32::EPSILON);
1572        assert_eq!(sig.staleness_ms, 0);
1573        assert!((sig.focus_boost - 0.0).abs() < f32::EPSILON);
1574        assert!((sig.interaction_boost - 0.0).abs() < f32::EPSILON);
1575        assert_eq!(sig.area_cells, 1);
1576        assert!((sig.cost_estimate_us - 5.0).abs() < f32::EPSILON);
1577        assert!((sig.recent_cost_us - 5.0).abs() < f32::EPSILON);
1578        assert_eq!(sig.estimate_source, CostEstimateSource::FixedDefault);
1579    }
1580
1581    #[test]
1582    fn widget_signal_new() {
1583        let sig = WidgetSignal::new(42);
1584        assert_eq!(sig.widget_id, 42);
1585        // Other fields should be default
1586        assert!(!sig.essential);
1587        assert!((sig.priority - 0.5).abs() < f32::EPSILON);
1588    }
1589
1590    #[test]
1591    fn widget_signal_debug_clone() {
1592        let sig = WidgetSignal::new(7);
1593        let dbg = format!("{:?}", sig);
1594        assert!(dbg.contains("widget_id"), "Debug: {dbg}");
1595        let cloned = sig.clone();
1596        assert_eq!(cloned.widget_id, 7);
1597    }
1598
1599    // --- WidgetBudget ---
1600
1601    #[test]
1602    fn widget_budget_default_is_allow_all() {
1603        let budget = WidgetBudget::default();
1604        assert!(budget.allows(0, false));
1605        assert!(budget.allows(u64::MAX, false));
1606        assert!(budget.allows(42, true));
1607    }
1608
1609    #[test]
1610    fn widget_budget_allow_only() {
1611        let budget = WidgetBudget::allow_only(vec![10, 20, 30]);
1612        assert!(budget.allows(10, false));
1613        assert!(budget.allows(20, false));
1614        assert!(budget.allows(30, false));
1615        assert!(!budget.allows(15, false));
1616        assert!(!budget.allows(0, false));
1617    }
1618
1619    #[test]
1620    fn widget_budget_essential_always_allowed() {
1621        let budget = WidgetBudget::allow_only(vec![10]);
1622        // Essential widgets bypass the allow list
1623        assert!(budget.allows(999, true));
1624        assert!(budget.allows(0, true));
1625    }
1626
1627    #[test]
1628    fn widget_budget_allow_only_dedup() {
1629        let budget = WidgetBudget::allow_only(vec![5, 5, 5, 10, 10]);
1630        assert!(budget.allows(5, false));
1631        assert!(budget.allows(10, false));
1632        assert!(!budget.allows(7, false));
1633    }
1634
1635    #[test]
1636    fn widget_budget_allow_only_empty() {
1637        let budget = WidgetBudget::allow_only(vec![]);
1638        // No widgets allowed (except essential)
1639        assert!(!budget.allows(0, false));
1640        assert!(!budget.allows(1, false));
1641        assert!(budget.allows(1, true)); // essential always passes
1642    }
1643
1644    #[test]
1645    fn widget_budget_debug_clone() {
1646        let budget = WidgetBudget::allow_only(vec![1, 2, 3]);
1647        let dbg = format!("{:?}", budget);
1648        assert!(dbg.contains("allow_list"), "Debug: {dbg}");
1649        let cloned = budget.clone();
1650        assert!(cloned.allows(2, false));
1651    }
1652
1653    // --- Frame construction variants ---
1654
1655    #[test]
1656    fn frame_zero_dimensions_clamped_to_one() {
1657        let mut pool = GraphemePool::new();
1658        let frame = Frame::new(0, 0, &mut pool);
1659        assert_eq!(frame.buffer.width(), 1);
1660        assert_eq!(frame.buffer.height(), 1);
1661    }
1662
1663    #[test]
1664    fn frame_from_buffer() {
1665        let mut pool = GraphemePool::new();
1666        let mut buf = Buffer::new(20, 10);
1667        buf.set_raw(5, 5, Cell::from_char('Z'));
1668        let frame = Frame::from_buffer(buf, &mut pool);
1669        assert_eq!(frame.width(), 20);
1670        assert_eq!(frame.height(), 10);
1671        assert_eq!(frame.buffer.get(5, 5).unwrap().content.as_char(), Some('Z'));
1672        assert!(frame.hit_grid.is_none());
1673        assert!(frame.cursor_visible);
1674    }
1675
1676    #[test]
1677    fn frame_with_links() {
1678        let mut pool = GraphemePool::new();
1679        let mut links = LinkRegistry::new();
1680        let frame = Frame::with_links(10, 5, &mut pool, &mut links);
1681        assert!(frame.links.is_some());
1682        assert_eq!(frame.width(), 10);
1683        assert_eq!(frame.height(), 5);
1684    }
1685
1686    #[test]
1687    fn frame_set_links() {
1688        let mut pool = GraphemePool::new();
1689        let mut links = LinkRegistry::new();
1690        let mut frame = Frame::new(10, 5, &mut pool);
1691        assert!(frame.links.is_none());
1692        frame.set_links(&mut links);
1693        assert!(frame.links.is_some());
1694    }
1695
1696    #[test]
1697    fn frame_register_link_no_registry() {
1698        let mut pool = GraphemePool::new();
1699        let mut frame = Frame::new(10, 5, &mut pool);
1700        // No link registry => returns 0
1701        let id = frame.register_link("https://example.com");
1702        assert_eq!(id, 0);
1703    }
1704
1705    #[test]
1706    fn frame_register_link_with_registry() {
1707        let mut pool = GraphemePool::new();
1708        let mut links = LinkRegistry::new();
1709        let mut frame = Frame::with_links(10, 5, &mut pool, &mut links);
1710        let id = frame.register_link("https://example.com");
1711        assert!(id > 0);
1712        // Same URL should return same ID
1713        let id2 = frame.register_link("https://example.com");
1714        assert_eq!(id, id2);
1715        // Different URL should return different ID
1716        let id3 = frame.register_link("https://other.com");
1717        assert_ne!(id, id3);
1718    }
1719
1720    // --- Frame widget budget integration ---
1721
1722    #[test]
1723    fn frame_set_widget_budget() {
1724        let mut pool = GraphemePool::new();
1725        let mut frame = Frame::new(10, 10, &mut pool);
1726
1727        // Default allows all
1728        assert!(frame.should_render_widget(42, false));
1729
1730        // Set restricted budget
1731        frame.set_widget_budget(WidgetBudget::allow_only(vec![1, 2]));
1732        assert!(frame.should_render_widget(1, false));
1733        assert!(!frame.should_render_widget(42, false));
1734        assert!(frame.should_render_widget(42, true)); // essential
1735    }
1736
1737    // --- Frame widget signals ---
1738
1739    #[test]
1740    fn frame_widget_signals_lifecycle() {
1741        let mut pool = GraphemePool::new();
1742        let mut frame = Frame::new(10, 10, &mut pool);
1743        assert!(frame.widget_signals().is_empty());
1744
1745        frame.register_widget_signal(WidgetSignal::new(1));
1746        frame.register_widget_signal(WidgetSignal::new(2));
1747        assert_eq!(frame.widget_signals().len(), 2);
1748        assert_eq!(frame.widget_signals()[0].widget_id, 1);
1749        assert_eq!(frame.widget_signals()[1].widget_id, 2);
1750
1751        let taken = frame.take_widget_signals();
1752        assert_eq!(taken.len(), 2);
1753        assert!(frame.widget_signals().is_empty());
1754    }
1755
1756    #[test]
1757    fn frame_clear_resets_signals_and_cursor() {
1758        let mut pool = GraphemePool::new();
1759        let mut frame = Frame::new(10, 10, &mut pool);
1760        frame.set_cursor(Some((5, 5)));
1761        frame.register_widget_signal(WidgetSignal::new(1));
1762        assert!(frame.cursor_position.is_some());
1763        assert!(!frame.widget_signals().is_empty());
1764
1765        frame.clear();
1766        assert!(frame.cursor_position.is_none());
1767        assert!(frame.widget_signals().is_empty());
1768    }
1769
1770    // --- Frame degradation ---
1771
1772    #[test]
1773    fn frame_set_degradation_propagates_to_buffer() {
1774        let mut pool = GraphemePool::new();
1775        let mut frame = Frame::new(10, 10, &mut pool);
1776        assert_eq!(frame.degradation, DegradationLevel::Full);
1777        assert_eq!(frame.buffer.degradation, DegradationLevel::Full);
1778
1779        frame.set_degradation(DegradationLevel::SimpleBorders);
1780        assert_eq!(frame.degradation, DegradationLevel::SimpleBorders);
1781        assert_eq!(frame.buffer.degradation, DegradationLevel::SimpleBorders);
1782
1783        frame.set_degradation(DegradationLevel::EssentialOnly);
1784        assert_eq!(frame.degradation, DegradationLevel::EssentialOnly);
1785        assert_eq!(frame.buffer.degradation, DegradationLevel::EssentialOnly);
1786    }
1787
1788    // --- Frame hit grid with zero-size screen ---
1789
1790    #[test]
1791    fn frame_with_hit_grid_zero_size_clamped_to_one() {
1792        let mut pool = GraphemePool::new();
1793        let frame = Frame::with_hit_grid(0, 0, &mut pool);
1794        assert_eq!(frame.buffer.width(), 1);
1795        assert_eq!(frame.buffer.height(), 1);
1796    }
1797
1798    // --- Frame register_hit returns true/false correctly ---
1799
1800    #[test]
1801    fn frame_register_hit_with_all_regions() {
1802        let mut pool = GraphemePool::new();
1803        let mut frame = Frame::with_hit_grid(20, 20, &mut pool);
1804        let regions = [
1805            HitRegion::Content,
1806            HitRegion::Border,
1807            HitRegion::Scrollbar,
1808            HitRegion::Handle,
1809            HitRegion::Button,
1810            HitRegion::Link,
1811            HitRegion::Custom(0),
1812            HitRegion::Custom(255),
1813        ];
1814        for (i, &region) in regions.iter().enumerate() {
1815            let y = i as u16;
1816            frame.register_hit(Rect::new(0, y, 1, 1), HitId::new(i as u32), region, 0);
1817        }
1818        for (i, &region) in regions.iter().enumerate() {
1819            let y = i as u16;
1820            assert_eq!(
1821                frame.hit_test(0, y),
1822                Some((HitId::new(i as u32), region, 0))
1823            );
1824        }
1825    }
1826
1827    // --- Frame Draw trait ---
1828
1829    #[test]
1830    fn frame_draw_horizontal_line() {
1831        let mut pool = GraphemePool::new();
1832        let mut frame = Frame::new(10, 5, &mut pool);
1833        let cell = Cell::from_char('-');
1834        frame.draw_horizontal_line(2, 1, 5, cell);
1835        for x in 2..7 {
1836            assert_eq!(frame.buffer.get(x, 1).unwrap().content.as_char(), Some('-'));
1837        }
1838        // Neighbors untouched
1839        assert!(frame.buffer.get(1, 1).unwrap().is_empty());
1840        assert!(frame.buffer.get(7, 1).unwrap().is_empty());
1841    }
1842
1843    #[test]
1844    fn frame_draw_vertical_line() {
1845        let mut pool = GraphemePool::new();
1846        let mut frame = Frame::new(10, 10, &mut pool);
1847        let cell = Cell::from_char('|');
1848        frame.draw_vertical_line(3, 2, 4, cell);
1849        for y in 2..6 {
1850            assert_eq!(frame.buffer.get(3, y).unwrap().content.as_char(), Some('|'));
1851        }
1852        assert!(frame.buffer.get(3, 1).unwrap().is_empty());
1853        assert!(frame.buffer.get(3, 6).unwrap().is_empty());
1854    }
1855
1856    #[test]
1857    fn frame_draw_rect_filled() {
1858        let mut pool = GraphemePool::new();
1859        let mut frame = Frame::new(10, 10, &mut pool);
1860        let cell = Cell::from_char('#');
1861        frame.draw_rect_filled(Rect::new(1, 1, 3, 3), cell);
1862        for y in 1..4 {
1863            for x in 1..4 {
1864                assert_eq!(frame.buffer.get(x, y).unwrap().content.as_char(), Some('#'));
1865            }
1866        }
1867        // Outside
1868        assert!(frame.buffer.get(0, 0).unwrap().is_empty());
1869        assert!(frame.buffer.get(4, 4).unwrap().is_empty());
1870    }
1871
1872    #[test]
1873    fn frame_paint_area() {
1874        use crate::cell::PackedRgba;
1875        let mut pool = GraphemePool::new();
1876        let mut frame = Frame::new(5, 5, &mut pool);
1877        let red = PackedRgba::rgb(255, 0, 0);
1878        frame.paint_area(Rect::new(0, 0, 2, 2), Some(red), None);
1879        let cell = frame.buffer.get(0, 0).unwrap();
1880        assert_eq!(cell.fg, red);
1881    }
1882
1883    // --- Frame print_text_clipped ---
1884
1885    #[test]
1886    fn frame_print_text_clipped_at_boundary() {
1887        let mut pool = GraphemePool::new();
1888        let mut frame = Frame::new(5, 1, &mut pool);
1889        // "Hello World" should be clipped at width 5
1890        let end = frame.print_text(0, 0, "Hello World", Cell::from_char(' '));
1891        assert_eq!(end, 5);
1892        for x in 0..5 {
1893            assert!(!frame.buffer.get(x, 0).unwrap().is_empty());
1894        }
1895    }
1896
1897    #[test]
1898    fn frame_print_text_empty_string() {
1899        let mut pool = GraphemePool::new();
1900        let mut frame = Frame::new(10, 1, &mut pool);
1901        let end = frame.print_text(0, 0, "", Cell::from_char(' '));
1902        assert_eq!(end, 0);
1903    }
1904
1905    #[test]
1906    fn frame_print_text_at_right_edge() {
1907        let mut pool = GraphemePool::new();
1908        let mut frame = Frame::new(5, 1, &mut pool);
1909        // Start at x=4, only 1 cell fits
1910        let end = frame.print_text(4, 0, "AB", Cell::from_char(' '));
1911        assert_eq!(end, 5);
1912        assert_eq!(frame.buffer.get(4, 0).unwrap().content.as_char(), Some('A'));
1913    }
1914
1915    // --- Frame Debug ---
1916
1917    #[test]
1918    fn frame_debug() {
1919        let mut pool = GraphemePool::new();
1920        let frame = Frame::new(5, 3, &mut pool);
1921        let dbg = format!("{:?}", frame);
1922        assert!(dbg.contains("Frame"), "Debug: {dbg}");
1923    }
1924
1925    // --- HitGrid Debug ---
1926
1927    #[test]
1928    fn hit_grid_debug() {
1929        let grid = HitGrid::new(3, 3);
1930        let dbg = format!("{:?}", grid);
1931        assert!(dbg.contains("HitGrid"), "Debug: {dbg}");
1932    }
1933
1934    // --- Frame cursor beyond bounds ---
1935
1936    #[test]
1937    fn frame_cursor_beyond_bounds() {
1938        let mut pool = GraphemePool::new();
1939        let mut frame = Frame::new(10, 10, &mut pool);
1940        // Setting cursor beyond frame is allowed (no clipping)
1941        frame.set_cursor(Some((100, 200)));
1942        assert_eq!(frame.cursor_position, Some((100, 200)));
1943    }
1944
1945    // --- HitGrid large data values ---
1946
1947    #[test]
1948    fn hit_grid_register_overwrite() {
1949        let mut grid = HitGrid::new(5, 5);
1950        grid.register(Rect::new(0, 0, 3, 3), HitId::new(1), HitRegion::Content, 10);
1951        grid.register(Rect::new(0, 0, 3, 3), HitId::new(2), HitRegion::Button, 20);
1952        // Second registration overwrites first
1953        assert_eq!(
1954            grid.hit_test(1, 1),
1955            Some((HitId::new(2), HitRegion::Button, 20))
1956        );
1957    }
1958
1959    #[test]
1960    fn frame_hit_test_detailed_preserves_owner() {
1961        let mut pool = GraphemePool::new();
1962        let mut frame = Frame::with_hit_grid(4, 4, &mut pool);
1963
1964        frame.with_hit_owner(77, |frame| {
1965            frame.register_hit(Rect::new(1, 1, 2, 2), HitId::new(5), HitRegion::Button, 9);
1966        });
1967
1968        assert_eq!(
1969            frame.hit_test_detailed(1, 1),
1970            Some(HitTestResult::new(
1971                HitId::new(5),
1972                HitRegion::Button,
1973                9,
1974                Some(77),
1975            ))
1976        );
1977        assert_eq!(
1978            frame.hit_test(1, 1),
1979            Some((HitId::new(5), HitRegion::Button, 9))
1980        );
1981    }
1982
1983    #[test]
1984    fn frame_hit_owner_scope_restores_previous_owner() {
1985        let mut pool = GraphemePool::new();
1986        let mut frame = Frame::with_hit_grid(4, 4, &mut pool);
1987
1988        frame.with_hit_owner(10, |frame| {
1989            frame.register_hit(Rect::new(0, 0, 1, 1), HitId::new(1), HitRegion::Content, 1);
1990            frame.with_hit_owner(20, |frame| {
1991                frame.register_hit(Rect::new(1, 0, 1, 1), HitId::new(2), HitRegion::Content, 2);
1992            });
1993            frame.register_hit(Rect::new(2, 0, 1, 1), HitId::new(3), HitRegion::Content, 3);
1994        });
1995
1996        assert_eq!(frame.hit_test_detailed(0, 0).unwrap().owner, Some(10));
1997        assert_eq!(frame.hit_test_detailed(1, 0).unwrap().owner, Some(20));
1998        assert_eq!(frame.hit_test_detailed(2, 0).unwrap().owner, Some(10));
1999    }
2000
2001    #[test]
2002    fn frame_hit_owner_scope_restores_after_panic() {
2003        let mut pool = GraphemePool::new();
2004        let mut frame = Frame::with_hit_grid(4, 4, &mut pool);
2005
2006        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2007            frame.with_hit_owner(55, |_frame| panic!("boom"));
2008        }));
2009        assert!(result.is_err());
2010
2011        frame.register_hit(Rect::new(0, 0, 1, 1), HitId::new(9), HitRegion::Content, 0);
2012        assert_eq!(frame.hit_test_detailed(0, 0).unwrap().owner, None);
2013    }
2014}