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 while let Some(visible_main_axis_size) = sizes.get(&index).copied() {
49 let rect = scroll_axis.rect(
50 scroll_axis_pos,
51 cross_axis_pos,
52 visible_main_axis_size,
53 cross_axis_size,
54 );
55
56 if point_in_rect(rect, mouse_x, mouse_y) {
57 return Some(Hit::Item(index));
58 }
59
60 scroll_axis_pos = scroll_axis_pos.saturating_add(visible_main_axis_size);
61 index += 1;
62 }
63
64 Some(Hit::Area)
65 }
66}
67
68#[cfg(test)]
69mod tests {
70 use crate::hit_test::Hit;
71 use crate::{ListBuilder, ListState, ListView, ScrollAxis};
72 use ratatui::buffer::Buffer;
73 use ratatui::prelude::{Rect, StatefulWidget, Style};
74 use ratatui::text::{Line, Span};
75 use ratatui::widgets::Paragraph;
76
77 fn build_list(
78 item_count: usize,
79 ) -> (
80 Rect,
81 Buffer,
82 ListView<'static, Paragraph<'static>>,
83 ListState,
84 ) {
85 let builder = ListBuilder::new(|context| {
87 let text = format!("Item {0}", context.index);
88 let mut item = Line::from(text);
89
90 if context.index % 2 == 0 {
91 item.style = Style::default();
92 } else {
93 item.style = Style::default();
94 };
95
96 if context.is_selected {
97 let mut spans = item.spans;
98 spans.insert(0, Span::from(">>"));
99 item = Line::from(spans);
100 };
101
102 let style = item.style;
103 let lines = vec![item, Line::from(""), Line::from("")];
104 let paragraph = Paragraph::new(lines).style(style);
105 (paragraph, 3)
106 });
107
108 let area = Rect::new(0, 0, 5, (item_count as u16) * 3);
109 let buf = Buffer::empty(area);
110 let list = ListView::new(builder, item_count).scroll_axis(ScrollAxis::Vertical);
111 let state = ListState::default();
112 (area, buf, list, state)
113 }
114
115 #[test]
116 fn hit_test_points_in_each_visible_item() {
117 let (area, mut buf, list, mut state) = build_list(3);
119 list.render(area, &mut buf, &mut state);
120
121 let sizes = state.visible_main_axis_sizes().clone();
122 let mut scroll_pos = state.inner_area().top();
123 let cross_pos = state.inner_area().left();
124 let cross_size = state.inner_area().width;
125
126 let mut expected_index = state.scroll_offset_index();
127 while let Some(visible) = sizes.get(&expected_index) {
128 let mid_y = scroll_pos.saturating_add(visible / 2);
130 let mid_x = cross_pos.saturating_add(cross_size / 2);
131 assert_eq!(
132 state.hit_test(mid_x, mid_y),
133 Some(Hit::Item(expected_index))
134 );
135 scroll_pos = scroll_pos.saturating_add(*visible);
136 expected_index += 1;
137 }
138 }
139
140 #[test]
141 fn hit_test_respects_inner_area_offset() {
142 let (_, mut buf, list, mut state) = build_list(3);
144 let area = Rect::new(10, 5, 5, 9);
145 list.render(area, &mut buf, &mut state);
146
147 let inner = state.inner_area();
148 let sizes = state.visible_main_axis_sizes().clone();
149
150 let first_visible = sizes
151 .get(&state.scroll_offset_index())
152 .copied()
153 .unwrap_or(0);
154 let mid_y = inner.top() + first_visible / 2;
155 let mid_x = inner.left() + inner.width / 2;
156 assert_eq!(
157 state.hit_test(mid_x, mid_y),
158 Some(Hit::Item(state.scroll_offset_index()))
159 );
160 }
161
162 #[test]
163 fn hit_test_with_truncated_first_item() {
164 let (area, mut buf, list, mut state) = build_list(3);
166 state.select(Some(2));
167 list.render(
168 Rect::new(area.left(), area.top(), area.width, 8),
169 &mut buf,
170 &mut state,
171 );
172
173 let inner = state.inner_area();
174 let sizes = state.visible_main_axis_sizes().clone();
175
176 let mut scroll_pos = inner.top();
177 let mut index = state.scroll_offset_index();
178 while let Some(visible) = sizes.get(&index) {
179 let mid_y = scroll_pos.saturating_add(visible / 2);
180 let mid_x = inner.left() + inner.width / 2;
181 assert_eq!(state.hit_test(mid_x, mid_y), Some(Hit::Item(index)));
182 scroll_pos = scroll_pos.saturating_add(*visible);
183 index += 1;
184 }
185 }
186}