1use std::{error::Error, fmt::Display};
2
3use ratatui::{
4 layout::Rect,
5 style::Style,
6 widgets::{Block, BorderType, Borders, Paragraph, Widget},
7};
8use unicode_segmentation::UnicodeSegmentation;
9
10#[derive(Clone)]
20pub struct TextList {
21 pub items: Vec<String>,
23 pub selected: usize,
25 pub scroll: usize,
28 pub style: Style,
30 pub cursor_style: Style,
32 pub selected_style: Style,
34 pub border_type: BorderType,
36 pub height: Option<u16>,
38 pub ascii_only: bool,
40 pub non_ascii_replace: char,
42 pub trim_type: TrimType,
44}
45
46impl TextList {
48 pub fn update(&mut self) -> Result<(), TextListError> {
51 let height = if let Some(h) = self.height {
52 h as i32 - 2
53 } else {
54 return Err(TextListError::UnknownHeight);
55 };
56
57 if height <= 0 {
58 return Err(TextListError::NotEnoughHeight);
59 }
60
61 if self.selected < self.scroll {
62 self.scroll = self.selected;
63 } else if self.scroll + height as usize <= self.selected {
64 self.scroll = self.selected - height as usize + 1;
65 }
66 Ok(())
67 }
68
69 pub fn up(&mut self) -> Result<(), TextListError> {
71 if self.selected != 0 {
72 self.selected -= 1;
73 self.update()?;
74 }
75 Ok(())
76 }
77
78 pub fn down(&mut self) -> Result<(), TextListError> {
80 if self.items.is_empty() {
81 return Ok(());
82 }
83
84 if self.selected < self.items.len() - 1 {
85 self.selected += 1;
86 self.update()?;
87 }
88 Ok(())
89 }
90
91 pub fn pageup(&mut self) -> Result<(), TextListError> {
93 let height = match self.height {
94 Some(h) => h as usize,
95 None => return Err(TextListError::UnknownHeight),
96 };
97
98 if self.selected == 0 {
99 return Ok(());
100 }
101
102 let shift_by = height - 2;
103
104 if self.selected < shift_by {
105 self.selected = 0;
106 } else {
107 self.selected -= shift_by;
108
109 if self.scroll > shift_by {
110 self.scroll -= shift_by;
111 } else {
112 self.scroll = 0;
113 }
114 }
115
116 self.update()?;
117
118 Ok(())
119 }
120
121 pub fn pagedown(&mut self) -> Result<(), TextListError> {
123 let height = match self.height {
124 Some(h) => h as usize,
125 None => return Err(TextListError::UnknownHeight),
126 };
127
128 if self.selected >= self.items.len() - 1 {
129 return Ok(());
130 }
131
132 let shift_by = height - 2;
133
134 if self.selected + shift_by > self.items.len() - 1 {
135 self.selected = self.items.len() - 1;
136 } else {
137 self.selected += shift_by;
138
139 if self.scroll + shift_by + height - 2 < self.items.len() {
140 self.scroll += shift_by;
141 } else {
142 self.scroll = self.items.len() - 1 - height + 2;
143 }
144 }
145
146 self.update()?;
147
148 Ok(())
149 }
150
151 pub fn first(&mut self) -> Result<(), TextListError> {
153 if self.selected == 0 {
154 return Ok(());
155 }
156
157 self.selected = 0;
158 self.update()?;
159 Ok(())
160 }
161
162 pub fn last(&mut self) -> Result<(), TextListError> {
164 if self.selected == self.items.len() - 1 {
165 return Ok(());
166 }
167
168 self.selected = self.items.len() - 1;
169 self.update()?;
170 Ok(())
171 }
172}
173
174impl TextList {
179 pub fn ascii_only(mut self, ascii_only: bool) -> Self {
180 self.set_ascii_only(ascii_only);
181 self
182 }
183
184 pub fn set_ascii_only(&mut self, ascii_only: bool) {
185 self.ascii_only = ascii_only;
186 }
187
188 pub fn border_type(mut self, border_type: BorderType) -> Self {
189 self.set_border_type(border_type);
190 self
191 }
192
193 pub fn set_border_type(&mut self, border_type: BorderType) {
194 self.border_type = border_type;
195 }
196
197 pub fn cursor_style(mut self, cursor_style: Style) -> Self {
198 self.set_cursor_style(cursor_style);
199 self
200 }
201
202 pub fn set_cursor_style(&mut self, cursor_style: Style) {
203 self.cursor_style = cursor_style;
204 }
205
206 pub fn height(mut self, height: u16) -> Self {
207 self.set_height(height);
208 self
209 }
210
211 pub fn set_height(&mut self, height: u16) {
212 self.height = Some(height);
213 }
214
215 pub fn items<D: Display>(mut self, items: &[D]) -> Result<Self, Box<dyn Error>> {
216 self.set_items(items)?;
217 Ok(self)
218 }
219
220 pub fn set_items<D: Display>(&mut self, items: &[D]) -> Result<(), Box<dyn Error>> {
221 self.items = items.iter().map(|item| format!("{}", item)).collect();
222 if self.height.is_some() {
223 self.update()?;
224 }
225 Ok(())
226 }
227
228 pub fn selected(mut self, index: usize) -> Result<Self, TextListError> {
229 self.set_selected(index)?;
230 Ok(self)
231 }
232
233 pub fn set_selected(&mut self, index: usize) -> Result<(), TextListError> {
234 self.selected = index;
235 self.update()?;
236 Ok(())
237 }
238
239 pub fn non_ascii_replace(mut self, non_ascii_replace: char) -> Self {
240 self.set_non_ascii_replace(non_ascii_replace);
241 self
242 }
243
244 pub fn set_non_ascii_replace(&mut self, non_ascii_replace: char) {
245 self.non_ascii_replace = non_ascii_replace;
246 }
247
248 pub fn selected_style(mut self, selected_style: Style) -> Self {
249 self.set_selected_style(selected_style);
250 self
251 }
252
253 pub fn set_selected_style(&mut self, selected_style: Style) {
254 self.selected_style = selected_style;
255 }
256
257 pub fn style(mut self, style: Style) -> Self {
258 self.set_style(style);
259 self
260 }
261
262 pub fn set_style(&mut self, style: Style) {
263 self.style = style;
264 }
265
266 pub fn trim_type(mut self, trim_type: TrimType) -> Self {
267 self.set_trim_type(trim_type);
268 self
269 }
270
271 pub fn set_trim_type(&mut self, trim_type: TrimType) {
272 self.trim_type = trim_type;
273 }
274}
275
276impl Default for TextList {
278 fn default() -> Self {
279 Self {
280 items: Vec::new(),
281 selected: 0,
282 scroll: 0,
283 style: Style::default(),
284 cursor_style: Style::default(),
285 selected_style: Style::default(),
286 border_type: BorderType::Plain,
287 height: None,
288 ascii_only: false,
289 non_ascii_replace: '?',
290 trim_type: TrimType::FullTripleDot,
291 }
292 }
293}
294
295impl Widget for TextList {
297 fn render(mut self, area: ratatui::layout::Rect, buf: &mut ratatui::buffer::Buffer) {
300 let height = self.height.expect("unknown height");
301 if height != area.height {
302 panic!("height mismatch");
303 }
304
305 if area.height < 3 {
306 return;
308 }
309
310 self.items = self
311 .items
312 .into_iter()
313 .skip(self.scroll)
314 .take(height as usize - 2)
315 .collect();
316
317 if self.ascii_only {
320 self.items.iter_mut().for_each(|item| {
321 *item = item
322 .chars()
323 .map(|c| {
324 if c.is_ascii() {
325 c
326 } else {
327 self.non_ascii_replace
328 }
329 })
330 .collect();
331 });
332 }
333
334 let width_from = area.width as usize - 2;
337 let (width_after, end_with) = match self.trim_type {
338 TrimType::None => (width_from, ""),
339 TrimType::FullTripleDot => (width_from - 3, "..."),
340 TrimType::ShortTripleDot => (width_from - 1, "…"),
341 };
342
343 if area.width as usize - 2 < end_with.chars().count() {
344 panic!("width too small");
345 }
346
347 self.items.iter_mut().for_each(|item| {
348 let chars = UnicodeSegmentation::graphemes(item.as_str(), true).collect::<Vec<_>>();
349 if chars.len() > width_from {
350 *item = format!(
351 "{}{}",
352 chars.into_iter().take(width_after).collect::<String>(),
353 end_with
354 );
355 }
356 });
357
358 buf.set_style(area, self.style);
361
362 let mut y = area.y;
365 self.items
366 .into_iter()
367 .zip(self.scroll..)
368 .for_each(|(item, index)| {
369 if index == self.selected {
370 let block = Block::default()
371 .border_type(self.border_type)
372 .border_style(self.cursor_style)
373 .borders(Borders::ALL);
374 let paragraph = Paragraph::new(item).style(self.selected_style).block(block);
375
376 let select_area = Rect {
377 x: area.x,
378 y,
379 height: 3,
380 width: area.width,
381 };
382
383 paragraph.render(select_area, buf);
384 y += 3;
385 } else {
386 buf.set_string(area.x + 1, y, item, Style::default());
387 y += 1;
388 }
389 })
390 }
391}
392
393#[derive(Debug)]
395pub enum TextListError {
396 UnknownHeight,
398 NotEnoughHeight,
400}
401
402impl Display for TextListError {
403 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
404 f.write_str(&format!("{:?}", self))
405 }
406}
407
408impl Error for TextListError {}
409
410#[derive(Debug, Clone, Copy)]
412pub enum TrimType {
413 ShortTripleDot,
415 FullTripleDot,
417 r#None,
419}