From 191c2956b291ce2b6aed241dfd7a1e0f0a235597 Mon Sep 17 00:00:00 2001 From: Aaron Allen Date: Fri, 1 May 2026 17:09:48 -0500 Subject: [PATCH] feat(template): add stretch-width placeholders (%*title, %*note) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Template placeholders now support a stretch marker (`%*title`, `%*note`) that expands to fill remaining terminal columns after all fixed-width elements on the line. `%min-*title` sets a minimum width floor. The `COLUMNS` env var overrides live terminal width detection; falls back to 80. Adds `stretch: bool` and `min_width: Option` to Token::Placeholder. Adds `temp-env` as a dev dependency for safe env-var manipulation in tests. Matches Ruby doing v2.1.115–v2.1.122 and discussion #391. Closes #422 --- Cargo.lock | 73 ++++++++++++++++-- Cargo.toml | 1 + crates/template/Cargo.toml | 1 + crates/template/src/parser.rs | 107 +++++++++++++++++++++++++- crates/template/src/renderer.rs | 131 +++++++++++++++++++++++++++++++- 5 files changed, 302 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85e29e1..323c1a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,7 +62,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -580,6 +580,7 @@ dependencies = [ "doing-time", "pretty_assertions", "regex", + "temp-env", "unicode-width", "yansi", ] @@ -677,7 +678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1175,7 +1176,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1253,7 +1254,7 @@ dependencies = [ "bitflags", "libc", "plain", - "redox_syscall", + "redox_syscall 0.7.4", ] [[package]] @@ -1280,6 +1281,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -1358,6 +1368,29 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1625,6 +1658,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_syscall" version = "0.7.4" @@ -1749,7 +1791,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1800,6 +1842,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "self-replace" version = "1.5.0" @@ -1970,7 +2018,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2060,6 +2108,15 @@ dependencies = [ "xattr", ] +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2070,7 +2127,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index acf666c..75c1670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ serde = { features = ["derive"], version = "1" } serde_json = "1" strsim = "0.11" sublime_fuzzy = "0.7" +temp-env = "0.3" tempfile = "3" thiserror = "2" toml = "1" diff --git a/crates/template/Cargo.toml b/crates/template/Cargo.toml index ac28c65..68d5b5e 100644 --- a/crates/template/Cargo.toml +++ b/crates/template/Cargo.toml @@ -25,3 +25,4 @@ yansi.workspace = true [dev-dependencies] pretty_assertions.workspace = true +temp-env.workspace = true diff --git a/crates/template/src/parser.rs b/crates/template/src/parser.rs index 24a989a..31f2110 100644 --- a/crates/template/src/parser.rs +++ b/crates/template/src/parser.rs @@ -7,7 +7,7 @@ use crate::colors; static COLOR_RE: LazyLock = LazyLock::new(|| Regex::new(r"%((?:[fb]g?)?#[a-fA-F0-9]{6}|[a-zA-Z_]+)").unwrap()); static PLACEHOLDER_RE: LazyLock = LazyLock::new(|| { Regex::new(concat!( - r"%(?P-?\d+)?", + r"%(?:(?P\d+)-)?(?:(?P\*)|(?P-?\d+))?", r"(?:\^(?P.))?", r"(?:(?P[ _t]|[^a-zA-Z0-9\s])(?P\d+))?", r"(?P.[ _t]?)?", @@ -43,7 +43,9 @@ pub enum Token { indent: Option, kind: TokenKind, marker: Option, + min_width: Option, prefix: Option, + stretch: bool, width: Option, }, } @@ -164,6 +166,8 @@ pub fn parse(template: &str) -> Vec { TokenMatch::Placeholder { caps, .. } => { + let stretch = caps.name("stretch").is_some(); + let min_width = caps.name("min").and_then(|m| m.as_str().parse::().ok()); let width = caps .name("width") .map(|m| m.as_str().parse::().unwrap_or(i32::MAX)); @@ -209,7 +213,9 @@ pub fn parse(template: &str) -> Vec { indent, kind, marker, + min_width, prefix, + stretch, width, }); } @@ -238,7 +244,9 @@ mod test { indent: None, kind, marker: None, + min_width: None, prefix: None, + stretch: false, width: None, } } @@ -328,7 +336,9 @@ mod test { }), kind: TokenKind::Note, marker: None, + min_width: None, prefix: Some("\u{2503} ".into()), + stretch: false, width: Some(80), }] ); @@ -354,7 +364,9 @@ mod test { }), kind: TokenKind::Note, marker: Some('>'), + min_width: None, prefix: Some(": ".into()), + stretch: false, width: None, }] ); @@ -395,7 +407,9 @@ mod test { indent: None, kind: TokenKind::Note, marker: Some('>'), + min_width: None, prefix: None, + stretch: false, width: None, }] ); @@ -425,7 +439,9 @@ mod test { indent: None, kind: TokenKind::Title, marker: None, + min_width: None, prefix: None, + stretch: false, width: Some(i32::MAX), }] ); @@ -441,7 +457,9 @@ mod test { indent: None, kind: TokenKind::Section, marker: None, + min_width: None, prefix: None, + stretch: false, width: Some(-10), }] ); @@ -457,7 +475,9 @@ mod test { indent: None, kind: TokenKind::Title, marker: None, + min_width: None, prefix: None, + stretch: false, width: Some(80), }] ); @@ -473,7 +493,9 @@ mod test { indent: None, kind: TokenKind::Note, marker: None, + min_width: None, prefix: Some(": ".into()), + stretch: false, width: None, }] ); @@ -489,7 +511,9 @@ mod test { indent: None, kind: TokenKind::Title, marker: None, + min_width: None, prefix: Some("\u{2551} ".into()), + stretch: false, width: Some(80), }] ); @@ -508,7 +532,9 @@ mod test { }), kind: TokenKind::Note, marker: None, + min_width: None, prefix: None, + stretch: false, width: None, }] ); @@ -527,7 +553,9 @@ mod test { }), kind: TokenKind::Note, marker: None, + min_width: None, prefix: None, + stretch: false, width: None, }] ); @@ -555,7 +583,84 @@ mod test { }), kind: TokenKind::Note, marker: None, + min_width: None, prefix: None, + stretch: false, + width: None, + }] + ); + } + + #[test] + fn it_parses_stretch_modifier() { + let tokens = parse("%*title"); + + assert_eq!( + tokens, + vec![Token::Placeholder { + indent: None, + kind: TokenKind::Title, + marker: None, + min_width: None, + prefix: None, + stretch: true, + width: None, + }] + ); + } + + #[test] + fn it_parses_min_width_with_stretch() { + let tokens = parse("%20-*title"); + + assert_eq!( + tokens, + vec![Token::Placeholder { + indent: None, + kind: TokenKind::Title, + marker: None, + min_width: Some(20), + prefix: None, + stretch: true, + width: None, + }] + ); + } + + #[test] + fn it_parses_min_width_stretch_with_indent() { + let tokens = parse("%20-*_4title"); + + assert_eq!( + tokens, + vec![Token::Placeholder { + indent: Some(Indent { + count: 4, + kind: IndentChar::Space, + }), + kind: TokenKind::Title, + marker: None, + min_width: Some(20), + prefix: None, + stretch: true, + width: None, + }] + ); + } + + #[test] + fn it_parses_stretch_note() { + let tokens = parse("%*note"); + + assert_eq!( + tokens, + vec![Token::Placeholder { + indent: None, + kind: TokenKind::Note, + marker: None, + min_width: None, + prefix: None, + stretch: true, width: None, }] ); diff --git a/crates/template/src/renderer.rs b/crates/template/src/renderer.rs index aae2e91..8218cda 100644 --- a/crates/template/src/renderer.rs +++ b/crates/template/src/renderer.rs @@ -172,6 +172,53 @@ pub fn render(entry: &Entry, options: &RenderOptions, config: &Config) -> String /// Render a single entry using pre-parsed template tokens. fn render_with_tokens(entry: &Entry, tokens: &[Token], options: &RenderOptions, config: &Config) -> String { let values = build_values(entry, options, config); + + // Pre-compute available stretch width if any token uses stretch. + let stretch_width: Option = if tokens.iter().any(|t| { + matches!( + t, + Token::Placeholder { + stretch: true, + .. + } + ) + }) { + let fixed: usize = tokens + .iter() + .map(|t| match t { + Token::Color(_) => 0, + Token::Literal(text) => colors::visible_len(text), + Token::Placeholder { + stretch: true, .. + } => 0, + Token::Placeholder { + indent, + kind, + marker, + prefix, + width, + .. + } => { + let raw = values.get(kind).cloned().unwrap_or_default(); + let s = format_value( + &raw, + *kind, + *width, + marker.as_ref(), + indent.as_ref(), + prefix.as_deref(), + 0, + ); + colors::visible_len(&s) + } + }) + .sum(); + let term = terminal_width(); + Some(term.saturating_sub(fixed)) + } else { + None + }; + let mut output = String::new(); for token in tokens { @@ -182,18 +229,34 @@ fn render_with_tokens(entry: &Entry, tokens: &[Token], options: &RenderOptions, indent, kind, marker, + min_width, prefix, + stretch, width, } => { let raw = values.get(kind).cloned().unwrap_or_default(); + let effective_width = if *stretch { + let avail = stretch_width.unwrap_or(0); + let min = min_width.map(|m| m as usize).unwrap_or(0); + Some(avail.max(min) as i32) + } else { + *width + }; + let effective_wrap = if *stretch && matches!(kind, TokenKind::Note | TokenKind::Odnote | TokenKind::Idnote) { + let avail = stretch_width.unwrap_or(0); + let min = min_width.map(|m| m as usize).unwrap_or(0); + avail.max(min) as u32 + } else { + options.wrap_width + }; let formatted = format_value( &raw, *kind, - *width, + effective_width, marker.as_ref(), indent.as_ref(), prefix.as_deref(), - options.wrap_width, + effective_wrap, ); output.push_str(&formatted); } @@ -203,6 +266,18 @@ fn render_with_tokens(entry: &Entry, tokens: &[Token], options: &RenderOptions, output } +/// Return the current terminal width in columns. +/// +/// Checks `COLUMNS` env var first, then falls back to 80. +fn terminal_width() -> usize { + if let Ok(cols) = std::env::var("COLUMNS") + && let Ok(n) = cols.parse::() + { + return n; + } + 80 +} + fn apply_width(raw: &str, width: Option) -> String { use unicode_width::UnicodeWidthStr; @@ -690,6 +765,58 @@ mod test { } } + mod render_stretch { + use pretty_assertions::assert_eq; + use temp_env; + + use super::*; + + #[test] + fn it_stretches_title_to_columns_env_var() { + let entry = Entry::new( + sample_date(), + "Short", + Tags::new(), + Note::new(), + "Currently", + None::, + ); + let config = sample_config(); + let options = RenderOptions { + template: "prefix: %*title :suffix".into(), + ..sample_options() + }; + + let result = temp_env::with_var("COLUMNS", Some("30"), || render(&entry, &options, &config)); + + // "prefix: " = 8, " :suffix" = 8, total fixed = 16, stretch width = 14 + // "Short" (5 chars) padded to 14 = "Short " + assert_eq!(result, "prefix: Short :suffix"); + } + + #[test] + fn it_respects_min_width_for_stretch() { + let entry = Entry::new( + sample_date(), + "Hi", + Tags::new(), + Note::new(), + "Currently", + None::, + ); + let config = sample_config(); + let options = RenderOptions { + template: "%20-*title".into(), + ..sample_options() + }; + + let result = temp_env::with_var("COLUMNS", Some("10"), || render(&entry, &options, &config)); + + // terminal=10, fixed=0, available=10, min_width=20 → effective=20 + assert_eq!(result.len(), 20); + } + } + mod render_options { use pretty_assertions::assert_eq; -- knotmirror 0.0.1