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)]
42pub struct List<'a> {
43 items: &'a [&'a str],
44 item_style: Style,
45 selected_style: Style,
46}
47
48impl<'a> List<'a> {
49 #[must_use]
51 pub fn new(items: &'a [&'a str]) -> Self {
52 Self {
53 items,
54 item_style: Style::new().fg(Color::Rgb {
55 r: 170,
56 g: 175,
57 b: 190,
58 }),
59 selected_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
60 r: 40,
61 g: 60,
62 b: 90,
63 }),
64 }
65 }
66
67 #[must_use]
69 pub const fn item_style(mut self, style: Style) -> Self {
70 self.item_style = style;
71 self
72 }
73
74 #[must_use]
76 pub const fn selected_style(mut self, style: Style) -> Self {
77 self.selected_style = style;
78 self
79 }
80
81 #[must_use]
95 pub fn theme(self, theme: Theme) -> Self {
96 self.theme_on(theme, theme.panel_bg)
97 }
98
99 #[must_use]
103 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
104 self.item_style = Style::new().fg(theme.fg).bg(bg);
105 self.selected_style = Style::new().fg(theme.bg).bg(theme.accent);
106 self
107 }
108}
109
110impl<B: Backend> StatefulWidget<B> for List<'_> {
111 type State = ListState;
112
113 fn render(self, area: Rect, term: &mut Terminal<B>, state: &mut Self::State) {
114 if area.width() == 0 || area.height() == 0 {
115 return;
116 }
117
118 let visible_items = area.height_usize();
119 let selected = state.selected();
120 for (item_index, &item) in visible_window(self.items, state.offset(), visible_items) {
121 let y = area.top() + (item_index - state.offset()) as u16;
122 let style = if Some(item_index) == selected {
123 fill_rect(
124 term,
125 Rect::new(area.left(), y, area.width(), 1),
126 ' ',
127 Style::new().bg(self.selected_style.background()),
128 );
129 self.selected_style
130 } else {
131 self.item_style
132 };
133 let text = truncate_to_cols(item, area.width_usize());
134 term.reset_style()
135 .fg(style.foreground())
136 .bg(style.background());
137 term.print(area.left(), y, text);
138 }
139 term.reset_style();
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use retroglyph_core::Headless;
146
147 use super::*;
148
149 #[test]
150 fn list_widget_highlights_the_selected_item() {
151 let area = Rect::new(0, 0, 20, 2);
152 let items = ["Alpha", "Bravo"];
153 let list = List::new(&items);
154
155 let mut term = Terminal::new(Headless::new(20, 2));
156 let mut state = ListState::new();
157 state.select(Some(1));
158 list.render(area, &mut term, &mut state);
159
160 let highlighted_bg = term.grid().get(0, 1).style().background();
161 let plain_bg = term.grid().get(0, 0).style().background();
162 assert_ne!(highlighted_bg, plain_bg);
163 }
164
165 #[test]
166 fn list_widget_highlights_nothing_when_unselected() {
167 let area = Rect::new(0, 0, 20, 2);
168 let items = ["Alpha", "Bravo"];
169 let list = List::new(&items);
170
171 let mut term = Terminal::new(Headless::new(20, 2));
172 let mut state = ListState::new();
173 list.render(area, &mut term, &mut state);
174
175 let row0_bg = term.grid().get(0, 0).style().background();
176 let row1_bg = term.grid().get(0, 1).style().background();
177 assert_eq!(row0_bg, row1_bg);
178 }
179
180 fn items<'a>(names: &[&'a str]) -> Vec<&'a str> {
181 names.to_vec()
182 }
183
184 #[test]
185 fn scroll_offset_renders_the_window_starting_at_offset() {
186 let area = Rect::new(0, 0, 20, 2);
187 let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
188 let list = List::new(&names);
189
190 let mut term = Terminal::new(Headless::new(20, 2));
191 let mut state = ListState::new();
192 state.set_offset(2); list.render(area, &mut term, &mut state);
194
195 assert_eq!(term.grid().get(0, 0).glyph(), 'C');
196 assert_eq!(term.grid().get(0, 1).glyph(), 'D');
197 }
198
199 #[test]
200 fn selection_scrolled_out_of_view_highlights_nothing() {
201 let area = Rect::new(0, 0, 20, 2);
202 let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
203 let list = List::new(&names);
204
205 let mut term = Terminal::new(Headless::new(20, 2));
206 let mut state = ListState::new();
207 state.select(Some(0)); state.set_offset(2); list.render(area, &mut term, &mut state);
210
211 let row0_bg = term.grid().get(0, 0).style().background();
212 let row1_bg = term.grid().get(0, 1).style().background();
213 assert_eq!(row0_bg, row1_bg); }
215
216 #[test]
217 fn item_style_can_be_overridden() {
218 let area = Rect::new(0, 0, 20, 1);
219 let items = ["Alpha"];
220 let custom = Style::new().fg(Color::RED);
221 let list = List::new(&items).item_style(custom);
222
223 let mut term = Terminal::new(Headless::new(20, 1));
224 let mut state = ListState::new();
225 list.render(area, &mut term, &mut state);
226
227 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::RED);
228 }
229
230 #[test]
231 fn selected_style_can_be_overridden() {
232 let area = Rect::new(0, 0, 20, 1);
233 let items = ["Alpha"];
234 let custom = Style::new().fg(Color::GREEN).bg(Color::BLUE);
235 let list = List::new(&items).selected_style(custom);
236
237 let mut term = Terminal::new(Headless::new(20, 1));
238 let mut state = ListState::new();
239 state.select(Some(0));
240 list.render(area, &mut term, &mut state);
241
242 assert_eq!(term.grid().get(0, 0).style().foreground(), Color::GREEN);
243 assert_eq!(term.grid().get(0, 0).style().background(), Color::BLUE);
244 }
245
246 #[test]
247 fn clips_long_items_to_area_width() {
248 let area = Rect::new(0, 0, 5, 1);
249 let items = ["a much longer item than fits"];
250 let list = List::new(&items);
251
252 let mut term = Terminal::new(Headless::new(5, 1));
253 let mut state = ListState::new();
254 list.render(area, &mut term, &mut state);
255
256 assert_eq!(term.grid().get(4, 0).glyph(), 'c'); }
258
259 #[test]
260 fn ensure_visible_before_render_keeps_selection_on_screen() {
261 let area = Rect::new(0, 0, 20, 2); let names = items(&["Alpha", "Bravo", "Charlie", "Delta"]);
263 let list = List::new(&names);
264
265 let mut term = Terminal::new(Headless::new(20, 2));
266 let mut state = ListState::new();
267 state.select(Some(3)); state.ensure_visible(2);
269 list.render(area, &mut term, &mut state);
270
271 assert_eq!(term.grid().get(0, 1).glyph(), 'D');
272 let highlighted_bg = term.grid().get(0, 1).style().background();
273 let plain_bg = term.grid().get(0, 0).style().background();
274 assert_ne!(highlighted_bg, plain_bg);
275 }
276
277 #[test]
278 fn zero_height_is_a_no_op() {
279 let area = Rect::new(0, 0, 20, 0);
280 let items = ["Alpha"];
281 let list = List::new(&items);
282
283 let mut term = Terminal::new(Headless::new(20, 1));
284 let mut state = ListState::new();
285 list.render(area, &mut term, &mut state);
286
287 assert_eq!(term.grid().get(0, 0).glyph(), ' ');
288 }
289
290 #[test]
291 fn theme_maps_named_roles_onto_item_and_selected_styles() {
292 let area = Rect::new(0, 0, 20, 2);
293 let items = ["Alpha", "Bravo"];
294 let list = List::new(&items).theme(Theme::DARK);
295
296 let mut term = Terminal::new(Headless::new(20, 2));
297 let mut state = ListState::new();
298 state.select(Some(1));
299 list.render(area, &mut term, &mut state);
300
301 assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.fg);
302 assert_eq!(
303 term.grid().get(0, 0).style().background(),
304 Theme::DARK.panel_bg
305 );
306 assert_eq!(term.grid().get(0, 1).style().foreground(), Theme::DARK.bg);
307 assert_eq!(
308 term.grid().get(0, 1).style().background(),
309 Theme::DARK.accent
310 );
311 }
312
313 #[test]
314 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
315 let area = Rect::new(0, 0, 20, 1);
316 let items = ["Alpha"];
317 let list = List::new(&items).theme_on(Theme::DARK, Color::Default);
318
319 let mut term = Terminal::new(Headless::new(20, 1));
320 let mut state = ListState::new();
321 list.render(area, &mut term, &mut state);
322
323 assert_eq!(term.grid().get(0, 0).style().foreground(), Theme::DARK.fg);
324 assert_eq!(term.grid().get(0, 0).style().background(), Color::Default);
325 }
326}