retroglyph_widgets/widget/
list.rs1use retroglyph_core::{Backend, Color, Rect, Style, Terminal};
3
4use super::StatefulWidget;
5use super::window::visible_window;
6use crate::ListState;
7use crate::Theme;
8use crate::draw::fill_rect;
9use crate::text::truncate as truncate_to_cols;
10
11#[derive(Clone, Copy, Debug)]
28pub struct List<'a> {
29 items: &'a [&'a str],
30 item_style: Style,
31 selected_style: Style,
32}
33
34impl<'a> List<'a> {
35 #[must_use]
37 pub fn new(items: &'a [&'a str]) -> Self {
38 Self {
39 items,
40 item_style: Style::new().fg(Color::Rgb {
41 r: 170,
42 g: 175,
43 b: 190,
44 }),
45 selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
46 r: 40,
47 g: 60,
48 b: 90,
49 }),
50 }
51 }
52
53 #[must_use]
55 pub const fn item_style(mut self, style: Style) -> Self {
56 self.item_style = style;
57 self
58 }
59
60 #[must_use]
62 pub const fn selected_style(mut self, style: Style) -> Self {
63 self.selected_style = style;
64 self
65 }
66
67 #[must_use]
81 pub fn theme(mut self, theme: Theme) -> Self {
82 self.item_style = Style::new().fg(theme.fg).bg(theme.panel_bg);
83 self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
84 self
85 }
86}
87
88impl<B: Backend> StatefulWidget<B> for List<'_> {
89 type State = ListState;
90
91 fn render(self, area: Rect, term: &mut Terminal<B>, state: &mut Self::State) {
92 if area.width() == 0 || area.height() == 0 {
93 return;
94 }
95
96 let visible_items = area.height_usize();
97 let selected = state.selected();
98 for (item_index, &item) in visible_window(self.items, state.offset(), visible_items) {
99 let y = area.top() + (item_index - state.offset()) as u16;
100 let style = if Some(item_index) == selected {
101 fill_rect(
102 term,
103 Rect::new(area.left(), y, area.width(), 1),
104 ' ',
105 Style::new().bg(self.selected_style.background()),
106 );
107 self.selected_style
108 } else {
109 self.item_style
110 };
111 let text = truncate_to_cols(item, area.width_usize());
112 term.reset_style()
113 .fg(style.foreground())
114 .bg(style.background());
115 term.print(area.left(), y, &text);
116 }
117 term.reset_style();
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use retroglyph_core::Headless;
124
125 use super::*;
126
127 #[test]
128 fn list_widget_highlights_the_selected_item() {
129 let area = Rect::new(0, 0, 20, 2);
130 let items = ["Alpha", "Bravo"];
131 let list = List::new(&items);
132
133 let mut term = Terminal::new(Headless::new(20, 2));
134 let mut state = ListState::new();
135 state.select(Some(1));
136 list.render(area, &mut term, &mut state);
137
138 let highlighted_bg = term.grid().get(0, 1).style().background();
139 let plain_bg = term.grid().get(0, 0).style().background();
140 assert_ne!(highlighted_bg, plain_bg);
141 }
142
143 #[test]
144 fn list_widget_highlights_nothing_when_unselected() {
145 let area = Rect::new(0, 0, 20, 2);
146 let items = ["Alpha", "Bravo"];
147 let list = List::new(&items);
148
149 let mut term = Terminal::new(Headless::new(20, 2));
150 let mut state = ListState::new();
151 list.render(area, &mut term, &mut state);
152
153 let row0_bg = term.grid().get(0, 0).style().background();
154 let row1_bg = term.grid().get(0, 1).style().background();
155 assert_eq!(row0_bg, row1_bg);
156 }
157
158 fn items<'a>(names: &[&'a str]) -> Vec<&'a str> {
159 names.to_vec()
160 }
161
162 #[test]
163 fn scroll_offset_renders_the_window_starting_at_offset() {
164 let area = Rect::new(0, 0, 20, 2);
165 let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
166 let list = List::new(&names);
167
168 let mut term = Terminal::new(Headless::new(20, 2));
169 let mut state = ListState::new();
170 state.set_offset(2); list.render(area, &mut term, &mut state);
172
173 assert_eq!(term.grid().get(0, 0).glyph(), 'C');
174 assert_eq!(term.grid().get(0, 1).glyph(), 'D');
175 }
176
177 #[test]
178 fn selection_scrolled_out_of_view_highlights_nothing() {
179 let area = Rect::new(0, 0, 20, 2);
180 let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
181 let list = List::new(&names);
182
183 let mut term = Terminal::new(Headless::new(20, 2));
184 let mut state = ListState::new();
185 state.select(Some(0)); state.set_offset(2); list.render(area, &mut term, &mut state);
188
189 let row0_bg = term.grid().get(0, 0).style().background();
190 let row1_bg = term.grid().get(0, 1).style().background();
191 assert_eq!(row0_bg, row1_bg); }
193
194 #[test]
195 fn item_style_can_be_overridden() {
196 let area = Rect::new(0, 0, 20, 1);
197 let items = ["Alpha"];
198 let custom = Style::new().fg(Color::RED);
199 let list = List::new(&items).item_style(custom);
200
201 let mut term = Terminal::new(Headless::new(20, 1));
202 let mut state = ListState::new();
203 list.render(area, &mut term, &mut state);
204
205 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
206 }
207
208 #[test]
209 fn selected_style_can_be_overridden() {
210 let area = Rect::new(0, 0, 20, 1);
211 let items = ["Alpha"];
212 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
213 let list = List::new(&items).selected_style(custom);
214
215 let mut term = Terminal::new(Headless::new(20, 1));
216 let mut state = ListState::new();
217 state.select(Some(0));
218 list.render(area, &mut term, &mut state);
219
220 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::GREEN);
221 assert_eq!(term.grid().get(0, 0).style().background(), Color::BLUE);
222 }
223
224 #[test]
225 fn clips_long_items_to_area_width() {
226 let area = Rect::new(0, 0, 5, 1);
227 let items = ["a much longer item than fits"];
228 let list = List::new(&items);
229
230 let mut term = Terminal::new(Headless::new(5, 1));
231 let mut state = ListState::new();
232 list.render(area, &mut term, &mut state);
233
234 assert_eq!(term.grid().get(4, 0).glyph(), 'c'); }
236
237 #[test]
238 fn ensure_visible_before_render_keeps_selection_on_screen() {
239 let area = Rect::new(0, 0, 20, 2); let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
241 let list = List::new(&names);
242
243 let mut term = Terminal::new(Headless::new(20, 2));
244 let mut state = ListState::new();
245 state.select(Some(3)); state.ensure_visible(2);
247 list.render(area, &mut term, &mut state);
248
249 assert_eq!(term.grid().get(0, 1).glyph(), 'D');
250 let highlighted_bg = term.grid().get(0, 1).style().background();
251 let plain_bg = term.grid().get(0, 0).style().background();
252 assert_ne!(highlighted_bg, plain_bg);
253 }
254
255 #[test]
256 fn zero_height_is_a_no_op() {
257 let area = Rect::new(0, 0, 20, 0);
258 let items = ["Alpha"];
259 let list = List::new(&items);
260
261 let mut term = Terminal::new(Headless::new(20, 1));
262 let mut state = ListState::new();
263 list.render(area, &mut term, &mut state);
264
265 assert_eq!(term.grid().get(0, 0).glyph(), ' ');
266 }
267
268 #[test]
269 fn theme_maps_named_roles_onto_item_and_selected_styles() {
270 let area = Rect::new(0, 0, 20, 2);
271 let items = ["Alpha", "Bravo"];
272 let list = List::new(&items).theme(Theme::DARK);
273
274 let mut term = Terminal::new(Headless::new(20, 2));
275 let mut state = ListState::new();
276 state.select(Some(1));
277 list.render(area, &mut term, &mut state);
278
279 assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.fg);
280 assert_eq!(
281 term.grid().get(0, 0).style().background(),
282 Theme::DARK.panel_bg
283 );
284 assert_eq!(term.grid().get(0, 1).style().foreground(), Theme::DARK.bg);
285 assert_eq!(
286 term.grid().get(0, 1).style().background(),
287 Theme::DARK.accent
288 );
289 }
290}