diff --git a/docs/cli.md b/docs/cli.md index 2b954c8..6eaea93 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -22,7 +22,7 @@ With no file, ink reads stdin when something is piped in and opens the file brow | Option | What it does | |---|---| | `-t`, `--theme ` | Color theme. Default `auto`, which matches the terminal background. See [Themes](themes.md). | -| `-w`, `--width ` | Max text width: 20 to 1000 columns, or `narrow`, `wide`, `full` | +| `-w`, `--width ` | Max text width: 20 to 1000 columns, or `narrow` (60), `wide` (100), `full`. In the reader, explicit widths are limited only by the document pane; `full` uses the entire pane with two-column margins. Without a CLI or config width, text remains capped at 120 columns. | | `-s`, `--slides` | Presentation mode, split on `---` | | `-p`, `--plain` | Print rendered output instead of opening the reader. Automatic when stdout isn't a terminal. | | `--color ` | `auto`, `always` or `never`. Default `auto`. See [Color in plain output](#color-in-plain-output). | diff --git a/src/app.rs b/src/app.rs index 6f158c6..5c1241d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2376,8 +2376,12 @@ fn build_tab( // Content must fit within the terminal even after the left margin, so cap // it at term_width - 4 (a requested --width wider than the terminal would // otherwise overflow and be clipped at the right edge). - let hard_cap = term_width.saturating_sub(4).clamp(8, 120); - let max_content_width = args.width.unwrap_or(hard_cap).clamp(8, hard_cap); + let available_width = term_width.saturating_sub(4).max(8); + let max_content_width = if args.full_width { + available_width + } else { + args.width.unwrap_or(120).clamp(8, available_width) + }; let center_margin = if term_width > max_content_width + 4 { ((term_width - max_content_width) / 2) as usize } else { @@ -2843,6 +2847,7 @@ mod section_tests { inputs: Vec::new(), theme: "dark".into(), width: None, + full_width: false, slides: false, plain: false, watch: false, @@ -3079,6 +3084,62 @@ mod section_tests { assert_eq!(not_editable(&t), None); } + #[test] + fn full_width_uses_the_document_pane_with_small_margins() { + let graphics = crate::graphics::Graphics::halfblocks(); + let source = "word ".repeat(300); + for terminal_width in [80, 200, 320] { + for toc_visible in [false, true] { + let mut args = test_args(); + args.full_width = true; + args.width = Some(60); // A configured width must not limit full-width mode. + args.toc = toc_visible; + let pane_width = effective_width(terminal_width, toc_visible); + let tab = build_tab(source.as_str(), "t.md", &args, pane_width, 0, &graphics); + let lines: Vec<_> = tab.plain.iter().filter(|line| !line.is_empty()).collect(); + assert!(lines.iter().all(|line| line.starts_with(" word"))); + assert!(lines + .iter() + .all(|line| line.len() <= pane_width as usize - 2)); + // Word wrapping may leave up to four unused columns. + assert!(lines[0].len() >= pane_width as usize - 6); + } + } + } + + #[test] + fn requested_width_is_centered_and_clamped_to_the_document_pane() { + let graphics = crate::graphics::Graphics::halfblocks(); + let source = "word ".repeat(300); + for (requested, terminal_width, margin, content_width) in [ + (60, 200, 70, 60), + (120, 200, 40, 120), + (140, 200, 30, 140), + (160, 240, 40, 160), + (1000, 200, 2, 196), + (140, 80, 2, 76), + ] { + let mut args = test_args(); + args.width = Some(requested); + let tab = build_tab(source.as_str(), "t.md", &args, terminal_width, 0, &graphics); + let first = tab.plain.iter().find(|line| !line.is_empty()).unwrap(); + assert_eq!(first.len() - first.trim_start().len(), margin); + assert!(first.trim_start().len() <= content_width); + assert!(first.trim_start().len() >= content_width - 4); + } + } + + #[test] + fn automatic_width_keeps_the_existing_reading_width() { + let graphics = crate::graphics::Graphics::halfblocks(); + let source = "word ".repeat(300); + let tab = build_tab(source.as_str(), "t.md", &test_args(), 200, 0, &graphics); + let first = tab.plain.iter().find(|line| !line.is_empty()).unwrap(); + assert_eq!(first.len() - first.trim_start().len(), 40); + assert!(first.trim_start().len() <= 120); + assert!(first.trim_start().len() >= 116); + } + // The three ways a tab is rebuilt (re-layout, `--watch`, reload after // `e`) share `reload_tab`: the same state carried over and one clamp // rule. `rebuild_tab` used to clamp to `len - 1`, leaving a mostly diff --git a/src/cli.rs b/src/cli.rs index af4880a..a10f977 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -205,6 +205,8 @@ pub struct Args { pub inputs: Vec, pub theme: String, pub width: Option, + /// Use the entire document pane in the interactive reader. + pub full_width: bool, pub slides: bool, pub plain: bool, pub watch: bool, @@ -367,6 +369,7 @@ pub fn run() -> Result<()> { inputs, theme, width, + full_width: cli.width == Some(WidthArg::Full), slides: cli.slides, // The interactive reader needs a terminal: with stdout piped or // redirected (`ink file.md | cat`), render plain output instead. diff --git a/tests/cli.rs b/tests/cli.rs index e886231..8aea32a 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -361,6 +361,22 @@ fn documented_flag_values_still_work() { } } +#[test] +fn full_width_keeps_the_existing_plain_output_width() { + let source = "word ".repeat(100); + let render = |width| { + ink() + .args(["--plain", "--color=never", "--theme=dark", "-w", width]) + .write_stdin(source.as_str()) + .assert() + .success() + .get_output() + .stdout + .clone() + }; + assert_eq!(render("full"), render("80")); +} + #[test] fn config_syntax_error_warns_and_ink_still_runs() { let dir = xdg_with_config("theme = \"nord\"\nwidth = \n"); diff --git a/tests/mermaid.rs b/tests/mermaid.rs index 3f80e1c..b644fdd 100644 --- a/tests/mermaid.rs +++ b/tests/mermaid.rs @@ -9,6 +9,7 @@ fn args(width: u16) -> Args { inputs: vec![], theme: "dark".to_string(), width: Some(width), + full_width: false, slides: false, plain: true, watch: false, diff --git a/tests/plain_snapshots.rs b/tests/plain_snapshots.rs index 80c043b..421e24d 100644 --- a/tests/plain_snapshots.rs +++ b/tests/plain_snapshots.rs @@ -12,6 +12,7 @@ fn test_args() -> Args { inputs: vec![], theme: "dark".to_string(), width: Some(80), + full_width: false, slides: false, plain: true, watch: false, diff --git a/tests/pty.rs b/tests/pty.rs index 256de13..6e5fa6f 100644 --- a/tests/pty.rs +++ b/tests/pty.rs @@ -27,15 +27,18 @@ fn run_in_pty( envs: &[(&str, &str)], mut respond: impl FnMut(&[u8], Duration) -> Option>, ) -> Session { - run_in_pty_ctl(args, envs, move |out, t| respond(out, t).map(Reply::Type)) + run_in_pty_ctl(args, envs, 100, move |out, t| { + respond(out, t).map(Reply::Type) + }) } fn run_in_pty_ctl( args: &[&str], envs: &[(&str, &str)], + width: u16, mut respond: impl FnMut(&[u8], Duration) -> Option, ) -> Session { - let (mut master, slave) = open_pty(); + let (mut master, slave) = open_pty(width); let config_home = tempfile::tempdir().unwrap(); // An `ink/` directory makes ink use this (empty) config instead of the // developer's real one. @@ -114,11 +117,11 @@ fn run_in_pty_ctl( Session { output } } -fn open_pty() -> (std::fs::File, OwnedFd) { +fn open_pty(width: u16) -> (std::fs::File, OwnedFd) { let (mut m, mut s) = (0, 0); let mut size = libc::winsize { ws_row: 30, - ws_col: 100, + ws_col: width, ws_xpixel: 0, ws_ypixel: 0, }; @@ -558,6 +561,35 @@ fn reader_args(path: &std::path::Path) -> Vec { .collect() } +#[test] +fn explicit_width_is_honored_on_a_wide_terminal() { + let doc = tempfile::Builder::new().suffix(".md").tempfile().unwrap(); + std::fs::write(doc.path(), "x".repeat(600)).unwrap(); + for (width, expected) in [ + (None, 120), + (Some("full"), 196), + (Some("120"), 120), + (Some("140"), 140), + (Some("1000"), 196), + ] { + let mut args = reader_args(doc.path()); + if let Some(width) = width { + args.extend(["-w".into(), width.into()]); + } + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let s = run_in_pty_ctl(&args, &[], 200, control_steps(Vec::new(), Marks::default())); + assert!(contains(&s.output, b"\x1b[?1049l"), "reader did not exit"); + assert!( + contains(&s.output, "x".repeat(expected).as_bytes()), + "{width:?}" + ); + assert!( + !contains(&s.output, "x".repeat(expected + 1).as_bytes()), + "{width:?}" + ); + } +} + #[test] fn the_toc_takes_focus_filters_and_jumps() { let doc = long_doc_with_anchor_link(); @@ -597,7 +629,7 @@ fn shrinking_the_window_gives_focus_back_to_the_document() { ]; let args = reader_args(doc.path()); let args: Vec<&str> = args.iter().map(String::as_str).collect(); - let s = run_in_pty_ctl(&args, &[], control_steps(steps, Marks::clone(&marks))); + let s = run_in_pty_ctl(&args, &[], 100, control_steps(steps, Marks::clone(&marks))); let marks = marks.borrow(); assert_eq!(marks.len(), 3, "steps not all sent"); let focused = String::from_utf8_lossy(&s.output[marks[0]..marks[1]]); diff --git a/tests/rendering_features.rs b/tests/rendering_features.rs index 4e49c68..7c39717 100644 --- a/tests/rendering_features.rs +++ b/tests/rendering_features.rs @@ -10,6 +10,7 @@ fn args() -> Args { inputs: vec![], theme: "dark".to_string(), width: Some(80), + full_width: false, slides: false, plain: true, watch: false, diff --git a/tests/security.rs b/tests/security.rs index 96da4a1..bfedf3c 100644 --- a/tests/security.rs +++ b/tests/security.rs @@ -9,6 +9,7 @@ fn args() -> Args { inputs: vec![], theme: "dark".to_string(), width: Some(80), + full_width: false, slides: false, plain: true, watch: false,