diff --git a/Cargo.lock b/Cargo.lock index 997e1f458..1365049a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,6 +224,7 @@ dependencies = [ name = "coreutils" version = "0.0.6" dependencies = [ + "atty", "conv", "filetime", "glob 0.3.0", @@ -487,6 +488,53 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "crossterm" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c36c10130df424b2f3552fcc2ddcd9b28a27b1e54b358b45874f88d1ca6888c" +dependencies = [ + "bitflags", + "crossterm_winapi", + "lazy_static", + "libc", + "mio", + "parking_lot", + "signal-hook", + "winapi 0.3.9", +] + +[[package]] +name = "crossterm_winapi" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da8964ace4d3e4a044fd027919b2237000b24315a37c916f61809f1ff2140b9" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "csv" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +dependencies = [ + "bstr", + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr 2.4.0", +] + [[package]] name = "ctor" version = "0.1.20" @@ -713,6 +761,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46dbcb333e86939721589d25a3557e180b52778cb33c7fdfe9e0158ff790d5ec" +[[package]] +name = "instant" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec" +dependencies = [ + "cfg-if 1.0.0", +] + [[package]] name = "ioctl-sys" version = "0.5.2" @@ -768,6 +825,15 @@ dependencies = [ "libc", ] +[[package]] +name = "lock_api" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0382880606dff6d15c9476c416d18690b72742aa7b605bb6dd6ec9030fbf07eb" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.14" @@ -828,6 +894,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mio" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e50ae3f04d169fcc9bde0b547d1c205219b7157e07ded9c5aff03e0637cb3ed7" +dependencies = [ + "libc", + "log", + "miow", + "ntapi", + "winapi 0.3.9", +] + +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "nix" version = "0.13.1" @@ -859,6 +947,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "ntapi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +dependencies = [ + "winapi 0.3.9", + ] + [[package]] name = "num-bigint" version = "0.4.0" @@ -971,6 +1068,31 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "parking_lot" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7a782938e745763fe6907fc6ba86946d72f49fe7e21de074e08128a99fb018" +dependencies = [ + "cfg-if 1.0.0", + "instant", + "libc", + "redox_syscall 0.2.8", + "smallvec 1.6.1", + "winapi 0.3.9", +] + [[package]] name = "paste" version = "0.1.18" @@ -1372,6 +1494,26 @@ dependencies = [ "generic-array", ] +[[package]] +name = "signal-hook" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e31d442c16f047a671b5a71e2161d6e68814012b7f5379d269ebd915fac2729" +dependencies = [ + "libc", + "mio", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1d0fef1604ba8f7a073c7e701f213e056707210e9020af4528e0101ce11a6" +dependencies = [ + "libc", +] + [[package]] name = "smallvec" version = "0.6.14" @@ -1381,6 +1523,12 @@ dependencies = [ "maybe-uninit", ] +[[package]] +name = "smallvec" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" + [[package]] name = "socket2" version = "0.3.19" @@ -1840,7 +1988,7 @@ dependencies = [ "paste", "quickcheck", "rand 0.7.3", - "smallvec", + "smallvec 0.6.14", "uucore", "uucore_procs", ] @@ -2067,7 +2215,9 @@ dependencies = [ name = "uu_more" version = "0.0.6" dependencies = [ + "atty", "clap", + "crossterm", "nix 0.13.1", "redox_syscall 0.1.57", "redox_termios", diff --git a/Cargo.toml b/Cargo.toml index 36d667d98..3412ff9f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -348,6 +348,7 @@ time = "0.1" unindent = "0.1" uucore = { version=">=0.0.8", package="uucore", path="src/uucore", features=["entries"] } walkdir = "2.2" +atty = "0.2.14" [target.'cfg(unix)'.dev-dependencies] rust-users = { version="0.10", package="users" } diff --git a/src/uu/more/Cargo.toml b/src/uu/more/Cargo.toml index 1f4bfed68..9b1a3d7b6 100644 --- a/src/uu/more/Cargo.toml +++ b/src/uu/more/Cargo.toml @@ -18,6 +18,8 @@ path = "src/more.rs" clap = "2.33" uucore = { version = ">=0.0.7", package = "uucore", path = "../../uucore" } uucore_procs = { version = ">=0.0.5", package = "uucore_procs", path = "../../uucore_procs" } +crossterm = ">=0.19" +atty = "0.2.14" [target.'cfg(target_os = "redox")'.dependencies] redox_termios = "0.1" diff --git a/src/uu/more/src/more.rs b/src/uu/more/src/more.rs index eabdbee85..c4a13aa1b 100644 --- a/src/uu/more/src/more.rs +++ b/src/uu/more/src/more.rs @@ -10,150 +10,395 @@ #[macro_use] extern crate uucore; -use std::fs::File; -use std::io::{stdin, stdout, BufRead, BufReader, Read, Write}; +use std::{ + convert::TryInto, + fs::File, + io::{stdin, stdout, BufReader, Read, Stdout, Write}, + path::Path, + time::Duration, +}; #[cfg(all(unix, not(target_os = "fuchsia")))] extern crate nix; -#[cfg(all(unix, not(target_os = "fuchsia")))] -use nix::sys::termios::{self, LocalFlags, SetArg}; -use uucore::InvalidEncodingHandling; -#[cfg(target_os = "redox")] -extern crate redox_termios; -#[cfg(target_os = "redox")] -extern crate syscall; +use clap::{App, Arg}; +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyModifiers}, + execute, queue, + style::Attribute, + terminal, +}; -use clap::{App, Arg, ArgMatches}; - -static VERSION: &str = env!("CARGO_PKG_VERSION"); -static ABOUT: &str = "A file perusal filter for CRT viewing."; - -mod options { - pub const FILE: &str = "file"; +pub mod options { + pub const SILENT: &str = "silent"; + pub const LOGICAL: &str = "logical"; + pub const NO_PAUSE: &str = "no-pause"; + pub const PRINT_OVER: &str = "print-over"; + pub const CLEAN_PRINT: &str = "clean-print"; + pub const SQUEEZE: &str = "squeeze"; + pub const PLAIN: &str = "plain"; + pub const LINES: &str = "lines"; + pub const NUMBER: &str = "number"; + pub const PATTERN: &str = "pattern"; + pub const FROM_LINE: &str = "from-line"; + pub const FILES: &str = "files"; } -fn get_usage() -> String { - format!("{} [options] ...", executable!()) -} +const MULTI_FILE_TOP_PROMPT: &str = "::::::::::::::\n{}\n::::::::::::::\n"; pub fn uumain(args: impl uucore::Args) -> i32 { - let usage = get_usage(); - let args = args - .collect_str(InvalidEncodingHandling::ConvertLossy) - .accept_any(); - let matches = App::new(executable!()) - .version(VERSION) - .usage(usage.as_str()) - .about(ABOUT) + .about("A file perusal filter for CRT viewing.") + .version(env!("CARGO_PKG_VERSION")) + // The commented arguments below are unimplemented: + /* .arg( - Arg::with_name(options::FILE) - .number_of_values(1) - .multiple(true), + Arg::with_name(options::SILENT) + .short("d") + .long(options::SILENT) + .help("Display help instead of ringing bell"), + ) + .arg( + Arg::with_name(options::LOGICAL) + .short("f") + .long(options::LOGICAL) + .help("Count logical rather than screen lines"), + ) + .arg( + Arg::with_name(options::NO_PAUSE) + .short("l") + .long(options::NO_PAUSE) + .help("Suppress pause after form feed"), + ) + .arg( + Arg::with_name(options::PRINT_OVER) + .short("c") + .long(options::PRINT_OVER) + .help("Do not scroll, display text and clean line ends"), + ) + .arg( + Arg::with_name(options::CLEAN_PRINT) + .short("p") + .long(options::CLEAN_PRINT) + .help("Do not scroll, clean screen and display text"), + ) + .arg( + Arg::with_name(options::SQUEEZE) + .short("s") + .long(options::SQUEEZE) + .help("Squeeze multiple blank lines into one"), + ) + .arg( + Arg::with_name(options::PLAIN) + .short("u") + .long(options::PLAIN) + .help("Suppress underlining and bold"), + ) + .arg( + Arg::with_name(options::LINES) + .short("n") + .long(options::LINES) + .value_name("number") + .takes_value(true) + .help("The number of lines per screenful"), + ) + .arg( + Arg::with_name(options::NUMBER) + .allow_hyphen_values(true) + .long(options::NUMBER) + .required(false) + .takes_value(true) + .help("Same as --lines"), + ) + .arg( + Arg::with_name(options::FROM_LINE) + .short("F") + .allow_hyphen_values(true) + .required(false) + .takes_value(true) + .value_name("number") + .help("Display file beginning from line number"), + ) + .arg( + Arg::with_name(options::PATTERN) + .short("P") + .allow_hyphen_values(true) + .required(false) + .takes_value(true) + .help("Display file beginning from pattern match"), + ) + */ + .arg( + Arg::with_name(options::FILES) + .required(false) + .multiple(true) + .help("Path to the files to be read"), ) .get_matches_from(args); - - // FixME: fail without panic for now; but `more` should work with no arguments (ie, for piped input) - if let None | Some("-") = matches.value_of(options::FILE) { - show_usage_error!("Reading from stdin isn't supported yet."); - return 1; - } - - if let Some(x) = matches.value_of(options::FILE) { - let path = std::path::Path::new(x); - if path.is_dir() { - show_usage_error!("'{}' is a directory.", x); - return 1; + + let mut buff = String::new(); + if let Some(filenames) = matches.values_of(options::FILES) { + let mut stdout = setup_term(); + let length = filenames.len(); + for (idx, fname) in filenames.enumerate() { + let fname = Path::new(fname); + if fname.is_dir() { + terminal::disable_raw_mode().unwrap(); + show_usage_error!("'{}' is a directory.", fname.display()); + return 1; + } + if !fname.exists() { + terminal::disable_raw_mode().unwrap(); + show_error!( + "cannot open {}: No such file or directory", + fname.display() + ); + return 1; + } + if length > 1 { + buff.push_str(&MULTI_FILE_TOP_PROMPT.replace("{}", fname.to_str().unwrap())); + } + let mut reader = BufReader::new(File::open(fname).unwrap()); + reader.read_to_string(&mut buff).unwrap(); + let is_last = idx + 1 == length; + more(&buff, &mut stdout, is_last); + buff.clear(); } + reset_term(&mut stdout); + } else if atty::isnt(atty::Stream::Stdin) { + stdin().read_to_string(&mut buff).unwrap(); + let mut stdout = setup_term(); + more(&buff, &mut stdout, true); + reset_term(&mut stdout); + } else { + show_usage_error!("bad usage"); } - - more(matches); - 0 } -#[cfg(all(unix, not(target_os = "fuchsia")))] -fn setup_term() -> termios::Termios { - let mut term = termios::tcgetattr(0).unwrap(); - // Unset canonical mode, so we get characters immediately - term.local_flags.remove(LocalFlags::ICANON); - // Disable local echo - term.local_flags.remove(LocalFlags::ECHO); - termios::tcsetattr(0, SetArg::TCSADRAIN, &term).unwrap(); - term +#[cfg(not(target_os = "fuchsia"))] +fn setup_term() -> std::io::Stdout { + let stdout = stdout(); + terminal::enable_raw_mode().unwrap(); + stdout } -#[cfg(any(windows, target_os = "fuchsia"))] +#[cfg(target_os = "fuchsia")] #[inline(always)] fn setup_term() -> usize { 0 } -#[cfg(target_os = "redox")] -fn setup_term() -> redox_termios::Termios { - let mut term = redox_termios::Termios::default(); - let fd = syscall::dup(0, b"termios").unwrap(); - syscall::read(fd, &mut term).unwrap(); - term.local_flags &= !redox_termios::ICANON; - term.local_flags &= !redox_termios::ECHO; - syscall::write(fd, &term).unwrap(); - let _ = syscall::close(fd); - term +#[cfg(not(target_os = "fuchsia"))] +fn reset_term(stdout: &mut std::io::Stdout) { + terminal::disable_raw_mode().unwrap(); + // Clear the prompt + queue!(stdout, terminal::Clear(terminal::ClearType::CurrentLine)).unwrap(); + // Move cursor to the beginning without printing new line + print!("\r"); + stdout.flush().unwrap(); } -#[cfg(all(unix, not(target_os = "fuchsia")))] -fn reset_term(term: &mut termios::Termios) { - term.local_flags.insert(LocalFlags::ICANON); - term.local_flags.insert(LocalFlags::ECHO); - termios::tcsetattr(0, SetArg::TCSADRAIN, &term).unwrap(); -} - -#[cfg(any(windows, target_os = "fuchsia"))] +#[cfg(target_os = "fuchsia")] #[inline(always)] fn reset_term(_: &mut usize) {} -#[cfg(any(target_os = "redox"))] -fn reset_term(term: &mut redox_termios::Termios) { - let fd = syscall::dup(0, b"termios").unwrap(); - syscall::read(fd, term).unwrap(); - term.local_flags |= redox_termios::ICANON; - term.local_flags |= redox_termios::ECHO; - syscall::write(fd, &term).unwrap(); - let _ = syscall::close(fd); -} +fn more(buff: &str, mut stdout: &mut Stdout, is_last: bool) { + let (cols, rows) = terminal::size().unwrap(); + let lines = break_buff(buff, usize::from(cols)); + let line_count: u16 = lines.len().try_into().unwrap(); -fn more(matches: ArgMatches) { - let mut f: Box = match matches.value_of(options::FILE) { - None | Some("-") => Box::new(BufReader::new(stdin())), - Some(filename) => Box::new(BufReader::new(File::open(filename).unwrap())), - }; - let mut buffer = [0; 1024]; + let mut upper_mark = 0; + let mut lines_left = line_count.saturating_sub(upper_mark + rows); - let mut term = setup_term(); + draw( + &mut upper_mark, + rows, + &mut stdout, + lines.clone(), + line_count, + ); - let mut end = false; - while let Ok(sz) = f.read(&mut buffer) { - if sz == 0 { - break; - } - stdout().write_all(&buffer[0..sz]).unwrap(); - for byte in std::io::stdin().bytes() { - match byte.unwrap() { - b' ' => break, - b'q' | 27 => { - end = true; - break; - } - _ => (), - } - } - - if end { - break; + // Specifies whether we have reached the end of the file and should + // return on the next keypress. However, we immediately return when + // this is the last file. + let mut to_be_done = false; + if lines_left == 0 && is_last { + if is_last { + return; + } else { + to_be_done = true; } } + + loop { + if event::poll(Duration::from_millis(10)).unwrap() { + match event::read().unwrap() { + Event::Key(KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + }) + | Event::Key(KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + }) => { + reset_term(&mut stdout); + std::process::exit(0); + } + Event::Key(KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + }) + | Event::Key(KeyEvent { + code: KeyCode::Char(' '), + modifiers: KeyModifiers::NONE, + }) => { + upper_mark = upper_mark.saturating_add(rows.saturating_sub(1)); + + } + Event::Key(KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + }) => { + upper_mark = upper_mark.saturating_sub(rows.saturating_sub(1)); + } + _ => continue, + } + lines_left = line_count.saturating_sub(upper_mark + rows); + draw( + &mut upper_mark, + rows, + &mut stdout, + lines.clone(), + line_count, + ); - reset_term(&mut term); - println!(); + if lines_left == 0 { + if to_be_done || is_last { + return + } + to_be_done = true; + } + } + } +} + +fn draw( + upper_mark: &mut u16, + rows: u16, + mut stdout: &mut std::io::Stdout, + lines: Vec, + lc: u16, +) { + execute!(stdout, terminal::Clear(terminal::ClearType::CurrentLine)).unwrap(); + let (up_mark, lower_mark) = calc_range(*upper_mark, rows, lc); + // Reduce the row by 1 for the prompt + let displayed_lines = lines + .iter() + .skip(up_mark.into()) + .take(usize::from(rows.saturating_sub(1))); + + for line in displayed_lines { + stdout + .write_all(format!("\r{}\n", line).as_bytes()) + .unwrap(); + } + make_prompt_and_flush(&mut stdout, lower_mark, lc); + *upper_mark = up_mark; +} + +// Break the lines on the cols of the terminal +fn break_buff(buff: &str, cols: usize) -> Vec { + let mut lines = Vec::new(); + + for l in buff.lines() { + lines.append(&mut break_line(l, cols)); + } + lines +} + +fn break_line(mut line: &str, cols: usize) -> Vec { + let breaks = (line.len() / cols).saturating_add(1); + let mut lines = Vec::with_capacity(breaks); + // TODO: Use unicode width instead of the length in bytes. + if line.len() < cols { + lines.push(line.to_string()); + return lines; + } + + for _ in 1..=breaks { + let (line1, line2) = line.split_at(cols); + lines.push(line1.to_string()); + if line2.len() < cols { + lines.push(line2.to_string()); + break; + } + line = line2; + } + lines +} + +// Calculate upper_mark based on certain parameters +fn calc_range(mut upper_mark: u16, rows: u16, line_count: u16) -> (u16, u16) { + let mut lower_mark = upper_mark.saturating_add(rows); + + if lower_mark >= line_count { + upper_mark = line_count.saturating_sub(rows); + lower_mark = line_count; + } else { + lower_mark = lower_mark.saturating_sub(1) + } + (upper_mark, lower_mark) +} + +// Make a prompt similar to original more +fn make_prompt_and_flush(stdout: &mut Stdout, lower_mark: u16, lc: u16) { + write!( + stdout, + "\r{}--More--({}%){}", + Attribute::Reverse, + ((lower_mark as f64 / lc as f64) * 100.0).round() as u16, + Attribute::Reset + ) + .unwrap(); + stdout.flush().unwrap(); +} + +#[cfg(test)] +mod tests { + use super::{break_line, calc_range}; + + // It is good to test the above functions + #[test] + fn test_calc_range() { + assert_eq!((0, 24), calc_range(0, 25, 100)); + assert_eq!((50, 74), calc_range(50, 25, 100)); + assert_eq!((75, 100), calc_range(85, 25, 100)); + } + #[test] + fn test_break_lines_long() { + let mut test_string = String::with_capacity(100); + for _ in 0..200 { + test_string.push('#'); + } + + let lines = break_line(&test_string, 80); + + assert_eq!( + (80, 80, 40), + (lines[0].len(), lines[1].len(), lines[2].len()) + ); + } + + #[test] + fn test_break_lines_short() { + let mut test_string = String::with_capacity(100); + for _ in 0..20 { + test_string.push('#'); + } + + let lines = break_line(&test_string, 80); + + assert_eq!(20, lines[0].len()); + } } diff --git a/tests/by-util/test_more.rs b/tests/by-util/test_more.rs index 9245733ca..cc778f0b4 100644 --- a/tests/by-util/test_more.rs +++ b/tests/by-util/test_more.rs @@ -2,15 +2,22 @@ use crate::common::util::*; #[test] fn test_more_no_arg() { - // stderr = more: Reading from stdin isn't supported yet. - new_ucmd!().fails(); + // Reading from stdin is now supported, so this must succeed + if atty::is(atty::Stream::Stdout) { + new_ucmd!().succeeds(); + } else {} } #[test] fn test_more_dir_arg() { - let result = new_ucmd!().arg(".").run(); - result.failure(); - const EXPECTED_ERROR_MESSAGE: &str = - "more: '.' is a directory.\nTry 'more --help' for more information."; - assert_eq!(result.stderr_str().trim(), EXPECTED_ERROR_MESSAGE); + // Run the test only if there's a valud terminal, else do nothing + // Maybe we could capture the error, i.e. "Device not found" in that case + // but I am leaving this for later + if atty::is(atty::Stream::Stdout) { + let result = new_ucmd!().arg(".").run(); + result.failure(); + const EXPECTED_ERROR_MESSAGE: &str = + "more: '.' is a directory.\nTry 'more --help' for more information."; + assert_eq!(result.stderr_str().trim(), EXPECTED_ERROR_MESSAGE); + } else {} }