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::budget::DegradationLevel;
33use crate::buffer::Buffer;
34use crate::cell::{Cell, CellContent, GraphemeId};
35use crate::drawing::{BorderChars, Draw};
36use crate::grapheme_pool::GraphemePool;
37use crate::{display_width, grapheme_width};
38use ftui_core::geometry::Rect;
39use unicode_segmentation::UnicodeSegmentation;
40
41/// Identifier for a clickable region in the hit grid.
42///
43/// Widgets register hit regions with unique IDs to enable mouse interaction.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
45pub struct HitId(pub u32);
46
47impl HitId {
48    /// Create a new hit ID from a raw value.
49    #[inline]
50    pub const fn new(id: u32) -> Self {
51        Self(id)
52    }
53
54    /// Get the raw ID value.
55    #[inline]
56    pub const fn id(self) -> u32 {
57        self.0
58    }
59}
60
61/// Opaque user data for hit callbacks.
62pub type HitData = u64;
63
64/// Regions within a widget for mouse interaction.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
66pub enum HitRegion {
67    /// No interactive region.
68    #[default]
69    None,
70    /// Main content area.
71    Content,
72    /// Widget border area.
73    Border,
74    /// Scrollbar track or thumb.
75    Scrollbar,
76    /// Resize handle or drag target.
77    Handle,
78    /// Clickable button.
79    Button,
80    /// Hyperlink.
81    Link,
82    /// Custom region tag.
83    Custom(u8),
84}
85
86/// A single hit cell in the grid.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88pub struct HitCell {
89    /// Widget that registered this cell, if any.
90    pub widget_id: Option<HitId>,
91    /// Region tag for the hit area.
92    pub region: HitRegion,
93    /// Extra data attached to this hit cell.
94    pub data: HitData,
95}
96
97impl HitCell {
98    /// Create a populated hit cell.
99    #[inline]
100    pub const fn new(widget_id: HitId, region: HitRegion, data: HitData) -> Self {
101        Self {
102            widget_id: Some(widget_id),
103            region,
104            data,
105        }
106    }
107
108    /// Check if the cell is empty.
109    #[inline]
110    pub const fn is_empty(&self) -> bool {
111        self.widget_id.is_none()
112    }
113}
114
115/// Hit testing grid for mouse interaction.
116///
117/// Maps screen positions to widget IDs, enabling widgets to receive
118/// mouse events for their regions.
119#[derive(Debug, Clone)]
120pub struct HitGrid {
121    width: u16,
122    height: u16,
123    cells: Vec<HitCell>,
124}
125
126impl HitGrid {
127    /// Create a new hit grid with the given dimensions.
128    pub fn new(width: u16, height: u16) -> Self {
129        let size = width as usize * height as usize;
130        Self {
131            width,
132            height,
133            cells: vec![HitCell::default(); size],
134        }
135    }
136
137    /// Grid width.
138    #[inline]
139    pub const fn width(&self) -> u16 {
140        self.width
141    }
142
143    /// Grid height.
144    #[inline]
145    pub const fn height(&self) -> u16 {
146        self.height
147    }
148
149    /// Convert (x, y) to linear index.
150    #[inline]
151    fn index(&self, x: u16, y: u16) -> Option<usize> {
152        if x < self.width && y < self.height {
153            Some(y as usize * self.width as usize + x as usize)
154        } else {
155            None
156        }
157    }
158
159    /// Get the hit cell at (x, y).
160    #[inline]
161    pub fn get(&self, x: u16, y: u16) -> Option<&HitCell> {
162        self.index(x, y).map(|i| &self.cells[i])
163    }
164
165    /// Get mutable reference to hit cell at (x, y).
166    #[inline]
167    pub fn get_mut(&mut self, x: u16, y: u16) -> Option<&mut HitCell> {
168        self.index(x, y).map(|i| &mut self.cells[i])
169    }
170
171    /// Register a clickable region with the given hit metadata.
172    ///
173    /// All cells within the rectangle will map to this hit cell.
174    pub fn register(&mut self, rect: Rect, widget_id: HitId, region: HitRegion, data: HitData) {
175        // Use usize to avoid overflow for large coordinates
176        let x_end = (rect.x as usize + rect.width as usize).min(self.width as usize);
177        let y_end = (rect.y as usize + rect.height as usize).min(self.height as usize);
178
179        // Check if there's anything to do
180        if rect.x as usize >= x_end || rect.y as usize >= y_end {
181            return;
182        }
183
184        let hit_cell = HitCell::new(widget_id, region, data);
185
186        for y in rect.y as usize..y_end {
187            let row_start = y * self.width as usize;
188            let start = row_start + rect.x as usize;
189            let end = row_start + x_end;
190
191            // Optimize: use slice fill for contiguous memory access
192            self.cells[start..end].fill(hit_cell);
193        }
194    }
195
196    /// Hit test at the given position.
197    ///
198    /// Returns the hit tuple if a region is registered at (x, y).
199    pub fn hit_test(&self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
200        self.get(x, y)
201            .and_then(|cell| cell.widget_id.map(|id| (id, cell.region, cell.data)))
202    }
203
204    /// Return all hits within the given rectangle.
205    pub fn hits_in(&self, rect: Rect) -> Vec<(HitId, HitRegion, HitData)> {
206        let x_end = (rect.x as usize + rect.width as usize).min(self.width as usize) as u16;
207        let y_end = (rect.y as usize + rect.height as usize).min(self.height as usize) as u16;
208        let mut hits = Vec::new();
209
210        for y in rect.y..y_end {
211            for x in rect.x..x_end {
212                if let Some((id, region, data)) = self.hit_test(x, y) {
213                    hits.push((id, region, data));
214                }
215            }
216        }
217
218        hits
219    }
220
221    /// Clear all hit regions.
222    pub fn clear(&mut self) {
223        self.cells.fill(HitCell::default());
224    }
225}
226
227use crate::link_registry::LinkRegistry;
228
229/// Source of the cost estimate for widget scheduling.
230#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
231pub enum CostEstimateSource {
232    /// Measured from recent render timings.
233    Measured,
234    /// Derived from area-based fallback (cells * cost_per_cell).
235    AreaFallback,
236    /// Fixed default when no signals exist.
237    #[default]
238    FixedDefault,
239}
240
241/// Per-widget scheduling signals captured during rendering.
242///
243/// These signals are used by runtime policies (budgeted refresh, greedy
244/// selection) to prioritize which widgets to render when budget is tight.
245#[derive(Debug, Clone)]
246pub struct WidgetSignal {
247    /// Stable widget identifier.
248    pub widget_id: u64,
249    /// Whether this widget is essential.
250    pub essential: bool,
251    /// Base priority in [0, 1].
252    pub priority: f32,
253    /// Milliseconds since last render.
254    pub staleness_ms: u64,
255    /// Focus boost in [0, 1].
256    pub focus_boost: f32,
257    /// Interaction boost in [0, 1].
258    pub interaction_boost: f32,
259    /// Widget area in cells (width * height).
260    pub area_cells: u32,
261    /// Estimated render cost in microseconds.
262    pub cost_estimate_us: f32,
263    /// Recent measured cost (EMA), if available.
264    pub recent_cost_us: f32,
265    /// Cost estimate provenance.
266    pub estimate_source: CostEstimateSource,
267}
268
269impl Default for WidgetSignal {
270    fn default() -> Self {
271        Self {
272            widget_id: 0,
273            essential: false,
274            priority: 0.5,
275            staleness_ms: 0,
276            focus_boost: 0.0,
277            interaction_boost: 0.0,
278            area_cells: 1,
279            cost_estimate_us: 5.0,
280            recent_cost_us: 5.0,
281            estimate_source: CostEstimateSource::FixedDefault,
282        }
283    }
284}
285
286impl WidgetSignal {
287    /// Create a widget signal with neutral defaults.
288    #[must_use]
289    pub fn new(widget_id: u64) -> Self {
290        Self {
291            widget_id,
292            ..Self::default()
293        }
294    }
295}
296
297/// Widget render budget policy for a single frame.
298#[derive(Debug, Clone)]
299pub struct WidgetBudget {
300    allow_list: Option<Vec<u64>>,
301}
302
303impl Default for WidgetBudget {
304    fn default() -> Self {
305        Self::allow_all()
306    }
307}
308
309impl WidgetBudget {
310    /// Allow all widgets to render.
311    #[must_use]
312    pub fn allow_all() -> Self {
313        Self { allow_list: None }
314    }
315
316    /// Allow only a specific set of widget IDs.
317    #[must_use]
318    pub fn allow_only(mut ids: Vec<u64>) -> Self {
319        ids.sort_unstable();
320        ids.dedup();
321        Self {
322            allow_list: Some(ids),
323        }
324    }
325
326    /// Check whether a widget should be rendered.
327    #[inline]
328    pub fn allows(&self, widget_id: u64, essential: bool) -> bool {
329        if essential {
330            return true;
331        }
332        match &self.allow_list {
333            None => true,
334            Some(ids) => ids.binary_search(&widget_id).is_ok(),
335        }
336    }
337}
338
339/// Frame = Buffer + metadata for a render pass.
340///
341/// The Frame is passed to `Model::view()` and contains everything needed
342/// to render a single frame. The Buffer holds cells; metadata controls
343/// cursor and enables mouse hit testing.
344///
345/// # Lifetime
346///
347/// The frame borrows the `GraphemePool` from the runtime, so it cannot outlive
348/// the render pass. This is correct because frames are ephemeral render targets.
349#[derive(Debug)]
350pub struct Frame<'a> {
351    /// The cell grid for this render pass.
352    pub buffer: Buffer,
353
354    /// Reference to the grapheme pool for interning strings.
355    pub pool: &'a mut GraphemePool,
356
357    /// Optional reference to link registry for hyperlinks.
358    pub links: Option<&'a mut LinkRegistry>,
359
360    /// Optional hit grid for mouse hit testing.
361    ///
362    /// When `Some`, widgets can register clickable regions.
363    pub hit_grid: Option<HitGrid>,
364
365    /// Widget render budget policy for this frame.
366    pub widget_budget: WidgetBudget,
367
368    /// Collected per-widget scheduling signals for this frame.
369    pub widget_signals: Vec<WidgetSignal>,
370
371    /// Cursor position (if app wants to show cursor).
372    ///
373    /// Coordinates are relative to buffer (0-indexed).
374    pub cursor_position: Option<(u16, u16)>,
375
376    /// Whether cursor should be visible.
377    pub cursor_visible: bool,
378
379    /// Current degradation level from the render budget.
380    ///
381    /// Widgets can read this to skip expensive operations when the
382    /// budget is constrained (e.g., use ASCII borders instead of
383    /// Unicode, skip decorative rendering, etc.).
384    pub degradation: DegradationLevel,
385}
386
387impl<'a> Frame<'a> {
388    /// Create a new frame with given dimensions and grapheme pool.
389    ///
390    /// The frame starts with no hit grid and visible cursor at no position.
391    pub fn new(width: u16, height: u16, pool: &'a mut GraphemePool) -> Self {
392        Self {
393            buffer: Buffer::new(width, height),
394            pool,
395            links: None,
396            hit_grid: None,
397            widget_budget: WidgetBudget::default(),
398            widget_signals: Vec::new(),
399            cursor_position: None,
400            cursor_visible: true,
401            degradation: DegradationLevel::Full,
402        }
403    }
404
405    /// Create a frame from an existing buffer.
406    ///
407    /// This avoids per-frame buffer allocation when callers reuse buffers.
408    pub fn from_buffer(buffer: Buffer, pool: &'a mut GraphemePool) -> Self {
409        Self {
410            buffer,
411            pool,
412            links: None,
413            hit_grid: None,
414            widget_budget: WidgetBudget::default(),
415            widget_signals: Vec::new(),
416            cursor_position: None,
417            cursor_visible: true,
418            degradation: DegradationLevel::Full,
419        }
420    }
421
422    /// Create a new frame with grapheme pool and link registry.
423    ///
424    /// This avoids double-borrowing issues when both pool and links
425    /// come from the same parent struct.
426    pub fn with_links(
427        width: u16,
428        height: u16,
429        pool: &'a mut GraphemePool,
430        links: &'a mut LinkRegistry,
431    ) -> Self {
432        Self {
433            buffer: Buffer::new(width, height),
434            pool,
435            links: Some(links),
436            hit_grid: None,
437            widget_budget: WidgetBudget::default(),
438            widget_signals: Vec::new(),
439            cursor_position: None,
440            cursor_visible: true,
441            degradation: DegradationLevel::Full,
442        }
443    }
444
445    /// Create a frame with hit testing enabled.
446    ///
447    /// The hit grid allows widgets to register clickable regions.
448    pub fn with_hit_grid(width: u16, height: u16, pool: &'a mut GraphemePool) -> Self {
449        Self {
450            buffer: Buffer::new(width, height),
451            pool,
452            links: None,
453            hit_grid: Some(HitGrid::new(width, height)),
454            widget_budget: WidgetBudget::default(),
455            widget_signals: Vec::new(),
456            cursor_position: None,
457            cursor_visible: true,
458            degradation: DegradationLevel::Full,
459        }
460    }
461
462    /// Set the link registry for this frame.
463    pub fn set_links(&mut self, links: &'a mut LinkRegistry) {
464        self.links = Some(links);
465    }
466
467    /// Register a hyperlink URL and return its ID.
468    ///
469    /// Returns 0 if link registry is not available or full.
470    pub fn register_link(&mut self, url: &str) -> u32 {
471        if let Some(ref mut links) = self.links {
472            links.register(url)
473        } else {
474            0
475        }
476    }
477
478    /// Set the widget render budget for this frame.
479    pub fn set_widget_budget(&mut self, budget: WidgetBudget) {
480        self.widget_budget = budget;
481    }
482
483    /// Check whether a widget should be rendered under the current budget.
484    #[inline]
485    pub fn should_render_widget(&self, widget_id: u64, essential: bool) -> bool {
486        self.widget_budget.allows(widget_id, essential)
487    }
488
489    /// Register a widget scheduling signal for this frame.
490    pub fn register_widget_signal(&mut self, signal: WidgetSignal) {
491        self.widget_signals.push(signal);
492    }
493
494    /// Borrow the collected widget signals.
495    #[inline]
496    pub fn widget_signals(&self) -> &[WidgetSignal] {
497        &self.widget_signals
498    }
499
500    /// Take the collected widget signals, leaving an empty list.
501    #[inline]
502    pub fn take_widget_signals(&mut self) -> Vec<WidgetSignal> {
503        std::mem::take(&mut self.widget_signals)
504    }
505
506    /// Intern a string in the grapheme pool.
507    ///
508    /// Returns a `GraphemeId` that can be used to create a `Cell`.
509    /// The width is calculated automatically or can be provided if already known.
510    ///
511    /// # Panics
512    ///
513    /// Panics if width > 127.
514    pub fn intern(&mut self, text: &str) -> GraphemeId {
515        let width = display_width(text).min(127) as u8;
516        self.pool.intern(text, width)
517    }
518
519    /// Intern a string with explicit width.
520    pub fn intern_with_width(&mut self, text: &str, width: u8) -> GraphemeId {
521        self.pool.intern(text, width)
522    }
523
524    /// Enable hit testing on an existing frame.
525    pub fn enable_hit_testing(&mut self) {
526        if self.hit_grid.is_none() {
527            self.hit_grid = Some(HitGrid::new(self.width(), self.height()));
528        }
529    }
530
531    /// Frame width in cells.
532    #[inline]
533    pub fn width(&self) -> u16 {
534        self.buffer.width()
535    }
536
537    /// Frame height in cells.
538    #[inline]
539    pub fn height(&self) -> u16 {
540        self.buffer.height()
541    }
542
543    /// Clear frame for next render.
544    ///
545    /// Resets both the buffer and hit grid (if present).
546    pub fn clear(&mut self) {
547        self.buffer.clear();
548        if let Some(ref mut grid) = self.hit_grid {
549            grid.clear();
550        }
551        self.cursor_position = None;
552        self.widget_signals.clear();
553    }
554
555    /// Set cursor position.
556    ///
557    /// Pass `None` to indicate no cursor should be shown at a specific position.
558    #[inline]
559    pub fn set_cursor(&mut self, position: Option<(u16, u16)>) {
560        self.cursor_position = position;
561    }
562
563    /// Set cursor visibility.
564    #[inline]
565    pub fn set_cursor_visible(&mut self, visible: bool) {
566        self.cursor_visible = visible;
567    }
568
569    /// Set the degradation level for this frame.
570    ///
571    /// Propagates to the buffer so widgets can read `buf.degradation`
572    /// during rendering without needing access to the full Frame.
573    #[inline]
574    pub fn set_degradation(&mut self, level: DegradationLevel) {
575        self.degradation = level;
576        self.buffer.degradation = level;
577    }
578
579    /// Get the bounding rectangle of the frame.
580    #[inline]
581    pub fn bounds(&self) -> Rect {
582        self.buffer.bounds()
583    }
584
585    /// Register a hit region (if hit grid is enabled).
586    ///
587    /// Returns `true` if the region was registered, `false` if no hit grid.
588    ///
589    /// # Clipping
590    ///
591    /// The region is intersected with the current scissor stack of the
592    /// internal buffer. Parts of the region outside the scissor are
593    /// ignored.
594    pub fn register_hit(
595        &mut self,
596        rect: Rect,
597        id: HitId,
598        region: HitRegion,
599        data: HitData,
600    ) -> bool {
601        if let Some(ref mut grid) = self.hit_grid {
602            // Clip against current scissor
603            let clipped = rect.intersection(&self.buffer.current_scissor());
604            if !clipped.is_empty() {
605                grid.register(clipped, id, region, data);
606            }
607            true
608        } else {
609            false
610        }
611    }
612
613    /// Hit test at the given position (if hit grid is enabled).
614    pub fn hit_test(&self, x: u16, y: u16) -> Option<(HitId, HitRegion, HitData)> {
615        self.hit_grid.as_ref().and_then(|grid| grid.hit_test(x, y))
616    }
617
618    /// Register a hit region with default metadata (Content, data=0).
619    pub fn register_hit_region(&mut self, rect: Rect, id: HitId) -> bool {
620        self.register_hit(rect, id, HitRegion::Content, 0)
621    }
622}
623
624impl<'a> Draw for Frame<'a> {
625    fn draw_horizontal_line(&mut self, x: u16, y: u16, width: u16, cell: Cell) {
626        self.buffer.draw_horizontal_line(x, y, width, cell);
627    }
628
629    fn draw_vertical_line(&mut self, x: u16, y: u16, height: u16, cell: Cell) {
630        self.buffer.draw_vertical_line(x, y, height, cell);
631    }
632
633    fn draw_rect_filled(&mut self, rect: Rect, cell: Cell) {
634        self.buffer.draw_rect_filled(rect, cell);
635    }
636
637    fn draw_rect_outline(&mut self, rect: Rect, cell: Cell) {
638        self.buffer.draw_rect_outline(rect, cell);
639    }
640
641    fn print_text(&mut self, x: u16, y: u16, text: &str, base_cell: Cell) -> u16 {
642        self.print_text_clipped(x, y, text, base_cell, self.width())
643    }
644
645    fn print_text_clipped(
646        &mut self,
647        x: u16,
648        y: u16,
649        text: &str,
650        base_cell: Cell,
651        max_x: u16,
652    ) -> u16 {
653        let mut cx = x;
654        for grapheme in text.graphemes(true) {
655            let width = grapheme_width(grapheme);
656            if width == 0 {
657                continue;
658            }
659
660            if cx >= max_x {
661                break;
662            }
663
664            // Don't start a wide char if it won't fit
665            if cx as u32 + width as u32 > max_x as u32 {
666                break;
667            }
668
669            // Intern grapheme if needed (unlike Buffer::print_text, we have the pool!)
670            let content = if width > 1 || grapheme.chars().count() > 1 {
671                let id = self.intern_with_width(grapheme, width as u8);
672                CellContent::from_grapheme(id)
673            } else if let Some(c) = grapheme.chars().next() {
674                CellContent::from_char(c)
675            } else {
676                continue;
677            };
678
679            let cell = Cell {
680                content,
681                fg: base_cell.fg,
682                bg: base_cell.bg,
683                attrs: base_cell.attrs,
684            };
685            self.buffer.set(cx, y, cell);
686
687            cx = cx.saturating_add(width as u16);
688        }
689        cx
690    }
691
692    fn draw_border(&mut self, rect: Rect, chars: BorderChars, base_cell: Cell) {
693        self.buffer.draw_border(rect, chars, base_cell);
694    }
695
696    fn draw_box(&mut self, rect: Rect, chars: BorderChars, border_cell: Cell, fill_cell: Cell) {
697        self.buffer.draw_box(rect, chars, border_cell, fill_cell);
698    }
699
700    fn paint_area(
701        &mut self,
702        rect: Rect,
703        fg: Option<crate::cell::PackedRgba>,
704        bg: Option<crate::cell::PackedRgba>,
705    ) {
706        self.buffer.paint_area(rect, fg, bg);
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713    use crate::cell::Cell;
714
715    #[test]
716    fn frame_creation() {
717        let mut pool = GraphemePool::new();
718        let frame = Frame::new(80, 24, &mut pool);
719        assert_eq!(frame.width(), 80);
720        assert_eq!(frame.height(), 24);
721        assert!(frame.hit_grid.is_none());
722        assert!(frame.cursor_position.is_none());
723        assert!(frame.cursor_visible);
724    }
725
726    #[test]
727    fn frame_with_hit_grid() {
728        let mut pool = GraphemePool::new();
729        let frame = Frame::with_hit_grid(80, 24, &mut pool);
730        assert!(frame.hit_grid.is_some());
731        assert_eq!(frame.width(), 80);
732        assert_eq!(frame.height(), 24);
733    }
734
735    #[test]
736    fn frame_cursor() {
737        let mut pool = GraphemePool::new();
738        let mut frame = Frame::new(80, 24, &mut pool);
739        assert!(frame.cursor_position.is_none());
740        assert!(frame.cursor_visible);
741
742        frame.set_cursor(Some((10, 5)));
743        assert_eq!(frame.cursor_position, Some((10, 5)));
744
745        frame.set_cursor_visible(false);
746        assert!(!frame.cursor_visible);
747
748        frame.set_cursor(None);
749        assert!(frame.cursor_position.is_none());
750    }
751
752    #[test]
753    fn frame_clear() {
754        let mut pool = GraphemePool::new();
755        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
756
757        // Add some content
758        frame.buffer.set_raw(5, 5, Cell::from_char('X'));
759        frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
760
761        // Verify content exists
762        assert_eq!(frame.buffer.get(5, 5).unwrap().content.as_char(), Some('X'));
763        assert_eq!(
764            frame.hit_test(2, 2),
765            Some((HitId::new(1), HitRegion::Content, 0))
766        );
767
768        // Clear
769        frame.clear();
770
771        // Verify cleared
772        assert!(frame.buffer.get(5, 5).unwrap().is_empty());
773        assert!(frame.hit_test(2, 2).is_none());
774    }
775
776    #[test]
777    fn frame_bounds() {
778        let mut pool = GraphemePool::new();
779        let frame = Frame::new(80, 24, &mut pool);
780        let bounds = frame.bounds();
781        assert_eq!(bounds.x, 0);
782        assert_eq!(bounds.y, 0);
783        assert_eq!(bounds.width, 80);
784        assert_eq!(bounds.height, 24);
785    }
786
787    #[test]
788    fn hit_grid_creation() {
789        let grid = HitGrid::new(80, 24);
790        assert_eq!(grid.width(), 80);
791        assert_eq!(grid.height(), 24);
792    }
793
794    #[test]
795    fn hit_grid_registration() {
796        let mut pool = GraphemePool::new();
797        let mut frame = Frame::with_hit_grid(80, 24, &mut pool);
798        let hit_id = HitId::new(42);
799        let rect = Rect::new(10, 5, 20, 3);
800
801        frame.register_hit(rect, hit_id, HitRegion::Button, 99);
802
803        // Inside rect
804        assert_eq!(frame.hit_test(15, 6), Some((hit_id, HitRegion::Button, 99)));
805        assert_eq!(frame.hit_test(10, 5), Some((hit_id, HitRegion::Button, 99))); // Top-left corner
806        assert_eq!(frame.hit_test(29, 7), Some((hit_id, HitRegion::Button, 99))); // Bottom-right corner
807
808        // Outside rect
809        assert!(frame.hit_test(5, 5).is_none()); // Left of rect
810        assert!(frame.hit_test(30, 6).is_none()); // Right of rect (exclusive)
811        assert!(frame.hit_test(15, 8).is_none()); // Below rect
812        assert!(frame.hit_test(15, 4).is_none()); // Above rect
813    }
814
815    #[test]
816    fn hit_grid_overlapping_regions() {
817        let mut pool = GraphemePool::new();
818        let mut frame = Frame::with_hit_grid(20, 20, &mut pool);
819
820        // Register two overlapping regions
821        frame.register_hit(
822            Rect::new(0, 0, 10, 10),
823            HitId::new(1),
824            HitRegion::Content,
825            1,
826        );
827        frame.register_hit(Rect::new(5, 5, 10, 10), HitId::new(2), HitRegion::Border, 2);
828
829        // Non-overlapping region from first
830        assert_eq!(
831            frame.hit_test(2, 2),
832            Some((HitId::new(1), HitRegion::Content, 1))
833        );
834
835        // Overlapping region - second wins (last registered)
836        assert_eq!(
837            frame.hit_test(7, 7),
838            Some((HitId::new(2), HitRegion::Border, 2))
839        );
840
841        // Non-overlapping region from second
842        assert_eq!(
843            frame.hit_test(12, 12),
844            Some((HitId::new(2), HitRegion::Border, 2))
845        );
846    }
847
848    #[test]
849    fn hit_grid_out_of_bounds() {
850        let mut pool = GraphemePool::new();
851        let frame = Frame::with_hit_grid(10, 10, &mut pool);
852
853        // Out of bounds returns None
854        assert!(frame.hit_test(100, 100).is_none());
855        assert!(frame.hit_test(10, 0).is_none()); // Exclusive bound
856        assert!(frame.hit_test(0, 10).is_none()); // Exclusive bound
857    }
858
859    #[test]
860    fn hit_id_properties() {
861        let id = HitId::new(42);
862        assert_eq!(id.id(), 42);
863        assert_eq!(id, HitId(42));
864    }
865
866    #[test]
867    fn register_hit_region_no_grid() {
868        let mut pool = GraphemePool::new();
869        let mut frame = Frame::new(10, 10, &mut pool);
870        let result = frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
871        assert!(!result); // No hit grid, returns false
872    }
873
874    #[test]
875    fn register_hit_region_with_grid() {
876        let mut pool = GraphemePool::new();
877        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
878        let result = frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
879        assert!(result); // Has hit grid, returns true
880    }
881
882    #[test]
883    fn hit_grid_clear() {
884        let mut grid = HitGrid::new(10, 10);
885        grid.register(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
886
887        assert_eq!(
888            grid.hit_test(2, 2),
889            Some((HitId::new(1), HitRegion::Content, 0))
890        );
891
892        grid.clear();
893
894        assert!(grid.hit_test(2, 2).is_none());
895    }
896
897    #[test]
898    fn hit_grid_boundary_clipping() {
899        let mut grid = HitGrid::new(10, 10);
900
901        // Register region that extends beyond grid
902        grid.register(
903            Rect::new(8, 8, 10, 10),
904            HitId::new(1),
905            HitRegion::Content,
906            0,
907        );
908
909        // Inside clipped region
910        assert_eq!(
911            grid.hit_test(9, 9),
912            Some((HitId::new(1), HitRegion::Content, 0))
913        );
914
915        // Outside grid
916        assert!(grid.hit_test(10, 10).is_none());
917    }
918
919    #[test]
920    fn hit_grid_edge_and_corner_cells() {
921        let mut grid = HitGrid::new(4, 4);
922        grid.register(Rect::new(3, 0, 1, 4), HitId::new(7), HitRegion::Border, 11);
923
924        // Right-most column corners
925        assert_eq!(
926            grid.hit_test(3, 0),
927            Some((HitId::new(7), HitRegion::Border, 11))
928        );
929        assert_eq!(
930            grid.hit_test(3, 3),
931            Some((HitId::new(7), HitRegion::Border, 11))
932        );
933
934        // Neighboring cells remain empty
935        assert!(grid.hit_test(2, 0).is_none());
936        assert!(grid.hit_test(4, 0).is_none());
937        assert!(grid.hit_test(3, 4).is_none());
938
939        let mut grid = HitGrid::new(4, 4);
940        grid.register(Rect::new(0, 3, 4, 1), HitId::new(9), HitRegion::Content, 21);
941
942        // Bottom row corners
943        assert_eq!(
944            grid.hit_test(0, 3),
945            Some((HitId::new(9), HitRegion::Content, 21))
946        );
947        assert_eq!(
948            grid.hit_test(3, 3),
949            Some((HitId::new(9), HitRegion::Content, 21))
950        );
951
952        // Outside bottom row
953        assert!(grid.hit_test(0, 2).is_none());
954        assert!(grid.hit_test(0, 4).is_none());
955    }
956
957    #[test]
958    fn frame_register_hit_respects_nested_scissor() {
959        let mut pool = GraphemePool::new();
960        let mut frame = Frame::with_hit_grid(10, 10, &mut pool);
961
962        let outer = Rect::new(1, 1, 8, 8);
963        frame.buffer.push_scissor(outer);
964        assert_eq!(frame.buffer.current_scissor(), outer);
965
966        let inner = Rect::new(4, 4, 10, 10);
967        frame.buffer.push_scissor(inner);
968        let clipped = outer.intersection(&inner);
969        let current = frame.buffer.current_scissor();
970        assert_eq!(current, clipped);
971
972        // Monotonic intersection: inner scissor must stay within outer.
973        assert!(outer.contains(current.x, current.y));
974        assert!(outer.contains(
975            current.right().saturating_sub(1),
976            current.bottom().saturating_sub(1)
977        ));
978
979        frame.register_hit(
980            Rect::new(0, 0, 10, 10),
981            HitId::new(3),
982            HitRegion::Button,
983            99,
984        );
985
986        assert_eq!(
987            frame.hit_test(4, 4),
988            Some((HitId::new(3), HitRegion::Button, 99))
989        );
990        assert_eq!(
991            frame.hit_test(8, 8),
992            Some((HitId::new(3), HitRegion::Button, 99))
993        );
994        assert!(frame.hit_test(3, 3).is_none()); // inside outer, outside inner
995        assert!(frame.hit_test(0, 0).is_none()); // outside all scissor
996
997        frame.buffer.pop_scissor();
998        assert_eq!(frame.buffer.current_scissor(), outer);
999    }
1000
1001    #[test]
1002    fn hit_grid_hits_in_area() {
1003        let mut grid = HitGrid::new(5, 5);
1004        grid.register(Rect::new(0, 0, 2, 2), HitId::new(1), HitRegion::Content, 10);
1005        grid.register(Rect::new(1, 1, 2, 2), HitId::new(2), HitRegion::Button, 20);
1006
1007        let hits = grid.hits_in(Rect::new(0, 0, 3, 3));
1008        assert!(hits.contains(&(HitId::new(1), HitRegion::Content, 10)));
1009        assert!(hits.contains(&(HitId::new(2), HitRegion::Button, 20)));
1010    }
1011
1012    #[test]
1013    fn frame_intern() {
1014        let mut pool = GraphemePool::new();
1015        let mut frame = Frame::new(10, 10, &mut pool);
1016
1017        let id = frame.intern("πŸ‘‹");
1018        assert_eq!(frame.pool.get(id), Some("πŸ‘‹"));
1019    }
1020
1021    #[test]
1022    fn frame_intern_with_width() {
1023        let mut pool = GraphemePool::new();
1024        let mut frame = Frame::new(10, 10, &mut pool);
1025
1026        let id = frame.intern_with_width("πŸ§ͺ", 2);
1027        assert_eq!(id.width(), 2);
1028        assert_eq!(frame.pool.get(id), Some("πŸ§ͺ"));
1029    }
1030
1031    #[test]
1032    fn frame_print_text_emoji_presentation_sets_continuation() {
1033        let mut pool = GraphemePool::new();
1034        let mut frame = Frame::new(5, 1, &mut pool);
1035
1036        frame.print_text(0, 0, "βš™οΈ", Cell::from_char(' '));
1037
1038        let head = frame.buffer.get(0, 0).unwrap();
1039        let tail = frame.buffer.get(1, 0).unwrap();
1040
1041        assert_eq!(head.content.width(), 2);
1042        assert!(tail.content.is_continuation());
1043    }
1044
1045    #[test]
1046    fn frame_enable_hit_testing() {
1047        let mut pool = GraphemePool::new();
1048        let mut frame = Frame::new(10, 10, &mut pool);
1049        assert!(frame.hit_grid.is_none());
1050
1051        frame.enable_hit_testing();
1052        assert!(frame.hit_grid.is_some());
1053
1054        // Calling again is idempotent
1055        frame.enable_hit_testing();
1056        assert!(frame.hit_grid.is_some());
1057    }
1058
1059    #[test]
1060    fn frame_enable_hit_testing_then_register() {
1061        let mut pool = GraphemePool::new();
1062        let mut frame = Frame::new(10, 10, &mut pool);
1063        frame.enable_hit_testing();
1064
1065        let registered = frame.register_hit_region(Rect::new(0, 0, 5, 5), HitId::new(1));
1066        assert!(registered);
1067        assert_eq!(
1068            frame.hit_test(2, 2),
1069            Some((HitId::new(1), HitRegion::Content, 0))
1070        );
1071    }
1072
1073    #[test]
1074    fn hit_cell_default_is_empty() {
1075        let cell = HitCell::default();
1076        assert!(cell.is_empty());
1077        assert_eq!(cell.widget_id, None);
1078        assert_eq!(cell.region, HitRegion::None);
1079        assert_eq!(cell.data, 0);
1080    }
1081
1082    #[test]
1083    fn hit_cell_new_is_not_empty() {
1084        let cell = HitCell::new(HitId::new(1), HitRegion::Button, 42);
1085        assert!(!cell.is_empty());
1086        assert_eq!(cell.widget_id, Some(HitId::new(1)));
1087        assert_eq!(cell.region, HitRegion::Button);
1088        assert_eq!(cell.data, 42);
1089    }
1090
1091    #[test]
1092    fn hit_region_variants() {
1093        assert_eq!(HitRegion::default(), HitRegion::None);
1094
1095        // All variants are distinct
1096        let variants = [
1097            HitRegion::None,
1098            HitRegion::Content,
1099            HitRegion::Border,
1100            HitRegion::Scrollbar,
1101            HitRegion::Handle,
1102            HitRegion::Button,
1103            HitRegion::Link,
1104            HitRegion::Custom(0),
1105            HitRegion::Custom(1),
1106            HitRegion::Custom(255),
1107        ];
1108        for i in 0..variants.len() {
1109            for j in (i + 1)..variants.len() {
1110                assert_ne!(
1111                    variants[i], variants[j],
1112                    "variants {i} and {j} should differ"
1113                );
1114            }
1115        }
1116    }
1117
1118    #[test]
1119    fn hit_id_default() {
1120        let id = HitId::default();
1121        assert_eq!(id.id(), 0);
1122    }
1123
1124    #[test]
1125    fn hit_grid_initial_cells_empty() {
1126        let grid = HitGrid::new(5, 5);
1127        for y in 0..5 {
1128            for x in 0..5 {
1129                let cell = grid.get(x, y).unwrap();
1130                assert!(cell.is_empty());
1131            }
1132        }
1133    }
1134
1135    #[test]
1136    fn hit_grid_zero_dimensions() {
1137        let grid = HitGrid::new(0, 0);
1138        assert_eq!(grid.width(), 0);
1139        assert_eq!(grid.height(), 0);
1140        assert!(grid.get(0, 0).is_none());
1141        assert!(grid.hit_test(0, 0).is_none());
1142    }
1143
1144    #[test]
1145    fn hit_grid_hits_in_empty_area() {
1146        let grid = HitGrid::new(10, 10);
1147        let hits = grid.hits_in(Rect::new(0, 0, 5, 5));
1148        // All cells are empty, so no actual HitId hits
1149        assert!(hits.is_empty());
1150    }
1151
1152    #[test]
1153    fn hit_grid_hits_in_clipped_area() {
1154        let mut grid = HitGrid::new(5, 5);
1155        grid.register(Rect::new(0, 0, 5, 5), HitId::new(1), HitRegion::Content, 0);
1156
1157        // Query area extends beyond grid β€” should be clipped
1158        let hits = grid.hits_in(Rect::new(3, 3, 10, 10));
1159        assert_eq!(hits.len(), 4); // 2x2 cells inside grid
1160    }
1161
1162    #[test]
1163    fn hit_test_no_grid_returns_none() {
1164        let mut pool = GraphemePool::new();
1165        let frame = Frame::new(10, 10, &mut pool);
1166        assert!(frame.hit_test(0, 0).is_none());
1167    }
1168
1169    #[test]
1170    fn frame_cursor_operations() {
1171        let mut pool = GraphemePool::new();
1172        let mut frame = Frame::new(80, 24, &mut pool);
1173
1174        // Set position at edge of frame
1175        frame.set_cursor(Some((79, 23)));
1176        assert_eq!(frame.cursor_position, Some((79, 23)));
1177
1178        // Set position at origin
1179        frame.set_cursor(Some((0, 0)));
1180        assert_eq!(frame.cursor_position, Some((0, 0)));
1181
1182        // Toggle visibility
1183        frame.set_cursor_visible(false);
1184        assert!(!frame.cursor_visible);
1185        frame.set_cursor_visible(true);
1186        assert!(frame.cursor_visible);
1187    }
1188
1189    #[test]
1190    fn hit_data_large_values() {
1191        let mut grid = HitGrid::new(5, 5);
1192        // HitData is u64, test max value
1193        grid.register(
1194            Rect::new(0, 0, 1, 1),
1195            HitId::new(1),
1196            HitRegion::Content,
1197            u64::MAX,
1198        );
1199        let result = grid.hit_test(0, 0);
1200        assert_eq!(result, Some((HitId::new(1), HitRegion::Content, u64::MAX)));
1201    }
1202
1203    #[test]
1204    fn hit_id_large_value() {
1205        let id = HitId::new(u32::MAX);
1206        assert_eq!(id.id(), u32::MAX);
1207    }
1208
1209    #[test]
1210    fn frame_print_text_interns_complex_graphemes() {
1211        let mut pool = GraphemePool::new();
1212        let mut frame = Frame::new(10, 1, &mut pool);
1213
1214        // Flag emoji (complex grapheme)
1215        let flag = "πŸ‡ΊπŸ‡Έ";
1216        assert!(flag.chars().count() > 1);
1217
1218        frame.print_text(0, 0, flag, Cell::default());
1219
1220        let cell = frame.buffer.get(0, 0).unwrap();
1221        assert!(cell.content.is_grapheme());
1222
1223        let id = cell.content.grapheme_id().unwrap();
1224        assert_eq!(frame.pool.get(id), Some(flag));
1225    }
1226}