Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <THEME>` | Color theme. Default `auto`, which matches the terminal background. See [Themes](themes.md). |
| `-w`, `--width <WIDTH>` | Max text width: 20 to 1000 columns, or `narrow`, `wide`, `full` |
| `-w`, `--width <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 <WHEN>` | `auto`, `always` or `never`. Default `auto`. See [Color in plain output](#color-in-plain-output). |
Expand Down
65 changes: 63 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -2843,6 +2847,7 @@ mod section_tests {
inputs: Vec::new(),
theme: "dark".into(),
width: None,
full_width: false,
slides: false,
plain: false,
watch: false,
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ pub struct Args {
pub inputs: Vec<String>,
pub theme: String,
pub width: Option<u16>,
/// Use the entire document pane in the interactive reader.
pub full_width: bool,
pub slides: bool,
pub plain: bool,
pub watch: bool,
Expand Down Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions tests/mermaid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/plain_snapshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 37 additions & 5 deletions tests/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@ fn run_in_pty(
envs: &[(&str, &str)],
mut respond: impl FnMut(&[u8], Duration) -> Option<Vec<u8>>,
) -> 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<Reply>,
) -> 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.
Expand Down Expand Up @@ -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,
};
Expand Down Expand Up @@ -558,6 +561,35 @@ fn reader_args(path: &std::path::Path) -> Vec<String> {
.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();
Expand Down Expand Up @@ -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]]);
Expand Down
1 change: 1 addition & 0 deletions tests/rendering_features.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down