1 use std::{ 2 cmp::Ordering, 3 io::{stdout, Write}, 4 }; 5 6 use crossterm::{ 7 cursor::MoveTo, 8 queue, 9 style::{Color, SetBackgroundColor}, 10 terminal::{Clear, ClearType}, 11 }; 12 13 use crate::{ 14 compute_scrollbar, errors::Result, gray, Alignment, Area, CompoundStyle, MadSkin, Spacing, 15 }; 16 17 pub struct ListViewCell<'t> { 18 con: String, 19 style: &'t CompoundStyle, 20 width: usize, // length of content in chars 21 } 22 23 pub struct Title { 24 columns: Vec<usize>, // the column(s) below this title 25 } 26 27 pub struct ListViewColumn<'t, T> { 28 title: String, 29 min_width: usize, 30 max_width: usize, 31 spacing: Spacing, 32 extract: Box<dyn Fn(&T) -> ListViewCell<'t>>, // a function building cells from the rows 33 } 34 35 struct Row<T> { 36 data: T, 37 displayed: bool, 38 } 39 40 /// A filterable list whose columns can be automatically resized. 41 /// 42 /// 43 /// Notes: 44 /// * another version will allow more than one style per cell 45 /// (i.e. make the cells composites rather than compounds). Shout 46 /// out if you need that now. 47 /// * this version doesn't allow cell wrapping 48 pub struct ListView<'t, T> { 49 titles: Vec<Title>, 50 columns: Vec<ListViewColumn<'t, T>>, 51 rows: Vec<Row<T>>, 52 pub area: Area, 53 scroll: usize, 54 pub skin: &'t MadSkin, 55 filter: Option<Box<dyn Fn(&T) -> bool>>, // a function determining if the row must be displayed 56 displayed_rows_count: usize, 57 row_order: Option<Box<dyn Fn(&T, &T) -> Ordering>>, 58 selection: Option<usize>, // index of the selected line 59 selection_background: Color, 60 } 61 62 impl<'t> ListViewCell<'t> { new(con: String, style: &'t CompoundStyle) -> Self63 pub fn new(con: String, style: &'t CompoundStyle) -> Self { 64 let width = con.chars().count(); 65 Self { con, style, width } 66 } 67 } 68 69 impl<'t, T> ListViewColumn<'t, T> { new( title: &str, min_width: usize, max_width: usize, extract: Box<dyn Fn(&T) -> ListViewCell<'t>>, ) -> Self70 pub fn new( 71 title: &str, 72 min_width: usize, 73 max_width: usize, 74 extract: Box<dyn Fn(&T) -> ListViewCell<'t>>, 75 ) -> Self { 76 Self { 77 title: title.to_owned(), 78 min_width, 79 max_width, 80 spacing: Spacing { 81 width: min_width, 82 align: Alignment::Center, 83 }, 84 extract, 85 } 86 } with_align(mut self, align: Alignment) -> Self87 pub const fn with_align(mut self, align: Alignment) -> Self { 88 self.spacing.align = align; 89 self 90 } 91 } 92 93 impl<'t, T> ListView<'t, T> { 94 /// Create a new list view with the passed columns. 95 /// 96 /// The columns can't be changed afterwards but the area can be modified. 97 /// When two columns have the same title, those titles are merged (but 98 /// the columns below stay separated). new(area: Area, columns: Vec<ListViewColumn<'t, T>>, skin: &'t MadSkin) -> Self99 pub fn new(area: Area, columns: Vec<ListViewColumn<'t, T>>, skin: &'t MadSkin) -> Self { 100 let mut titles: Vec<Title> = Vec::new(); 101 for (column_idx, column) in columns.iter().enumerate() { 102 if let Some(last_title) = titles.last_mut() { 103 if columns[last_title.columns[0]].title == column.title { 104 // we merge those columns titles 105 last_title.columns.push(column_idx); 106 continue; 107 } 108 } 109 // this is a new title 110 titles.push(Title { 111 columns: vec![column_idx], 112 }); 113 } 114 Self { 115 titles, 116 columns, 117 rows: Vec::new(), 118 area, 119 scroll: 0, 120 skin, 121 filter: None, 122 displayed_rows_count: 0, 123 row_order: None, 124 selection: None, 125 selection_background: gray(5), 126 } 127 } 128 /// set a comparator for row sorting sort(&mut self, sort: Box<dyn Fn(&T, &T) -> Ordering>)129 pub fn sort(&mut self, sort: Box<dyn Fn(&T, &T) -> Ordering>) { 130 self.row_order = Some(sort); 131 } 132 /// return the height which is available for rows 133 #[inline(always)] tbody_height(&self) -> u16134 pub const fn tbody_height(&self) -> u16 { 135 if self.area.height > 2 { 136 self.area.height - 2 137 } else { 138 self.area.height 139 } 140 } 141 /// return an option which when filled contains 142 /// a tupple with the top and bottom of the vertical 143 /// scrollbar. Return none when the content fits 144 /// the available space. 145 #[inline(always)] scrollbar(&self) -> Option<(u16, u16)>146 pub fn scrollbar(&self) -> Option<(u16, u16)> { 147 compute_scrollbar( 148 self.scroll as u16, 149 self.displayed_rows_count as u16, 150 self.tbody_height(), 151 self.area.top, 152 ) 153 } add_row(&mut self, data: T)154 pub fn add_row(&mut self, data: T) { 155 let stick_to_bottom = self.row_order.is_none() && self.do_scroll_show_bottom(); 156 let displayed = match &self.filter { 157 Some(fun) => fun(&data), 158 None => true, 159 }; 160 if displayed { 161 self.displayed_rows_count += 1; 162 } 163 if stick_to_bottom { 164 self.scroll_to_bottom(); 165 } 166 self.rows.push(Row { data, displayed }); 167 if let Some(row_order) = &self.row_order { 168 self.rows.sort_by(|a, b| row_order(&a.data, &b.data)); 169 } 170 } 171 /// remove all rows (and selection). 172 /// 173 /// Keep the columns and the sort function, if any. clear_rows(&mut self)174 pub fn clear_rows(&mut self) { 175 self.rows.clear(); 176 self.scroll = 0; 177 self.displayed_rows_count = 0; 178 self.selection = None; 179 } 180 /// return both the number of displayed rows and the total number row_counts(&self) -> (usize, usize)181 pub fn row_counts(&self) -> (usize, usize) { 182 (self.displayed_rows_count, self.rows.len()) 183 } 184 /// recompute the widths of all columns. 185 /// This should be called when the area size is modified update_dimensions(&mut self)186 pub fn update_dimensions(&mut self) { 187 let available_width: i32 = 188 i32::from(self.area.width) 189 - (self.columns.len() as i32 - 1) // we remove the separator 190 - 1; // we remove 1 to let space for the scrollbar 191 let sum_min_widths: i32 = self.columns.iter().map(|c| c.min_width as i32).sum(); 192 if sum_min_widths >= available_width { 193 for i in 0..self.columns.len() { 194 self.columns[i].spacing.width = self.columns[i].min_width; 195 } 196 } else { 197 let mut excess = available_width - sum_min_widths; 198 for i in 0..self.columns.len() { 199 let d = 200 ((self.columns[i].max_width - self.columns[i].min_width) as i32).min(excess); 201 excess -= d; 202 self.columns[i].spacing.width = self.columns[i].min_width + d as usize; 203 } 204 // there might be some excess, but it's better to have some space at right rather 205 // than a too wide table 206 } 207 } set_filter(&mut self, filter: Box<dyn Fn(&T) -> bool>)208 pub fn set_filter(&mut self, filter: Box<dyn Fn(&T) -> bool>) { 209 let mut count = 0; 210 for row in self.rows.iter_mut() { 211 row.displayed = filter(&row.data); 212 if row.displayed { 213 count += 1; 214 } 215 } 216 self.scroll = 0; // something better should be done... later 217 self.displayed_rows_count = count; 218 self.filter = Some(filter); 219 } remove_filter(&mut self)220 pub fn remove_filter(&mut self) { 221 for row in self.rows.iter_mut() { 222 row.displayed = true; 223 } 224 self.displayed_rows_count = self.rows.len(); 225 self.filter = None; 226 } 227 /// write the list view on the given writer write_on<W>(&self, w: &mut W) -> Result<()> where W: std::io::Write,228 pub fn write_on<W>(&self, w: &mut W) -> Result<()> 229 where 230 W: std::io::Write, 231 { 232 let sx = self.area.left + self.area.width; 233 let vbar = self.skin.table.compound_style.style_char('│'); 234 let tee = self.skin.table.compound_style.style_char('┬'); 235 let cross = self.skin.table.compound_style.style_char('┼'); 236 let hbar = self.skin.table.compound_style.style_char('─'); 237 // title line 238 queue!(w, MoveTo(self.area.left, self.area.top))?; 239 for (title_idx, title) in self.titles.iter().enumerate() { 240 if title_idx != 0 { 241 vbar.queue(w)?; 242 } 243 let width = title 244 .columns 245 .iter() 246 .map(|ci| self.columns[*ci].spacing.width) 247 .sum::<usize>() 248 + title.columns.len() 249 - 1; 250 let spacing = Spacing { 251 width, 252 align: Alignment::Center, 253 }; 254 spacing.write_str( 255 w, 256 &self.columns[title.columns[0]].title, 257 &self.skin.headers[0].compound_style, 258 )?; 259 } 260 // separator line 261 queue!(w, MoveTo(self.area.left, self.area.top + 1))?; 262 for (title_idx, title) in self.titles.iter().enumerate() { 263 if title_idx != 0 { 264 cross.queue(w)?; 265 } 266 for (col_idx_idx, col_idx) in title.columns.iter().enumerate() { 267 if col_idx_idx > 0 { 268 tee.queue(w)?; 269 } 270 for _ in 0..self.columns[*col_idx].spacing.width { 271 hbar.queue(w)?; 272 } 273 } 274 } 275 // rows, maybe scrolled 276 let mut row_idx = self.scroll as usize; 277 let scrollbar = self.scrollbar(); 278 for y in 2..self.area.height { 279 queue!(w, MoveTo(self.area.left, self.area.top + y))?; 280 loop { 281 if row_idx == self.rows.len() { 282 queue!(w, Clear(ClearType::UntilNewLine))?; 283 break; 284 } 285 if self.rows[row_idx].displayed { 286 let selected = Some(row_idx) == self.selection; 287 for (col_idx, col) in self.columns.iter().enumerate() { 288 if col_idx != 0 { 289 if selected { 290 queue!(w, SetBackgroundColor(self.selection_background))?; 291 } 292 vbar.queue(w)?; 293 } 294 let cell = (col.extract)(&self.rows[row_idx].data); 295 if selected { 296 let mut style = cell.style.clone(); 297 style.set_bg(self.selection_background); 298 col.spacing 299 .write_counted_str(w, &cell.con, cell.width, &style)?; 300 } else { 301 col.spacing 302 .write_counted_str(w, &cell.con, cell.width, cell.style)?; 303 } 304 } 305 row_idx += 1; 306 break; 307 } 308 row_idx += 1; 309 } 310 if let Some((sctop, scbottom)) = scrollbar { 311 queue!(w, MoveTo(sx, self.area.top + y))?; 312 let y = y - 2; 313 if sctop <= y && y <= scbottom { 314 self.skin.scrollbar.thumb.queue(w)?; 315 } else { 316 self.skin.scrollbar.track.queue(w)?; 317 } 318 } 319 } 320 Ok(()) 321 } 322 /// display the whole list in its area write(&self) -> Result<()>323 pub fn write(&self) -> Result<()> { 324 let mut stdout = stdout(); 325 self.write_on(&mut stdout)?; 326 stdout.flush()?; 327 Ok(()) 328 } 329 /// return true if the last line of the list is visible do_scroll_show_bottom(&self) -> bool330 pub const fn do_scroll_show_bottom(&self) -> bool { 331 self.scroll + self.tbody_height() as usize >= self.displayed_rows_count 332 } 333 /// ensure the last line is visible scroll_to_bottom(&mut self)334 pub fn scroll_to_bottom(&mut self) { 335 let body_height = self.tbody_height() as usize; 336 self.scroll = if self.displayed_rows_count > body_height { 337 self.displayed_rows_count - body_height 338 } else { 339 0 340 } 341 } 342 /// set the scroll amount. 343 /// lines_count can be negative try_scroll_lines(&mut self, lines_count: i32)344 pub fn try_scroll_lines(&mut self, lines_count: i32) { 345 if lines_count < 0 { 346 let lines_count = -lines_count as usize; 347 self.scroll = if lines_count >= self.scroll { 348 0 349 } else { 350 self.scroll - lines_count 351 }; 352 } else { 353 self.scroll = (self.scroll + lines_count as usize) 354 .min(self.displayed_rows_count - self.tbody_height() as usize + 1); 355 } 356 self.make_selection_visible(); 357 } 358 /// set the scroll amount. 359 /// pages_count can be negative try_scroll_pages(&mut self, pages_count: i32)360 pub fn try_scroll_pages(&mut self, pages_count: i32) { 361 self.try_scroll_lines(pages_count * self.tbody_height() as i32) 362 } 363 /// try to select the next visible line try_select_next(&mut self, up: bool)364 pub fn try_select_next(&mut self, up: bool) { 365 if self.displayed_rows_count == 0 { 366 return; 367 } 368 if self.displayed_rows_count == 1 || self.selection.is_none() { 369 for i in 0..self.rows.len() { 370 let i = (i + self.scroll as usize) % self.rows.len(); 371 if self.rows[i].displayed { 372 self.selection = Some(i); 373 self.make_selection_visible(); 374 return; 375 } 376 } 377 } 378 for i in 0..self.rows.len() { 379 let delta_idx = if up { self.rows.len() - 1 - i } else { i + 1 }; 380 let row_idx = (delta_idx + self.selection.unwrap()) % self.rows.len(); 381 if self.rows[row_idx].displayed { 382 self.selection = Some(row_idx); 383 self.make_selection_visible(); 384 return; 385 } 386 } 387 } 388 /// select the first visible line (unless there's nothing). select_first_line(&mut self)389 pub fn select_first_line(&mut self) { 390 for i in 0..self.rows.len() { 391 if self.rows[i].displayed { 392 self.selection = Some(i); 393 self.make_selection_visible(); 394 return; 395 } 396 } 397 self.selection = None; 398 } 399 /// select the last visible line (unless there's nothing). select_last_line(&mut self)400 pub fn select_last_line(&mut self) { 401 for i in (0..self.rows.len()).rev() { 402 if self.rows[i].displayed { 403 self.selection = Some(i); 404 self.make_selection_visible(); 405 return; 406 } 407 } 408 self.selection = None; 409 } 410 /// scroll to ensure the selected line (if any) is visible. 411 /// 412 /// This is automatically called by try_scroll 413 /// and try select functions make_selection_visible(&mut self)414 pub fn make_selection_visible(&mut self) { 415 let tbody_height = self.tbody_height() as usize; 416 if self.displayed_rows_count <= tbody_height { 417 return; // there's no scroll 418 } 419 if let Some(sel) = self.selection { 420 if sel <= self.scroll { 421 self.scroll = if sel > 2 { sel - 2 } else { 0 }; 422 } else if sel + 1 >= self.scroll + tbody_height { 423 self.scroll = sel - tbody_height + 2; 424 } 425 } 426 } get_selection(&self) -> Option<&T>427 pub fn get_selection(&self) -> Option<&T> { 428 self.selection.map(|sel| &self.rows[sel].data) 429 } has_selection(&self) -> bool430 pub const fn has_selection(&self) -> bool { 431 self.selection.is_some() 432 } unselect(&mut self)433 pub fn unselect(&mut self) { 434 self.selection = None; 435 } 436 } 437