Skip to main content

tui_widget_list/
hit_test.rs

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