tui_widget_list/
hit_test.rs

1use crate::ListState;
2use ratatui_core::layout::Rect;
3
4/// Result of a hit-test within the list's inner area.
5#[derive(Debug, PartialEq)]
6pub enum Hit {
7    /// Area hit, but no item
8    Area,
9    /// Specific item hit at the given index
10    Item(usize),
11}
12
13impl ListState {
14    /// Hit-test using last rendered view self. Used for mouse click handling.
15    ///
16    /// Returns `Some(index)` if a visible item was hit, otherwise `None`.
17    #[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        // Build a list with items of height 3 lines, vertical scrolling
98        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        // given: 3 items, height 3 each
130        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            // middle point within the item's rect
141            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        // given: render not at origin
155        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        // given: area height 8, select last element so the first visible item is truncated
177        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}