ailloli_ui_editor/input/
hit_test.rs

1//! Screen-point hit testing for text and code-editor zones.
2
3use ailloli_ui_core::Point;
4
5use crate::layout::{layout_visual_height, run_visual_bottom, run_visual_top, EditorTextRun};
6use crate::{EditorStyle, EditorViewport};
7
8/// Hit-test result in UTF-8 byte coordinates.
9///
10/// # Examples
11///
12/// ```
13/// use ailloli_ui_editor::EditorHitTest;
14/// assert_eq!(EditorHitTest { byte: 4 }.byte, 4);
15/// ```
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct EditorHitTest {
18    /// Clamped source-buffer UTF-8 byte offset.
19    pub byte: usize,
20}
21
22/// Semantic editor area containing a hit point.
23///
24/// # Examples
25///
26/// ```
27/// use ailloli_ui_editor::EditorHitZone;
28/// assert_ne!(EditorHitZone::Text, EditorHitZone::Gutter);
29/// ```
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum EditorHitZone {
32    /// Text viewport or padded content area.
33    Text,
34    /// Enabled left gutter.
35    Gutter,
36    /// Outside the complete content rectangle.
37    Outside,
38}
39
40/// Hit result including semantic zone and source byte.
41///
42/// # Examples
43///
44/// ```
45/// use ailloli_ui_editor::{EditorHitZone, EditorZoneHitTest};
46/// let hit = EditorZoneHitTest { zone: EditorHitZone::Outside, byte: 10 };
47/// assert_eq!((hit.zone, hit.byte), (EditorHitZone::Outside, 10));
48/// ```
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct EditorZoneHitTest {
51    /// Text, gutter, or outside classification.
52    pub zone: EditorHitZone,
53    /// Source-buffer byte; outside hits use `buffer_len_bytes`.
54    pub byte: usize,
55}
56
57/// Maps a screen point to the closest visible run's UTF-8 source byte.
58///
59/// Above/below points select the first/last run. With no runs the result is
60/// `buffer_len_bytes`. Local run results are clamped to shaped text length.
61///
62/// # Examples
63///
64/// ```
65/// use ailloli_ui_core::{Point, Rect};
66/// use ailloli_ui_editor::{input::hit_test::byte_at_point, EditorConfig, EditorStyle, EditorViewport};
67/// use ailloli_ui_text::TextEditState;
68/// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 80.0, 40.0), EditorConfig::default(), &TextEditState::new());
69/// assert_eq!(byte_at_point(viewport, &[], EditorStyle::default(), 12, Point::new(1.0, 1.0)).byte, 12);
70/// ```
71pub fn byte_at_point(
72    viewport: EditorViewport,
73    runs: &[EditorTextRun],
74    style: EditorStyle,
75    buffer_len_bytes: usize,
76    pos: Point,
77) -> EditorHitTest {
78    let (rel_x, rel_y) = viewport.local_point(pos);
79    let chosen = runs
80        .iter()
81        .find(|run| {
82            let top = run_visual_top(run);
83            rel_y >= top && rel_y < run_visual_bottom(run, style)
84        })
85        .or_else(|| {
86            if rel_y < runs.first().map(run_visual_top).unwrap_or(0.0) {
87                runs.first()
88            } else {
89                runs.last()
90            }
91        });
92    let Some(run) = chosen else {
93        return EditorHitTest {
94            byte: buffer_len_bytes,
95        };
96    };
97    let local_y = (rel_y - run_visual_top(run))
98        .max(0.0)
99        .min(layout_visual_height(&run.layout, style));
100    let local = run.layout.caret_index_at_point(rel_x, local_y);
101    EditorHitTest {
102        byte: run.byte_range.start + local.min(run.layout.text().len()),
103    }
104}
105
106/// Classifies gutter/text/outside before resolving the nearest byte.
107///
108/// Gutter hits resolve at the text left edge on the same y coordinate. Padded
109/// content outside `text_rect` is still classified as text. Outside uses the
110/// supplied buffer length without consulting runs.
111///
112/// # Examples
113///
114/// ```
115/// use ailloli_ui_core::{Point, Rect};
116/// use ailloli_ui_editor::{input::hit_test::zone_byte_at_point, EditorConfig, EditorHitZone, EditorStyle, EditorViewport};
117/// use ailloli_ui_text::TextEditState;
118/// let viewport = EditorViewport::new(Rect::new(0.0, 0.0, 80.0, 40.0), EditorConfig::default(), &TextEditState::new());
119/// let hit = zone_byte_at_point(viewport, &[], EditorStyle::default(), 9, Point::new(100.0, 100.0));
120/// assert_eq!((hit.zone, hit.byte), (EditorHitZone::Outside, 9));
121/// ```
122pub fn zone_byte_at_point(
123    viewport: EditorViewport,
124    runs: &[EditorTextRun],
125    style: EditorStyle,
126    buffer_len_bytes: usize,
127    pos: Point,
128) -> EditorZoneHitTest {
129    if viewport
130        .gutter_rect
131        .is_some_and(|rect| rect.contains(pos.x, pos.y))
132    {
133        let gutter_line_pos = Point::new(viewport.text_rect.x, pos.y);
134        return EditorZoneHitTest {
135            zone: EditorHitZone::Gutter,
136            byte: byte_at_point(viewport, runs, style, buffer_len_bytes, gutter_line_pos).byte,
137        };
138    }
139    if viewport.text_rect.contains(pos.x, pos.y) || viewport.content_rect.contains(pos.x, pos.y) {
140        return EditorZoneHitTest {
141            zone: EditorHitZone::Text,
142            byte: byte_at_point(viewport, runs, style, buffer_len_bytes, pos).byte,
143        };
144    }
145    EditorZoneHitTest {
146        zone: EditorHitZone::Outside,
147        byte: buffer_len_bytes,
148    }
149}