/* * meli - pager * * Copyright 2020 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 melib::text_processing::LineBreakText; /// A pager for text. /// `Pager` holds its own content in its own `CellBuffer` and when `draw` is called, it draws the /// current view of the text. It is responsible for scrolling etc. #[derive(Default, Debug, Clone)] pub struct Pager { text: String, cursor: (usize, usize), reflow: Reflow, height: usize, width: usize, minimum_width: usize, search: Option, dirty: bool, colors: ThemeAttribute, initialised: bool, show_scrollbar: bool, content: CellBuffer, filtered_content: Option<(String, Result)>, text_lines: Vec, line_breaker: LineBreakText, movement: Option, id: ComponentId, } impl fmt::Display for Pager { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", Pager::DESCRIPTION) } } impl Pager { pub const DESCRIPTION: &'static str = "pager"; const PAGES_AHEAD_TO_RENDER_NO: usize = 16; pub fn new(context: &Context) -> Self { let mut ret = Pager::default(); ret.minimum_width = context.settings.pager.minimum_width; ret.set_colors(crate::conf::value(context, "theme_default")) .set_reflow(if context.settings.pager.split_long_lines { Reflow::All } else { Reflow::No }); ret } pub fn set_show_scrollbar(&mut self, new_val: bool) -> &mut Self { self.show_scrollbar = new_val; self } pub fn set_colors(&mut self, new_val: ThemeAttribute) -> &mut Self { self.colors = new_val; self } pub fn set_reflow(&mut self, new_val: Reflow) -> &mut Self { self.reflow = new_val; self } pub fn set_initialised(&mut self, new_val: bool) -> &mut Self { self.initialised = new_val; self } pub fn reflow(&self) -> Reflow { self.reflow } pub fn update_from_str(&mut self, text: &str, mut width: Option) { if let Some(ref mut width) = width.as_mut() { if **width < self.minimum_width { **width = self.minimum_width; } } self.text = text.to_string(); self.text_lines.clear(); self.line_breaker = LineBreakText::new(self.text.clone(), self.reflow, width); self.height = 0; self.width = 0; self.search = None; self.set_dirty(true); self.initialised = false; self.cursor = (0, 0); } pub fn from_string( text: String, context: Option<&Context>, cursor_pos: Option, mut width: Option, colors: ThemeAttribute, ) -> Self { let pager_filter: Option<&String> = if let Some(context) = context { context.settings.pager.filter.as_ref() } else { None }; let pager_minimum_width: usize = if let Some(context) = context { context.settings.pager.minimum_width } else { 0 }; let reflow: Reflow = if let Some(context) = context { if context.settings.pager.split_long_lines { Reflow::All } else { Reflow::No } } else { Reflow::All }; if let Some(ref mut width) = width.as_mut() { if **width < pager_minimum_width { **width = pager_minimum_width; } } let mut ret = Pager { text, text_lines: vec![], reflow, cursor: (0, cursor_pos.unwrap_or(0)), height: 1, width: 1, minimum_width: pager_minimum_width, initialised: false, dirty: true, id: ComponentId::new_v4(), filtered_content: None, colors, ..Default::default() }; if let Some(bin) = pager_filter { ret.filter(bin); } ret } pub fn filter(&mut self, cmd: &str) { let _f = |bin: &str, text: &str| -> Result { use std::io::Write; use std::process::{Command, Stdio}; let mut filter_child = Command::new("sh") .args(&["-c", bin]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .chain_err_summary(|| "Failed to start pager filter process")?; let stdin = filter_child .stdin .as_mut() .ok_or_else(|| "failed to open stdin")?; stdin .write_all(text.as_bytes()) .chain_err_summary(|| "Failed to write to stdin")?; let out = filter_child .wait_with_output() .chain_err_summary(|| "Failed to wait on filter")? .stdout; let mut dev_null = std::fs::File::open("/dev/null")?; let mut embedded = crate::terminal::embed::EmbedGrid::new(); embedded.set_terminal_size((80, 20)); for b in out { embedded.process_byte(&mut dev_null, b); } Ok(std::mem::replace(embedded.buffer_mut(), Default::default())) }; let buf = _f(cmd, &self.text); if let Some((width, height)) = buf.as_ref().ok().map(CellBuffer::size) { self.width = width; self.height = height; } self.filtered_content = Some((cmd.to_string(), buf)); } pub fn cursor_pos(&self) -> usize { self.cursor.1 } pub fn size(&self) -> (usize, usize) { (self.width, self.height) } pub fn initialise(&mut self, grid: &mut CellBuffer, area: Area, context: &mut Context) { let mut width = width!(area); if width < self.minimum_width { width = self.minimum_width; } if self.filtered_content.is_none() { if self.line_breaker.width() != Some(width.saturating_sub(4)) { let line_breaker = LineBreakText::new( self.text.clone(), self.reflow, Some(width.saturating_sub(4)), ); self.line_breaker = line_breaker; self.text_lines.clear(); }; self.height = self.text_lines.len(); self.width = width; if let Some(ref mut search) = self.search { use melib::text_processing::search::KMP; search.positions.clear(); for (y, l) in self.text_lines.iter().enumerate() { search.positions.extend( l.kmp_search(&search.pattern) .into_iter() .map(|offset| (y, offset)), ); } if let Some(pos) = search.positions.get(search.cursor) { if self.cursor.1 > pos.0 || self.cursor.1 + height!(area) < pos.0 { self.cursor.1 = pos.0.saturating_sub(3); } } } self.draw_lines_up_to( grid, area, context, self.cursor.1 + Self::PAGES_AHEAD_TO_RENDER_NO * height!(area), ); } self.draw_page(grid, area, context); self.initialised = true; } pub fn draw_lines_up_to( &mut self, _grid: &mut CellBuffer, area: Area, _context: &mut Context, up_to: usize, ) { if self.line_breaker.is_finished() { return; } let old_lines_no = self.text_lines.len(); if up_to == 0 { self.text_lines.extend(self.line_breaker.by_ref()); } else { if old_lines_no >= up_to + height!(area) { return; } let new_lines_no = (up_to + height!(area)) - old_lines_no; self.text_lines .extend(self.line_breaker.by_ref().take(new_lines_no)); }; let new_lines_no = self.text_lines.len() - old_lines_no; if let Some(ref mut search) = self.search { use melib::text_processing::search::KMP; for (y, l) in self.text_lines.iter().enumerate().skip(old_lines_no) { search.positions.extend( l.kmp_search(&search.pattern) .into_iter() .map(|offset| (y, offset)), ); } } self.height += new_lines_no; } fn draw_page(&mut self, grid: &mut CellBuffer, area: Area, context: &mut Context) { if let Some((ref cmd, ref filtered_content)) = self.filtered_content { match filtered_content { Ok(ref content) => { copy_area( grid, &content, area, ( ( std::cmp::min( self.cursor.0, content.size().0.saturating_sub(width!(area)), ), std::cmp::min( self.cursor.1, content.size().1.saturating_sub(height!(area)), ), ), pos_dec(content.size(), (1, 1)), ), ); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::UpdateSubStatus( cmd.to_string(), ))); return; } Err(ref err) => { let mut cmd = cmd.as_str(); cmd.truncate_at_boundary(4); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::UpdateSubStatus(format!( "{}: {}", cmd, err )))); } } } let (mut upper_left, bottom_right) = area; for l in self .text_lines .iter() .skip(self.cursor.1) .take(height!(area) + 1) { write_string_to_grid( l, grid, self.colors.fg, self.colors.bg, Attr::DEFAULT, (upper_left, bottom_right), None, ); if l.starts_with("⤷") { grid[upper_left] .set_fg(Color::Byte(240)) .set_attrs(Attr::BOLD); } upper_left = pos_inc(upper_left, (0, 1)); } if get_y(upper_left) <= get_y(bottom_right) { clear_area( grid, (upper_left, bottom_right), crate::conf::value(context, "theme_default"), ); } let (upper_left, _bottom_right) = area; #[cfg(feature = "regexp")] { for text_formatter in crate::conf::text_format_regexps(context, "pager.envelope.body") { let t = grid.insert_tag(text_formatter.tag); for (i, l) in self .text_lines .iter() .skip(self.cursor.1) .enumerate() .take(height!(area) + 1) { let i = i + get_y(upper_left); for (start, end) in text_formatter.regexp.find_iter(l) { let start = start + get_x(upper_left); let end = end + get_x(upper_left); grid.set_tag(t, (start, i), (end, i)); } } } } let cursor_line = self.cursor.1; if let Some(ref mut search) = self.search { let results_attr = crate::conf::value(context, "pager.highlight_search"); let results_current_attr = crate::conf::value(context, "pager.highlight_search_current"); search.cursor = std::cmp::min(search.positions.len().saturating_sub(1), search.cursor); for (i, (y, x)) in search .positions .iter() .enumerate() .filter(|(_, (y, _))| *y >= cursor_line) .take(height!(area) + 1) { let x = *x + get_x(upper_left); let y = *y - cursor_line; for c in grid.row_iter( x..x + search.pattern.grapheme_width(), y + get_y(upper_left), ) { if i == search.cursor { grid[c] .set_fg(results_current_attr.fg) .set_bg(results_current_attr.bg) .set_attrs(results_current_attr.attrs); } else { grid[c] .set_fg(results_attr.fg) .set_bg(results_attr.bg) .set_attrs(results_attr.attrs); } } } } } } impl Component for Pager { fn draw(&mut self, grid: &mut CellBuffer, area: Area, context: &mut Context) { if !is_valid_area!(area) { return; } if !self.is_dirty() { return; } if !self.initialised { self.initialise(grid, area, context); } self.dirty = false; let height = height!(area); if let Some(mvm) = self.movement.take() { match mvm { PageMovement::Up(amount) => { self.cursor.1 = self.cursor.1.saturating_sub(amount); } PageMovement::PageUp(multiplier) => { self.cursor.1 = self.cursor.1.saturating_sub(height * multiplier); } PageMovement::Down(amount) => { if self.cursor.1 + amount + 1 < self.height { self.cursor.1 += amount; } else { self.cursor.1 = self.height.saturating_sub(1); } self.draw_lines_up_to( grid, area, context, self.cursor.1 + Self::PAGES_AHEAD_TO_RENDER_NO * height, ); } PageMovement::PageDown(multiplier) => { if self.cursor.1 + height * multiplier + 1 < self.height { self.cursor.1 += height * multiplier; } else if self.cursor.1 + height * multiplier > self.height { self.cursor.1 = self.height.saturating_sub(1); } else { self.cursor.1 = (self.height / height) * height; } self.draw_lines_up_to( grid, area, context, self.cursor.1 + Self::PAGES_AHEAD_TO_RENDER_NO * height, ); } PageMovement::Right(amount) => { if self.cursor.0 + amount + 1 < self.width { self.cursor.0 += amount; } else { self.cursor.0 = self.width.saturating_sub(1); } } PageMovement::Left(amount) => { self.cursor.0 = self.cursor.0.saturating_sub(amount); } PageMovement::Home => { self.cursor.1 = 0; } PageMovement::End => { self.draw_lines_up_to(grid, area, context, 0); self.cursor.1 = self.height.saturating_sub(1); } } } if self.height == 0 || self.width == 0 { return; } if let Some(ref mut search) = self.search { if !search.positions.is_empty() { if let Some(mvm) = search.movement.take() { match mvm { PageMovement::Up(_) => { if self.cursor.1 > search.positions[search.cursor].0 { self.cursor.1 = search.positions[search.cursor].0; } } PageMovement::Down(_) => { if self.cursor.1 + height < search.positions[search.cursor].0 { self.cursor.1 = search.positions[search.cursor].0; } } _ => {} } } } } clear_area(grid, area, crate::conf::value(context, "theme_default")); let (mut cols, mut rows) = (width!(area), height!(area)); if cols < 2 || rows < 2 { return; } let (has_more_lines, (width, height)) = if self.filtered_content.is_some() { (false, (self.width, self.height)) } else { ( !self.line_breaker.is_finished(), (self.line_breaker.width().unwrap_or(cols), self.height), ) }; if self.show_scrollbar && rows < height { cols -= 1; rows -= 1; } else if self.search.is_some() { rows -= 1; } if self.show_scrollbar && cols < width { rows -= 1; } self.cursor = ( std::cmp::min(width.saturating_sub(cols), self.cursor.0), std::cmp::min(height.saturating_sub(rows), self.cursor.1), ); self.draw_page( grid, (upper_left!(area), pos_inc(upper_left!(area), (cols, rows))), context, ); if self.show_scrollbar && rows < height { ScrollBar::default().set_show_arrows(true).draw( grid, ( set_x(upper_left!(area), get_x(bottom_right!(area))), bottom_right!(area), ), context, /* position */ self.cursor.1, /* visible_rows */ rows, /* length */ height, ); } if self.show_scrollbar && cols < width { ScrollBar::default().set_show_arrows(true).draw_horizontal( grid, ( set_y(upper_left!(area), get_y(bottom_right!(area))), bottom_right!(area), ), context, self.cursor.0, cols, width, ); } if (rows < height) || self.search.is_some() { const RESULTS_STR: &str = "Results for "; let shown_lines = self.cursor.1 + rows; let total_lines = height; if rows < height { context .replies .push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate( ScrollUpdate::Update { id: self.id, context: ScrollContext { shown_lines, total_lines, has_more_lines, }, }, ))); } else { context .replies .push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate( ScrollUpdate::End(self.id), ))); }; if let Some(ref search) = self.search { let status_message = format!( "{results_str}{search_pattern}: {current_pos}/{total_results}{has_more_lines}", results_str = RESULTS_STR, search_pattern = &search.pattern, current_pos = if search.positions.is_empty() { 0 } else { search.cursor + 1 }, total_results = search.positions.len(), has_more_lines = if !has_more_lines { "" } else { "(+)" } ); let mut attribute = crate::conf::value(context, "status.bar"); if !context.settings.terminal.use_color() { attribute.attrs |= Attr::REVERSE; } let (_, y) = write_string_to_grid( &status_message, grid, attribute.fg, attribute.bg, attribute.attrs, ( set_y(upper_left!(area), get_y(bottom_right!(area))), bottom_right!(area), ), None, ); /* set search pattern to italics */ let start_x = get_x(upper_left!(area)) + RESULTS_STR.len(); for c in grid.row_iter(start_x..(start_x + search.pattern.grapheme_width()), y) { grid[c].set_attrs(attribute.attrs | Attr::ITALICS); } }; } context.dirty_areas.push_back(area); } fn process_event(&mut self, event: &mut UIEvent, context: &mut Context) -> bool { let shortcuts = self.get_shortcuts(context); match event { UIEvent::ConfigReload { old_settings: _ } => { self.set_colors(crate::conf::value(context, "theme_default")); self.set_dirty(true); } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["scroll_up"]) => { self.movement = Some(PageMovement::Up(1)); self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["scroll_down"]) => { self.movement = Some(PageMovement::Down(1)); self.dirty = true; return true; } UIEvent::Input(Key::Home) => { self.movement = Some(PageMovement::Home); self.dirty = true; return true; } UIEvent::Input(Key::End) => { self.movement = Some(PageMovement::End); self.dirty = true; return true; } UIEvent::Input(Key::Left) => { self.movement = Some(PageMovement::Left(1)); self.dirty = true; return true; } UIEvent::Input(Key::Right) => { self.movement = Some(PageMovement::Right(1)); self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["page_up"]) => { self.movement = Some(PageMovement::PageUp(1)); self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Self::DESCRIPTION]["page_down"]) => { self.movement = Some(PageMovement::PageDown(1)); self.dirty = true; return true; } UIEvent::ChangeMode(UIMode::Normal) => { self.dirty = true; } UIEvent::Action(View(Pipe(ref bin, ref args))) => { use std::io::Write; use std::process::{Command, Stdio}; let mut command_obj = match Command::new(bin) .args(args.as_slice()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() { Ok(o) => o, Err(e) => { context.replies.push_back(UIEvent::StatusEvent( StatusEvent::DisplayMessage(format!( "Could not pipe to {}: {}", bin, e )), )); return true; } }; let stdin = command_obj.stdin.as_mut().expect("failed to open stdin"); stdin .write_all(self.text.as_bytes()) .expect("Failed to write to stdin"); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::DisplayMessage(format!( "Pager text piped to '{}{}{}'", &bin, if args.is_empty() { "" } else { " " }, args.join(" ") )))); return true; } UIEvent::Action(View(Filter(ref cmd))) => { self.filter(cmd); self.initialised = false; self.dirty = true; return true; } UIEvent::Action(Action::Listing(ListingAction::Search(pattern))) => { self.search = Some(SearchPattern { pattern: pattern.to_string(), positions: vec![], cursor: 0, movement: Some(PageMovement::Home), }); self.initialised = false; self.dirty = true; return true; } UIEvent::Input(Key::Char('n')) if self.search.is_some() => { if let Some(ref mut search) = self.search { search.movement = Some(PageMovement::Down(1)); search.cursor += 1; } else { unsafe { std::hint::unreachable_unchecked(); } } self.initialised = false; self.dirty = true; return true; } UIEvent::Input(Key::Char('N')) if self.search.is_some() => { if let Some(ref mut search) = self.search { search.movement = Some(PageMovement::Up(1)); search.cursor = search.cursor.saturating_sub(1); } else { unsafe { std::hint::unreachable_unchecked(); } } self.initialised = false; self.dirty = true; return true; } UIEvent::Input(Key::Esc) if self.search.is_some() => { self.search = None; self.initialised = false; self.dirty = true; return true; } UIEvent::Input(Key::Esc) if self.filtered_content.is_some() => { self.filtered_content = None; self.initialised = false; self.dirty = true; context .replies .push_back(UIEvent::StatusEvent(StatusEvent::UpdateSubStatus( String::new(), ))); return true; } UIEvent::Resize => { self.initialised = false; self.dirty = true; } UIEvent::VisibilityChange(false) => { context .replies .push_back(UIEvent::StatusEvent(StatusEvent::ScrollUpdate( ScrollUpdate::End(self.id), ))); context .replies .push_back(UIEvent::StatusEvent(StatusEvent::UpdateSubStatus( String::new(), ))); } _ => {} } false } fn is_dirty(&self) -> bool { self.dirty } fn set_dirty(&mut self, value: bool) { self.dirty = value; } fn get_shortcuts(&self, context: &Context) -> ShortcutMaps { let config_map: IndexMap<&'static str, Key> = context.settings.shortcuts.pager.key_values(); let mut ret: ShortcutMaps = Default::default(); ret.insert(Pager::DESCRIPTION, config_map); ret } fn id(&self) -> ComponentId { self.id } fn set_id(&mut self, id: ComponentId) { self.id = id; } }