tui_widget_list/
hit_test.rs1use crate::ListState;
2use ratatui_core::layout::Rect;
3
4#[derive(Debug, PartialEq)]
6pub enum Hit {
7 Area,
9 Item(usize),
11}
12
13impl ListState {
14 #[must_use]
18 pub fn hit_test(&self, mouse_x: u16, mouse_y: u16) -> Option<Hit> {
19 let sizes = self.visible_main_axis_sizes();
20
21 if sizes.is_empty() {
22 return None;
23 }
24
25 let inner_area = self.inner_area();
26 let scroll_axis = self.last_scroll_axis();
27
28 let point_in_rect = |rect: ratatui_core::layout::Rect, x: u16, y: u16| {
29 x >= rect.left() && x < rect.right() && y >= rect.top() && y < rect.bottom()
30 };
31
32 if !point_in_rect(inner_area, mouse_x, mouse_y) {
33 return None;
34 }
35
36 let cross_axis_size = match scroll_axis {
37 crate::ScrollAxis::Vertical => inner_area.width,
38 crate::ScrollAxis::Horizontal => inner_area.height,
39 };
40 let (mut scroll_axis_pos, cross_axis_pos) = match scroll_axis {
41 crate::ScrollAxis::Vertical => (inner_area.top(), inner_area.left()),
42 crate::ScrollAxis::Horizontal => (inner_area.left(), inner_area.top()),
43 };
44
45 let start_index = self.scroll_offset_index();
46 let mut index = start_index;
47
48 loop {
49 let Some(visible_main_axis_size) = sizes.get(&index).copied() else {
50 break;
51 };
52
53 let rect = match scroll_axis {
54 crate::ScrollAxis::Vertical => Rect::new(
55 cross_axis_pos,
56 scroll_axis_pos,
57 cross_axis_size,
58 visible_main_axis_size,
59 ),
60 crate::ScrollAxis::Horizontal => Rect::new(
61 scroll_axis_pos,
62 cross_axis_pos,
63 visible_main_axis_size,
64 cross_axis_size,
65 ),
66 };
67
68 if point_in_rect(rect, mouse_x, mouse_y) {
69 return Some(Hit::Item(index));
70 }
71
72 scroll_axis_pos = scroll_axis_pos.saturating_add(visible_main_axis_size);
73 index += 1;
74 }
75
76 Some(Hit::Area)
77 }
78}
79
80#[cfg(test)]
81mod tests {
82 use crate::hit_test::Hit;
83 use crate::{ListBuilder, ListState, ListView, ScrollAxis};
84 use ratatui::buffer::Buffer;
85 use ratatui::prelude::{Rect, StatefulWidget, Style};
86 use ratatui::text::{Line, Span};
87 use ratatui::widgets::Paragraph;
88
89 fn build_list(
90 item_count: usize,
91 ) -> (
92 Rect,
93 Buffer,
94 ListView<'static, Paragraph<'static>>,
95 ListState,
96 ) {
97 let builder = ListBuilder::new(|context| {
99 let text = format!("Item {0}", context.index);
100 let mut item = Line::from(text);
101
102 if context.index % 2 == 0 {
103 item.style = Style::default();
104 } else {
105 item.style = Style::default();
106 };
107
108 if context.is_selected {
109 let mut spans = item.spans;
110 spans.insert(0, Span::from(">>"));
111 item = Line::from(spans);
112 };
113
114 let style = item.style;
115 let lines = vec![item, Line::from(""), Line::from("")];
116 let paragraph = Paragraph::new(lines).style(style);
117 (paragraph, 3)
118 });
119
120 let area = Rect::new(0, 0, 5, (item_count as u16) * 3);
121 let buf = Buffer::empty(area);
122 let list = ListView::new(builder, item_count).scroll_axis(ScrollAxis::Vertical);
123 let state = ListState::default();
124 (area, buf, list, state)
125 }
126
127 #[test]
128 fn hit_test_points_in_each_visible_item() {
129 let (area, mut buf, list, mut state) = build_list(3);
131 list.render(area, &mut buf, &mut state);
132
133 let sizes = state.visible_main_axis_sizes().clone();
134 let mut scroll_pos = state.inner_area().top();
135 let cross_pos = state.inner_area().left();
136 let cross_size = state.inner_area().width;
137
138 let mut expected_index = state.scroll_offset_index();
139 while let Some(visible) = sizes.get(&expected_index) {
140 let mid_y = scroll_pos.saturating_add(visible / 2);
142 let mid_x = cross_pos.saturating_add(cross_size / 2);
143 assert_eq!(
144 state.hit_test(mid_x, mid_y),
145 Some(Hit::Item(expected_index))
146 );
147 scroll_pos = scroll_pos.saturating_add(*visible);
148 expected_index += 1;
149 }
150 }
151
152 #[test]
153 fn hit_test_respects_inner_area_offset() {
154 let (_, mut buf, list, mut state) = build_list(3);
156 let area = Rect::new(10, 5, 5, 9);
157 list.render(area, &mut buf, &mut state);
158
159 let inner = state.inner_area();
160 let sizes = state.visible_main_axis_sizes().clone();
161
162 let first_visible = sizes
163 .get(&state.scroll_offset_index())
164 .copied()
165 .unwrap_or(0);
166 let mid_y = inner.top() + first_visible / 2;
167 let mid_x = inner.left() + inner.width / 2;
168 assert_eq!(
169 state.hit_test(mid_x, mid_y),
170 Some(Hit::Item(state.scroll_offset_index()))
171 );
172 }
173
174 #[test]
175 fn hit_test_with_truncated_first_item() {
176 let (area, mut buf, list, mut state) = build_list(3);
178 state.select(Some(2));
179 list.render(
180 Rect::new(area.left(), area.top(), area.width, 8),
181 &mut buf,
182 &mut state,
183 );
184
185 let inner = state.inner_area();
186 let sizes = state.visible_main_axis_sizes().clone();
187
188 let mut scroll_pos = inner.top();
189 let mut index = state.scroll_offset_index();
190 while let Some(visible) = sizes.get(&index) {
191 let mid_y = scroll_pos.saturating_add(visible / 2);
192 let mid_x = inner.left() + inner.width / 2;
193 assert_eq!(state.hit_test(mid_x, mid_y), Some(Hit::Item(index)));
194 scroll_pos = scroll_pos.saturating_add(*visible);
195 index += 1;
196 }
197 }
198}