/* * meli * * Copyright 2019 Manos Pitsidianakis * * This file is part of meli. * * meli is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * meli is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with meli. If not, see . */ use super::*; use crate::melib::text_processing::TextProcessing; use melib::CardId; use std::cmp; const MAX_COLS: usize = 500; #[derive(Debug, PartialEq)] enum ViewMode { List, View(ComponentId), } #[derive(Debug)] struct AccountMenuEntry { name: String, // Index in the config account vector. index: usize, } #[derive(Debug)] pub struct ContactList { accounts: Vec, cursor_pos: usize, new_cursor_pos: usize, account_pos: usize, length: usize, data_columns: DataColumns, initialized: bool, id_positions: Vec, mode: ViewMode, dirty: bool, show_divider: bool, menu_visibility: bool, movement: Option, cmd_buf: String, view: Option, ratio: usize, // right/(container width) * 100 id: ComponentId, } impl fmt::Display for ContactList { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", ContactList::DESCRIPTION) } } impl ContactList { const DESCRIPTION: &'static str = "contact list"; pub fn new(context: &Context) -> Self { let accounts = context .accounts .iter() .enumerate() .map(|(i, a)| AccountMenuEntry { name: a.name().to_string(), index: i, }) .collect(); ContactList { accounts, cursor_pos: 0, new_cursor_pos: 0, length: 0, account_pos: 0, id_positions: Vec::new(), mode: ViewMode::List, data_columns: DataColumns::default(), initialized: false, dirty: true, movement: None, cmd_buf: String::with_capacity(8), view: None, ratio: 90, show_divider: false, menu_visibility: true, id: ComponentId::new_v4(), } } pub fn for_account(pos: usize, context: &Context) -> Self { ContactList { account_pos: pos, ..Self::new(context) } } fn initialize(&mut self, context: &mut Context) { let account = &context.accounts[self.account_pos]; let book = &account.address_book; self.length = book.len(); self.id_positions.clear(); if self.id_positions.capacity() < book.len() { self.id_positions.reserve(book.len()); } self.dirty = true; let mut min_width = ("Name".len(), "E-mail".len(), 0, "external".len(), 0, 0); for c in book.values() { /* name */ min_width.0 = cmp::max(min_width.0, c.name().split_graphemes().len()); /* email */ min_width.1 = cmp::max(min_width.1, c.email().split_graphemes().len()); /* url */ min_width.2 = cmp::max(min_width.2, c.url().split_graphemes().len()); } /* name column */ self.data_columns.columns[0] = CellBuffer::new_with_context(min_width.0, self.length, Cell::with_char(' '), context); /* email column */ self.data_columns.columns[1] = CellBuffer::new_with_context(min_width.1, self.length, Cell::with_char(' '), context); /* url column */ self.data_columns.columns[2] = CellBuffer::new_with_context(min_width.2, self.length, Cell::with_char(' '), context); /* source column */ self.data_columns.columns[3] = CellBuffer::new_with_context( "external".len(), self.length, Cell::with_char(' '), context, ); let account = &context.accounts[self.account_pos]; let book = &account.address_book; let mut book_values = book.values().collect::>(); book_values.sort_unstable_by_key(|c| c.name()); for (idx, c) in book_values.iter().enumerate() { self.id_positions.push(*c.id()); write_string_to_grid( c.name(), &mut self.data_columns.columns[0], Color::Default, Color::Default, Attr::Default, ((0, idx), (min_width.0, idx)), None, ); write_string_to_grid( c.email(), &mut self.data_columns.columns[1], Color::Default, Color::Default, Attr::Default, ((0, idx), (min_width.1, idx)), None, ); write_string_to_grid( c.url(), &mut self.data_columns.columns[2], Color::Default, Color::Default, Attr::Default, ((0, idx), (min_width.2, idx)), None, ); write_string_to_grid( if c.external_resource() { "external" } else { "local" }, &mut self.data_columns.columns[3], Color::Default, Color::Default, Attr::Default, ((0, idx), (min_width.3, idx)), None, ); } if self.length == 0 { let message = "Address book is empty.".to_string(); self.data_columns.columns[0] = CellBuffer::new_with_context( message.len(), self.length, Cell::with_char(' '), context, ); write_string_to_grid( &message, &mut self.data_columns.columns[0], Color::Default, Color::Default, Attr::Default, ((0, 0), (MAX_COLS - 1, 0)), None, ); return; } } fn highlight_line(&mut self, grid: &mut CellBuffer, area: Area, idx: usize) { /* Reset previously highlighted line */ let fg_color = Color::Default; let bg_color = if idx == self.new_cursor_pos { Color::Byte(246) } else { Color::Default }; change_colors(grid, area, fg_color, bg_color); } fn draw_menu(&mut self, grid: &mut CellBuffer, mut area: Area, context: &mut Context) { if !self.is_dirty() { return; } clear_area(grid, area); /* visually divide menu and listing */ area = (area.0, pos_dec(area.1, (1, 0))); let upper_left = upper_left!(area); let bottom_right = bottom_right!(area); self.dirty = false; let mut y = get_y(upper_left); for a in &self.accounts { self.print_account(grid, (set_y(upper_left, y), bottom_right), &a, context); y += 1; } context.dirty_areas.push_back(area); } /* * Print a single account in the menu area. */ fn print_account( &self, grid: &mut CellBuffer, area: Area, a: &AccountMenuEntry, context: &mut Context, ) { if !is_valid_area!(area) { debug!("BUG: invalid area in print_account"); } let width = width!(area); let must_highlight_account: bool = self.account_pos == a.index; let (fg_color, bg_color) = if must_highlight_account { if self.account_pos == a.index { (Color::Byte(233), Color::Byte(15)) } else { (Color::Byte(15), Color::Byte(233)) } } else { (Color::Default, Color::Default) }; let s = format!(" [{}]", context.accounts[a.index].address_book.len()); if a.name.grapheme_len() + s.len() > width + 1 { /* Print account name */ let (x, y) = write_string_to_grid(&a.name, grid, fg_color, bg_color, Attr::Bold, area, None); write_string_to_grid( &s, grid, fg_color, bg_color, Attr::Bold, ( pos_dec( (get_x(bottom_right!(area)), get_y(upper_left!(area))), (s.len() - 1, 0), ), bottom_right!(area), ), None, ); write_string_to_grid( "…", grid, fg_color, bg_color, Attr::Bold, ( pos_dec( (get_x(bottom_right!(area)), get_y(upper_left!(area))), (s.len() - 1, 0), ), bottom_right!(area), ), None, ); for x in x..=get_x(bottom_right!(area)) { grid[(x, y)].set_fg(fg_color); grid[(x, y)].set_bg(bg_color); } } else { /* Print account name */ let (x, y) = write_string_to_grid(&a.name, grid, fg_color, bg_color, Attr::Bold, area, None); write_string_to_grid( &s, grid, fg_color, bg_color, Attr::Bold, ( pos_dec( (get_x(bottom_right!(area)), get_y(upper_left!(area))), (s.len() - 1, 0), ), bottom_right!(area), ), None, ); for x in x..=get_x(bottom_right!(area)) { grid[(x, y)].set_fg(fg_color); grid[(x, y)].set_bg(bg_color); } } } fn draw_list(&mut self, grid: &mut CellBuffer, area: Area, context: &mut Context) { /* reserve top row for column headers */ let upper_left = pos_inc(upper_left!(area), (0, 1)); let bottom_right = bottom_right!(area); if self.length == 0 { clear_area(grid, area); copy_area( grid, &self.data_columns.columns[0], area, ((0, 0), pos_dec(self.data_columns.columns[0].size(), (1, 1))), ); context.dirty_areas.push_back(area); return; } let rows = get_y(bottom_right) - get_y(upper_left) + 1; if let Some(mvm) = self.movement.take() { match mvm { PageMovement::Up(amount) => { self.new_cursor_pos = self.new_cursor_pos.saturating_sub(amount); } PageMovement::PageUp(multiplier) => { self.new_cursor_pos = self.new_cursor_pos.saturating_sub(rows * multiplier); } PageMovement::Down(amount) => { if self.new_cursor_pos + amount < self.length { self.new_cursor_pos += amount; } else { self.new_cursor_pos = self.length - 1; } } PageMovement::PageDown(multiplier) => { if self.new_cursor_pos + rows * multiplier < self.length { self.new_cursor_pos += rows * multiplier; } else if self.new_cursor_pos + rows * multiplier > self.length { self.new_cursor_pos = self.length - 1; } else { self.new_cursor_pos = (self.length / rows) * rows; } } PageMovement::Right(_) | PageMovement::Left(_) => {} PageMovement::Home => { self.new_cursor_pos = 0; } PageMovement::End => { self.new_cursor_pos = self.length - 1; } } } let prev_page_no = (self.cursor_pos).wrapping_div(rows); let page_no = (self.new_cursor_pos).wrapping_div(rows); let top_idx = page_no * rows; /* If cursor position has changed, remove the highlight from the previous position and * apply it in the new one. */ if self.cursor_pos != self.new_cursor_pos && prev_page_no == page_no { let old_cursor_pos = self.cursor_pos; self.cursor_pos = self.new_cursor_pos; for idx in &[old_cursor_pos, self.new_cursor_pos] { if *idx >= self.length { continue; //bounds check } let new_area = ( set_y(upper_left, get_y(upper_left) + (*idx % rows)), set_y(bottom_right, get_y(upper_left) + (*idx % rows)), ); self.highlight_line(grid, new_area, *idx); context.dirty_areas.push_back(new_area); } return; } else if self.cursor_pos != self.new_cursor_pos { self.cursor_pos = self.new_cursor_pos; } if self.new_cursor_pos >= self.length { self.new_cursor_pos = self.length - 1; self.cursor_pos = self.new_cursor_pos; } let width = width!(area); self.data_columns.widths = Default::default(); self.data_columns.widths[0] = self.data_columns.columns[0].size().0; /* name */ self.data_columns.widths[1] = self.data_columns.columns[1].size().0; /* email*/ self.data_columns.widths[2] = self.data_columns.columns[2].size().0; /* url */ self.data_columns.widths[3] = self.data_columns.columns[3].size().0; /* source */ let min_col_width = std::cmp::min( 15, std::cmp::min(self.data_columns.widths[0], self.data_columns.widths[1]), ); if self.data_columns.widths[0] + self.data_columns.widths[1] + 3 * min_col_width + 8 > width { let remainder = width.saturating_sub(self.data_columns.widths[0] + self.data_columns.widths[1] + 4); self.data_columns.widths[2] = remainder / 6; } clear_area(grid, area); /* Page_no has changed, so draw new page */ let mut x = get_x(upper_left); for i in 0..self.data_columns.columns.len() { if self.data_columns.widths[i] == 0 { continue; } let (column_width, column_height) = self.data_columns.columns[i].size(); write_string_to_grid( match i { 0 => "NAME", 1 => "E-MAIL", 2 => "URL", 3 => "SOURCE", _ => "", }, grid, Color::Black, Color::White, Attr::Bold, ( set_x(upper_left!(area), x), ( std::cmp::min(get_x(bottom_right), x + (self.data_columns.widths[i])), get_y(upper_left!(area)), ), ), None, ); copy_area( grid, &self.data_columns.columns[i], ( set_x(upper_left, x), set_x( bottom_right, std::cmp::min(get_x(bottom_right), x + (self.data_columns.widths[i])), ), ), ( (0, top_idx), ( column_width.saturating_sub(1), column_height.saturating_sub(1), ), ), ); x += self.data_columns.widths[i] + 2; // + SEPARATOR if x > get_x(bottom_right) { break; } } change_colors( grid, ( upper_left!(area), set_y(bottom_right, get_y(upper_left!(area))), ), Color::Black, Color::White, ); if top_idx + rows > self.length { clear_area( grid, ( pos_inc(upper_left, (0, self.length - top_idx + 2)), bottom_right, ), ); } self.highlight_line( grid, ( set_y(upper_left, get_y(upper_left) + (self.cursor_pos % rows)), set_y(bottom_right, get_y(upper_left) + (self.cursor_pos % rows)), ), self.cursor_pos, ); context.dirty_areas.push_back(area); } } impl Component for ContactList { fn draw(&mut self, grid: &mut CellBuffer, area: Area, context: &mut Context) { if let Some(mgr) = self.view.as_mut() { mgr.draw(grid, area, context); return; } if !self.dirty { return; } if !self.initialized { self.initialize(context); } let upper_left = upper_left!(area); let bottom_right = bottom_right!(area); let total_cols = get_x(bottom_right) - get_x(upper_left); let right_component_width = if self.menu_visibility { (self.ratio * total_cols) / 100 } else { total_cols }; let mid = get_x(bottom_right) - right_component_width; if self.dirty && mid != get_x(upper_left) { if self.show_divider { for i in get_y(upper_left)..=get_y(bottom_right) { grid[(mid, i)].set_ch(VERT_BOUNDARY); grid[(mid, i)].set_fg(Color::Default); grid[(mid, i)].set_bg(Color::Default); } } else { for i in get_y(upper_left)..=get_y(bottom_right) { grid[(mid, i)].set_fg(Color::Default); grid[(mid, i)].set_bg(Color::Default); } } context .dirty_areas .push_back(((mid, get_y(upper_left)), (mid, get_y(bottom_right)))); } if right_component_width == total_cols { self.draw_list(grid, area, context); } else if right_component_width == 0 { self.draw_menu(grid, area, context); } else { self.draw_menu(grid, (upper_left, (mid, get_y(bottom_right))), context); self.draw_list(grid, (set_x(upper_left, mid + 1), bottom_right), context); } self.dirty = false; } fn process_event(&mut self, event: &mut UIEvent, context: &mut Context) -> bool { if let Some(ref mut v) = self.view { if v.process_event(event, context) { return true; } } let shortcuts = self.get_shortcuts(context); if self.view.is_none() { match *event { UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["create_contact"]) => { let mut manager = ContactManager::default(); manager.set_parent_id(self.id); manager.account_pos = self.account_pos; self.mode = ViewMode::View(manager.id()); self.view = Some(manager); return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["edit_contact"]) && self.length > 0 => { let account = &mut context.accounts[self.account_pos]; let book = &mut account.address_book; let card = book[&self.id_positions[self.cursor_pos]].clone(); let mut manager = ContactManager::default(); manager.set_parent_id(self.id); manager.card = card; manager.account_pos = self.account_pos; self.mode = ViewMode::View(manager.id()); self.view = Some(manager); return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["mail_contact"]) && self.length > 0 => { let account = &context.accounts[self.account_pos]; let book = &account.address_book; let card = &book[&self.id_positions[self.cursor_pos]]; let mut draft: Draft = Draft::default(); *draft.headers_mut().get_mut("To").unwrap() = format!("{} <{}>", &card.name(), &card.email()); context.replies.push_back(UIEvent::Action(Tab(NewDraft( self.account_pos, Some(draft), )))); return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["next_account"]) => { let amount = if self.cmd_buf.is_empty() { 1 } else if let Ok(amount) = self.cmd_buf.parse::() { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); amount } else { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); return true; }; if self.accounts.is_empty() { return true; } if self.account_pos + amount < self.accounts.len() { self.account_pos += amount; self.set_dirty(true); self.initialized = false; self.cursor_pos = 0; self.new_cursor_pos = 0; self.length = 0; context .replies .push_back(UIEvent::StatusEvent(StatusEvent::UpdateStatus( self.get_status(context).unwrap(), ))); } return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["prev_account"]) => { let amount = if self.cmd_buf.is_empty() { 1 } else if let Ok(amount) = self.cmd_buf.parse::() { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); amount } else { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); return true; }; if self.accounts.is_empty() { return true; } if self.account_pos >= amount { self.account_pos -= amount; self.set_dirty(true); self.cursor_pos = 0; self.new_cursor_pos = 0; self.length = 0; self.initialized = false; context .replies .push_back(UIEvent::StatusEvent(StatusEvent::UpdateStatus( self.get_status(context).unwrap(), ))); } return true; } UIEvent::Input(ref k) if shortcut!(k == shortcuts[Self::DESCRIPTION]["toggle_menu_visibility"]) => { self.menu_visibility = !self.menu_visibility; self.set_dirty(true); } UIEvent::Input(Key::Esc) | UIEvent::Input(Key::Alt('')) => { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); return true; } UIEvent::Input(Key::Char(c)) if c >= '0' && c <= '9' => { self.cmd_buf.push(c); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufSet( self.cmd_buf.clone(), ))); return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["scroll_up"]) => { let amount = if self.cmd_buf.is_empty() { 1 } else if let Ok(amount) = self.cmd_buf.parse::() { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); amount } else { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); return true; }; self.movement = Some(PageMovement::Up(amount)); self.set_dirty(true); return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["scroll_down"]) && self.cursor_pos < self.length.saturating_sub(1) => { let amount = if self.cmd_buf.is_empty() { 1 } else if let Ok(amount) = self.cmd_buf.parse::() { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); amount } else { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); return true; }; self.set_dirty(true); self.movement = Some(PageMovement::Down(amount)); return true; } UIEvent::Input(Key::PageUp) => { let mult = if self.cmd_buf.is_empty() { 1 } else if let Ok(mult) = self.cmd_buf.parse::() { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); mult } else { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); return true; }; self.set_dirty(true); self.movement = Some(PageMovement::PageUp(mult)); return true; } UIEvent::Input(Key::PageDown) => { let mult = if self.cmd_buf.is_empty() { 1 } else if let Ok(mult) = self.cmd_buf.parse::() { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); mult } else { self.cmd_buf.clear(); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::BufClear)); return true; }; self.set_dirty(true); self.movement = Some(PageMovement::PageDown(mult)); return true; } UIEvent::Input(ref key) if *key == Key::Home => { self.set_dirty(true); self.movement = Some(PageMovement::Home); return true; } UIEvent::Input(ref key) if *key == Key::End => { self.set_dirty(true); self.movement = Some(PageMovement::End); return true; } _ => {} } } else { match event { UIEvent::ComponentKill(ref kill_id) if self.mode == ViewMode::View(*kill_id) => { self.mode = ViewMode::List; self.view.take(); self.set_dirty(true); return true; } UIEvent::ChangeMode(UIMode::Normal) => { self.set_dirty(true); } UIEvent::Resize => { self.set_dirty(true); } _ => {} } } false } fn is_dirty(&self) -> bool { self.dirty || self.view.as_ref().map(|v| v.is_dirty()).unwrap_or(false) } fn set_dirty(&mut self, value: bool) { if let Some(p) = self.view.as_mut() { p.set_dirty(value); }; self.dirty = value; } fn kill(&mut self, uuid: Uuid, context: &mut Context) { debug_assert!(uuid == self.id); context.replies.push_back(UIEvent::Action(Tab(Kill(uuid)))); } fn get_shortcuts(&self, context: &Context) -> ShortcutMaps { let mut map = self .view .as_ref() .map(|p| p.get_shortcuts(context)) .unwrap_or_default(); let config_map = context.settings.shortcuts.contact_list.key_values(); map.insert(Self::DESCRIPTION, config_map); map } fn id(&self) -> ComponentId { self.id } fn set_id(&mut self, id: ComponentId) { self.id = id; } fn can_quit_cleanly(&mut self, context: &Context) -> bool { self.view .as_mut() .map(|p| p.can_quit_cleanly(context)) .unwrap_or(true) } fn get_status(&self, context: &Context) -> Option { Some(format!( "{} entries", context.accounts[self.account_pos].address_book.len() )) } }