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