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        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        // Build a list with items of height 3 lines, vertical scrolling
90        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        // given: 3 items, height 3 each
122        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            // middle point within the item's rect
133            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        // given: render not at origin
147        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        // given: area height 8, select last element so the first visible item is truncated
169        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}