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