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}