├── .gitignore ├── Cargo.toml ├── README.md ├── examples └── bat-riffle.rs └── src ├── app └── main.rs └── lib.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "riffle" 3 | description = "A terminal pager library" 4 | version = "0.2.0" 5 | categories = ["command-line-interface"] 6 | homepage = "https://github.com/sharkdp/riffle" 7 | repository = "https://github.com/sharkdp/riffle" 8 | authors = ["David Peter "] 9 | edition = "2021" 10 | readme = "README.md" 11 | license = "MIT/Apache-2.0" 12 | 13 | [dependencies] 14 | crossterm = "0.22" 15 | 16 | [dev-dependencies] 17 | bat = "0.19" 18 | 19 | [[bin]] 20 | name = "riffle" 21 | path = "src/app/main.rs" 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # riffle 2 | 3 | A proof-of-concept for a pager-as-a-library. Mainly designed for `bat`, and not 4 | ready for general use. 5 | 6 | How to run the `bat` integration example: 7 | ```bash 8 | cargo run -q --release --example=bat-riffle -- 9 | ``` 10 | -------------------------------------------------------------------------------- /examples/bat-riffle.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::io; 3 | use std::process; 4 | use std::process::Command; 5 | use std::str; 6 | use std::sync::{Arc, Mutex}; 7 | 8 | use riffle::{KeyCode, Pager}; 9 | 10 | struct Config { 11 | enable_wrapping: bool, 12 | show_sidebar: bool, 13 | run_editor: bool, 14 | } 15 | 16 | fn run() -> io::Result<()> { 17 | let mut args = env::args_os(); 18 | args.next(); 19 | let file = args.next().ok_or(io::Error::new( 20 | io::ErrorKind::Other, 21 | "FILE argument missing", 22 | ))?; 23 | let file = &file; 24 | 25 | let config = Arc::new(Mutex::new(Config { 26 | enable_wrapping: true, 27 | show_sidebar: true, 28 | run_editor: false, 29 | })); 30 | 31 | let mut pager = Pager::new(); 32 | 33 | let config2 = config.clone(); 34 | pager.on_resize(move |pager| { 35 | let scroll_position = pager.scroll_position(); 36 | pager.clear_buffer(); 37 | 38 | let width = pager.terminal_width(); 39 | 40 | let enable_wrapping = config2.lock().unwrap().enable_wrapping; 41 | let show_sidebar = config2.lock().unwrap().show_sidebar; 42 | 43 | let output = Command::new("bat") 44 | .arg(format!( 45 | "--style={}", 46 | if show_sidebar { 47 | "full" 48 | } else { 49 | "header,grid,snip" 50 | } 51 | )) 52 | .arg("--force-colorization") 53 | .arg("--paging=never") 54 | .arg(format!( 55 | "--wrap={}", 56 | if enable_wrapping { 57 | "character" 58 | } else { 59 | "never" 60 | } 61 | )) 62 | .arg(format!("--terminal-width={}", width)) 63 | .arg(file) 64 | .output() 65 | .expect("Failed to run 'bat'"); 66 | 67 | let stdout = str::from_utf8(&output.stdout).expect("Could not decode 'bat' output"); 68 | let lines: Vec<_> = stdout.lines().collect(); 69 | 70 | let len = lines.len(); 71 | if len >= 4 { 72 | pager.header( 73 | lines[0..3] 74 | .iter() 75 | .map(|l| format!("{}\n", l)) 76 | .collect::(), 77 | ); 78 | 79 | for line in lines[3..(len - 1)].iter() { 80 | pager.append(&line); 81 | } 82 | 83 | pager.footer(lines[len - 1]); 84 | } 85 | 86 | pager.scroll_to(scroll_position); 87 | }); 88 | 89 | let config3 = config.clone(); 90 | pager.on_keypress(move |pager, key| match key { 91 | KeyCode::Char('e') => { 92 | { 93 | config3.lock().unwrap().run_editor = true; 94 | } 95 | pager.quit(); 96 | } 97 | KeyCode::Char('n') => { 98 | let show_sidebar = &mut config3.lock().unwrap().show_sidebar; 99 | *show_sidebar = !*show_sidebar; 100 | } 101 | KeyCode::Char('w') => { 102 | let enable_wrapping = &mut config3.lock().unwrap().enable_wrapping; 103 | *enable_wrapping = !*enable_wrapping; 104 | } 105 | _ => {} 106 | }); 107 | 108 | pager.run(); 109 | 110 | if config.lock().unwrap().run_editor { 111 | Command::new(std::env::var_os("EDITOR").expect("EDITOR not set")) 112 | .arg(file) 113 | .status() 114 | .expect("Failed to run editor"); 115 | } 116 | 117 | Ok(()) 118 | } 119 | 120 | fn main() { 121 | match run() { 122 | Ok(_) => {} 123 | Err(e) => { 124 | eprintln!("Error: {}", e); 125 | process::exit(1); 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/app/main.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs::File; 3 | use std::io::{self, prelude::*, BufReader}; 4 | use std::process; 5 | use std::str; 6 | 7 | use riffle::Pager; 8 | 9 | fn run() -> io::Result<()> { 10 | // Super simple pager application based on riffle 11 | 12 | // Unfortunately, reading from STDIN can not be supported currently. 13 | // See https://github.com/crossterm-rs/crossterm/issues/396 14 | 15 | let mut args = env::args_os(); 16 | args.next(); 17 | let input_path = args.next().expect("FILE argument"); 18 | 19 | let mut pager = Pager::new(); 20 | 21 | pager.on_init(|pager| { 22 | let input_file = File::open(&input_path).expect("Can not open file"); 23 | let mut reader = BufReader::new(input_file); 24 | pager.footer(format!("\x1b[7m{}\x1b[0m", input_path.to_string_lossy())); 25 | 26 | let mut line_buffer = vec![]; 27 | while let Ok(num) = reader.read_until(b'\n', &mut line_buffer) { 28 | if num == 0 { 29 | break; 30 | } 31 | 32 | pager.append(str::from_utf8(&line_buffer).unwrap()); 33 | line_buffer.clear(); 34 | } 35 | }); 36 | 37 | pager.run(); 38 | 39 | Ok(()) 40 | } 41 | 42 | fn main() { 43 | match run() { 44 | Ok(_) => {} 45 | Err(e) => { 46 | eprintln!("Error: {}", e); 47 | process::exit(1); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::io::{stdout, Write}; 2 | use std::time::Duration; 3 | 4 | use crossterm::{ 5 | cursor, 6 | event::{poll, read, Event, KeyModifiers}, 7 | execute, 8 | style::{self, style}, 9 | terminal::{self, disable_raw_mode, enable_raw_mode}, 10 | ExecutableCommand, QueueableCommand, Result, 11 | }; 12 | 13 | pub use crossterm::event::KeyCode; 14 | 15 | pub struct PagerHandle { 16 | buffer: Vec, 17 | top_row: usize, 18 | header: Option>, 19 | footer: Option>, 20 | quit_requested: bool, 21 | } 22 | 23 | impl PagerHandle { 24 | pub fn header>(&mut self, message: S) { 25 | self.header = Some(message.as_ref().lines().map(|l| l.to_owned()).collect()); 26 | } 27 | 28 | pub fn footer>(&mut self, message: S) { 29 | self.footer = Some(message.as_ref().lines().map(|l| l.to_owned()).collect()); 30 | } 31 | 32 | pub fn append>(&mut self, content: S) { 33 | self.buffer.push(content.as_ref().into()); 34 | } 35 | 36 | pub fn clear_buffer(&mut self) { 37 | self.buffer.clear(); 38 | self.top_row = 0; 39 | } 40 | 41 | pub fn terminal_width(&self) -> u16 { 42 | terminal::size().unwrap().0 43 | } 44 | 45 | pub fn quit(&mut self) { 46 | self.quit_requested = true; 47 | } 48 | 49 | pub fn scroll_position(&self) -> usize { 50 | self.top_row 51 | } 52 | 53 | pub fn scroll_to>(&mut self, line: U) { 54 | self.top_row = line.into(); 55 | } 56 | } 57 | 58 | pub struct Pager<'a> { 59 | handle: PagerHandle, 60 | on_init_callback: Box, 61 | on_resize_callback: Box, 62 | on_keypress_callback: Box, 63 | } 64 | 65 | impl<'a> Pager<'a> { 66 | pub fn new() -> Self { 67 | Pager { 68 | handle: PagerHandle { 69 | buffer: vec![], 70 | top_row: 0, 71 | header: None, 72 | footer: None, 73 | quit_requested: false, 74 | }, 75 | on_init_callback: Box::new(|_handle: &mut PagerHandle| {}), 76 | on_resize_callback: Box::new(|_handle: &mut PagerHandle| {}), 77 | on_keypress_callback: Box::new(|_handle: &mut PagerHandle, _code: KeyCode| {}), 78 | } 79 | } 80 | 81 | pub fn on_init(&mut self, callback: F) 82 | where 83 | F: FnMut(&mut PagerHandle) + 'a, 84 | { 85 | self.on_init_callback = Box::new(callback); 86 | } 87 | 88 | pub fn on_resize(&mut self, callback: F) 89 | where 90 | F: FnMut(&mut PagerHandle) + 'a, 91 | { 92 | self.on_resize_callback = Box::new(callback); 93 | } 94 | 95 | pub fn on_keypress(&mut self, callback: F) 96 | where 97 | F: FnMut(&mut PagerHandle, KeyCode) + 'a, 98 | { 99 | self.on_keypress_callback = Box::new(callback); 100 | } 101 | 102 | fn clear_screen(&self) -> Result<()> { 103 | let mut stdout = stdout(); 104 | stdout.execute(terminal::Clear(terminal::ClearType::All))?; 105 | Ok(()) 106 | } 107 | 108 | fn redraw(&self) -> Result<()> { 109 | self.clear_screen()?; 110 | 111 | let mut stdout = stdout(); 112 | 113 | let header_height = self.header_height(); 114 | 115 | // Header 116 | if let Some(ref header) = self.handle.header { 117 | for (r, line) in header.iter().enumerate() { 118 | stdout.queue(cursor::MoveTo(0, r as u16))?; 119 | stdout.queue(style::PrintStyledContent(style(line)))?; 120 | } 121 | } 122 | 123 | // Body 124 | let body_height = self.body_height()?; 125 | 126 | for r in 0..body_height { 127 | if let Some(ref line) = self.handle.buffer.get(self.handle.top_row + r as usize) { 128 | stdout.queue(cursor::MoveTo(0, header_height + r))?; 129 | stdout.queue(style::PrintStyledContent(style(line)))?; 130 | } else { 131 | break; 132 | } 133 | } 134 | 135 | // Footer 136 | stdout.queue(cursor::MoveTo(0, body_height))?; 137 | if let Some(ref footer) = self.handle.footer { 138 | for (r, line) in footer.iter().enumerate() { 139 | stdout.queue(cursor::MoveTo(0, header_height + body_height + r as u16))?; 140 | stdout.queue(style::PrintStyledContent(style(line)))?; 141 | } 142 | } 143 | 144 | stdout.flush()?; 145 | 146 | Ok(()) 147 | } 148 | 149 | fn scroll_down>(&mut self, lines: U) -> Result<()> { 150 | let length = self.content_length(); 151 | let height = self.body_height()? as usize; 152 | 153 | let lines = lines.into(); 154 | 155 | self.handle.top_row += lines; 156 | 157 | let max_top_row = if length > height { length - height } else { 0 }; 158 | if self.handle.top_row > max_top_row { 159 | self.handle.top_row = max_top_row; 160 | } 161 | 162 | Ok(()) 163 | } 164 | 165 | fn scroll_up>(&mut self, lines: U) -> Result<()> { 166 | let lines = lines.into(); 167 | self.handle.top_row = if self.handle.top_row >= lines { 168 | self.handle.top_row - lines 169 | } else { 170 | 0 171 | }; 172 | 173 | Ok(()) 174 | } 175 | 176 | fn header_height(&self) -> u16 { 177 | self.handle.header.as_ref().map(|h| h.len()).unwrap_or(0) as u16 178 | } 179 | 180 | fn footer_height(&self) -> u16 { 181 | self.handle.footer.as_ref().map(|h| h.len()).unwrap_or(0) as u16 182 | } 183 | 184 | fn body_height(&self) -> Result { 185 | Ok(crossterm::terminal::size()?.1 - self.header_height() - self.footer_height()) 186 | // TODO: handle overflows, what about size 0 terminals? 187 | } 188 | 189 | fn content_length(&self) -> usize { 190 | self.handle.buffer.len() 191 | } 192 | 193 | pub fn run(&mut self) { 194 | let result = self.run_impl(); 195 | 196 | match result { 197 | Ok(_) => {} 198 | Err(e) => { 199 | self.cleanup().ok(); 200 | dbg!(&e); // TODO 201 | } 202 | } 203 | } 204 | 205 | fn run_impl(&mut self) -> Result<()> { 206 | let mut stdout = stdout(); 207 | 208 | execute!(stdout, terminal::EnterAlternateScreen, cursor::Hide)?; 209 | enable_raw_mode()?; 210 | 211 | // TODO: Do we want to run resize after init here? This would 212 | // allow clients to only implement on_resize. 213 | (self.on_init_callback)(&mut self.handle); 214 | (self.on_resize_callback)(&mut self.handle); 215 | // TODO: check for quit_requested? 216 | self.redraw()?; 217 | 218 | loop { 219 | if poll(Duration::from_millis(20))? { 220 | match read()? { 221 | Event::Key(event) => { 222 | match event.code { 223 | KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { 224 | break; 225 | } 226 | KeyCode::Down | KeyCode::Char('j') | KeyCode::Enter => { 227 | self.scroll_down(1usize)?; 228 | } 229 | KeyCode::Up | KeyCode::Char('k') => { 230 | self.scroll_up(1usize)?; 231 | } 232 | KeyCode::PageDown => { 233 | self.scroll_down(self.body_height()?)?; 234 | } 235 | KeyCode::PageUp => { 236 | self.scroll_up(self.body_height()?)?; 237 | } 238 | KeyCode::Home => { 239 | self.handle.top_row = 0; 240 | } 241 | KeyCode::End => { 242 | let length = self.content_length(); 243 | self.scroll_down(length)?; 244 | } 245 | KeyCode::Char('c') 246 | if event.modifiers.contains(KeyModifiers::CONTROL) => 247 | { 248 | break; 249 | } 250 | c => { 251 | (self.on_keypress_callback)(&mut self.handle, c); 252 | (self.on_resize_callback)(&mut self.handle); 253 | if self.handle.quit_requested { 254 | break; 255 | } 256 | } 257 | } 258 | self.redraw()?; 259 | } 260 | Event::Mouse(_) => { 261 | // Capturing of mouse events is not enabled in order to allow 262 | // for normal text selection. Scrolling still works on terminals 263 | // that send up/down arrow events. 264 | } 265 | Event::Resize(_width, _height) => { 266 | (self.on_resize_callback)(&mut self.handle); 267 | if self.handle.quit_requested { 268 | break; 269 | } 270 | self.redraw()?; 271 | } 272 | } 273 | } 274 | } 275 | 276 | self.cleanup()?; 277 | 278 | Ok(()) 279 | } 280 | 281 | fn cleanup(&self) -> Result<()> { 282 | disable_raw_mode()?; 283 | 284 | execute!(stdout(), cursor::Show, terminal::LeaveAlternateScreen) 285 | } 286 | } 287 | --------------------------------------------------------------------------------