/* * 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 melib::text::{LineBreakText, Truncate}; use super::*; use crate::terminal::embedded::EmbeddedGrid; /// 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(Clone, Debug, Default)] 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, /// At the last draw, were the visible columns plus horizontal cursor less /// than total width? Used to decide whether to accept `scroll_right` /// key events. cols_lt_width: bool, /// At the last draw, were the visible rows plus vertical cursor less than /// total height? Used to decide whether to accept `scroll_down` key /// events. rows_lt_height: bool, filtered_content: Option<(String, Result)>, text_lines: Vec, line_breaker: LineBreakText, movement: Option, id: ComponentId, } impl std::fmt::Display for Pager { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "pager") } } impl Pager { const PAGES_AHEAD_TO_RENDER_NO: usize = 16; pub fn new(context: &Context) -> Self { let mut ret = Self { minimum_width: context.settings.pager.minimum_width, ..Self::default() }; 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 = Self { 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::default(), 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, 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("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 = EmbeddedGrid::new(); embedded.set_terminal_size((80, 20)); for b in out { embedded.process_byte(&mut dev_null, b); } Ok(embedded) }; let buf = _f(cmd, &self.text); if let Some((width, height)) = buf.as_ref().ok().map(EmbeddedGrid::terminal_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 = area.width(); 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::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 + area.height() < 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 * area.height(), ); } 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 + area.height() { return; } let new_lines_no = (up_to + area.height()) - 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::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) => { grid.copy_area( content.buffer(), area, content .area() .skip_cols(self.cursor.0) .skip_rows(self.cursor.1) .take_cols(content.terminal_size().0.saturating_sub(area.width())) .take_rows(content.terminal_size().1.saturating_sub(area.height())), ); 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 area2 = area; for l in self .text_lines .iter() .skip(self.cursor.1) .take(area2.height()) { if area2.is_empty() { break; } grid.write_string( l, self.colors.fg, self.colors.bg, Attr::DEFAULT, area2, None, ); if l.starts_with('⤷') { grid[area2.upper_left()] .set_fg(crate::conf::value(context, "highlight").fg) .set_attrs(crate::conf::value(context, "highlight").attrs); } area2 = area2.skip_rows(1); } if area2.height() <= 1 { grid.clear_area(area2, crate::conf::value(context, "theme_default")); } } { #[cfg(feature = "regexp")] { let area3 = area; 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(area3.height() + 1) { let i = i + area3.upper_left().1; for (start, end) in text_formatter.regexp.find_iter(l) { let start = start + area3.upper_left().0; let end = end + area3.upper_left().0; grid.set_tag(t, (start, i), (end, i)); } } } } if let Some(ref mut search) = self.search { // Last row will be reserved for the "Results for ..." line. let area3 = area.skip_rows_from_end(1); let cursor_line = self.cursor.1; 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, offset)) in search .positions .iter() .enumerate() .filter(|(_, &(y, _))| y >= cursor_line && y < cursor_line + area3.height()) { let attr = if i == search.cursor { results_current_attr } else { results_attr }; let (y, x) = (*y, *offset); let row_iter = grid.row_iter( area3.nth_row(y - cursor_line), x..x + search.pattern.grapheme_width(), 0, ); debug_assert_eq!(row_iter.area().width(), search.pattern.grapheme_width()); for c in row_iter { grid[c] .set_fg(attr.fg) .set_bg(attr.bg) .set_attrs(attr.attrs); } } } } } } impl Component for Pager { fn draw(&mut self, grid: &mut CellBuffer, area: Area, context: &mut Context) { if !self.is_dirty() { return; } if !self.initialised { self.initialise(grid, area, context); } self.dirty = false; if self.height == 0 || self.width == 0 { grid.clear_area(area, crate::conf::value(context, "theme_default")); return; } let (mut cols, mut rows) = (area.width(), area.height()); 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 cols < 2 || rows < 2 { return; } 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; } 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(rows * 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 * rows, ); } PageMovement::PageDown(multiplier) => { if self.cursor.1 + rows * multiplier + 1 < self.height { self.cursor.1 += rows * multiplier; } else if self.cursor.1 + rows * multiplier > self.height { self.cursor.1 = self.height.saturating_sub(1); } else { self.cursor.1 = (self.height / rows) * rows; } self.draw_lines_up_to( grid, area, context, self.cursor.1 + Self::PAGES_AHEAD_TO_RENDER_NO * rows, ); } 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 let Some(ref mut search) = self.search { if !search.positions.is_empty() { if let Some(mvm) = search.movement.take() { match mvm { SearchMovement::First | SearchMovement::Last => { self.cursor.1 = search.positions[search.cursor].0; } SearchMovement::Previous => { if self.cursor.1 > search.positions[search.cursor].0 { self.cursor.1 = search.positions[search.cursor].0; } } SearchMovement::Next => { if self.cursor.1 + rows < search.positions[search.cursor].0 { self.cursor.1 = search.positions[search.cursor].0; } } } } } } grid.clear_area(area, crate::conf::value(context, "theme_default")); self.cols_lt_width = cols + self.cursor.0 < width; self.rows_lt_height = rows + self.cursor.1 < height; 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, area.take_cols(cols).take_rows(rows), context); if self.show_scrollbar && rows < height { ScrollBar::default().set_show_arrows(true).draw( grid, area.nth_col(area.width()), 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, area.nth_row(area.height()), 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; } grid.write_string( &status_message, attribute.fg, attribute.bg, attribute.attrs, area.nth_row(area.height().saturating_sub(1)), None, ); /* set search pattern to italics */ let start_x = RESULTS_STR.len(); let row_iter = grid.row_iter( area.nth_row(area.height().saturating_sub(1)), start_x..(start_x + search.pattern.grapheme_width()), 0, ); debug_assert_eq!(row_iter.area().width(), search.pattern.grapheme_width()); for c in row_iter { 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.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[Shortcuts::PAGER]["scroll_up"]) && self.cursor.1 > 0 => { self.movement = Some(PageMovement::Up(1)); self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::PAGER]["scroll_down"]) && self.rows_lt_height => { self.movement = Some(PageMovement::Down(1)); self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::GENERAL]["scroll_left"]) && self.cursor.0 > 0 => { self.movement = Some(PageMovement::Left(1)); self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::GENERAL]["scroll_right"]) && self.cols_lt_width => { self.movement = Some(PageMovement::Right(1)); self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::PAGER]["page_up"]) => { self.movement = Some(PageMovement::PageUp(1)); self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::PAGER]["page_down"]) => { self.movement = Some(PageMovement::PageDown(1)); self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::GENERAL]["home_page"]) => { self.movement = Some(PageMovement::Home); self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::GENERAL]["end_page"]) => { self.movement = Some(PageMovement::End); self.dirty = true; return true; } UIEvent::ChangeMode(UIMode::Normal) => { self.dirty = true; } UIEvent::Action(View(Pipe(ref bin, ref args))) => { use std::{ io::Write, 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(SearchMovement::First), }); self.initialised = false; self.dirty = true; return true; } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::GENERAL]["next_search_result"]) && self.search.is_some() => { if let Some(ref mut search) = self.search { search.movement = Some(SearchMovement::Next); search.cursor += 1; self.initialised = false; self.dirty = true; return true; } } UIEvent::Input(ref key) if shortcut!(key == shortcuts[Shortcuts::GENERAL]["previous_search_result"]) && self.search.is_some() => { if let Some(ref mut search) = self.search { search.movement = Some(SearchMovement::Previous); search.cursor = search.cursor.saturating_sub(1); 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.set_dirty(true); } UIEvent::VisibilityChange(true) => { self.set_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(), ))); } UIEvent::Input(ref key) => { return context.settings.shortcuts.pager.commands.iter().any(|cmd| { if cmd.shortcut == *key { for cmd in &cmd.command { context.replies.push_back(UIEvent::Command(cmd.to_string())); } return true; } false }) } _ => {} } false } fn is_dirty(&self) -> bool { self.dirty } fn set_dirty(&mut self, value: bool) { self.dirty = value; } fn shortcuts(&self, context: &Context) -> ShortcutMaps { let mut ret: ShortcutMaps = Default::default(); ret.insert( Shortcuts::PAGER, context.settings.shortcuts.pager.key_values(), ); ret.insert( Shortcuts::GENERAL, context.settings.shortcuts.general.key_values(), ); ret } fn id(&self) -> ComponentId { self.id } }