From b8bca75b7c8f3bd044130e0d6a268a2f2dab8eeb Mon Sep 17 00:00:00 2001 From: RivoLink Date: Wed, 8 Jul 2026 20:52:35 +0300 Subject: [PATCH] chore: terminal tab title filename --- config.toml | 8 ++++ src/app/mod.rs | 14 +++++++ src/config.rs | 25 +++++++++++- src/main.rs | 31 +++++++++++++- src/render/status.rs | 5 +-- src/runtime/mod.rs | 10 +++++ src/terminal.rs | 97 +++++++++++++++++++++++++++++++++++++++++++- src/tests/config.rs | 48 ++++++++++++++++++++++ 8 files changed, 231 insertions(+), 7 deletions(-) diff --git a/config.toml b/config.toml index 5310d72..6b6548a 100644 --- a/config.toml +++ b/config.toml @@ -72,3 +72,11 @@ watch = false # Accepted env values: 1 = true, 0 = false (other values ignored) # Default: true # code-line-numbers = true + +# Maximum tab title length (in characters) +# Controls the tab title of the terminal: "leaf: ". +# When the filename is long, it is truncated: "leaf: prefix...ext". +# Priority: LEAF_TAB_TITLE_LENGTH > this setting > default (-1) +# Minimum accepted: 20. Any other value falls back to no truncation. +# Default: -1 (no truncation) +# tab-title-length = -1 diff --git a/src/app/mod.rs b/src/app/mod.rs index d786fa6..7b84868 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -160,6 +160,7 @@ pub(crate) struct App { pub(super) file_mode: bool, pub(super) code_line_numbers: bool, max_width: Option, + tab_title_max_filename_len: Option, mouse_capture: bool, } @@ -318,6 +319,7 @@ impl App { file_mode: false, code_line_numbers: true, max_width: None, + tab_title_max_filename_len: None, mouse_capture: true, }; app.store_current_theme_preview(); @@ -341,6 +343,14 @@ impl App { self.max_width = max_width; } + pub(crate) fn set_tab_title_max_filename_len(&mut self, value: Option) { + self.tab_title_max_filename_len = value; + } + + pub(crate) fn tab_title_max_filename_len(&self) -> Option { + self.tab_title_max_filename_len + } + pub(crate) fn is_mouse_capture_enabled(&self) -> bool { self.mouse_capture } @@ -468,6 +478,10 @@ impl App { &self.filename } + pub(crate) fn title_filename(&self) -> Option<&str> { + self.has_content().then_some(self.filename.as_str()) + } + #[cfg(test)] pub(crate) fn line(&self, idx: usize) -> Option<&Line<'static>> { self.lines.get(idx) diff --git a/src/config.rs b/src/config.rs index 1687e46..1f1787a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,7 @@ use std::{ }; use anyhow::Context; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; use crate::theme::{resolve_theme_selection, CustomThemeConfig}; @@ -21,11 +21,24 @@ pub(crate) struct LeafConfig { pub(crate) extras: Vec, #[serde(rename = "code-line-numbers")] pub(crate) code_line_numbers: Option, + #[serde( + rename = "tab-title-length", + deserialize_with = "deserialize_lenient_i32" + )] + pub(crate) tab_title_length: Option, pub(crate) themes: BTreeMap, #[serde(skip)] pub(crate) config_dir: Option, } +fn deserialize_lenient_i32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = toml::Value::deserialize(deserializer)?; + Ok(value.as_integer().and_then(|n| i32::try_from(n).ok())) +} + #[derive(Default)] pub(crate) struct CliOverrides { pub(crate) width: Option, @@ -74,6 +87,16 @@ pub(crate) fn load_config(overrides: &CliOverrides) -> (LeafConfig, Option().is_ok_and(crate::is_valid_tab_title_length)); + if !leaf_tab_title_env_overrides { + if let Some(n) = config.tab_title_length { + if (0..20).contains(&n) { + warnings.push("tab-title-length is invalid, no truncation applied".to_string()); + } + } + } + let warning = if warnings.is_empty() { None } else { diff --git a/src/main.rs b/src/main.rs index 32cacd7..b8b9c9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -107,6 +107,31 @@ fn resolve_code_line_numbers(config_value: Option) -> bool { config_value.unwrap_or(true) } +const LEAF_TAB_PREFIX_LEN: usize = 6; + +pub(crate) fn is_valid_tab_title_length(n: i32) -> bool { + n == -1 || n >= 20 +} + +fn resolve_tab_title_max_filename_len(config_value: Option) -> Option { + if let Ok(val) = std::env::var("LEAF_TAB_TITLE_LENGTH") { + if let Ok(n) = val.parse::() { + if is_valid_tab_title_length(n) { + return tab_title_n_to_max_filename_len(n); + } + } + } + tab_title_n_to_max_filename_len(config_value.unwrap_or(-1)) +} + +pub(crate) fn tab_title_n_to_max_filename_len(n: i32) -> Option { + if n >= 20 { + Some((n as usize).saturating_sub(LEAF_TAB_PREFIX_LEN)) + } else { + None + } +} + fn append_config_warning(warning: &mut Option, next: Option) { let Some(next) = next else { return; @@ -188,6 +213,8 @@ fn main() -> Result<()> { let watch_from_config = user_config.watch.unwrap_or(false); let max_width = resolve_configured_width(cli_width, user_config.width); let code_line_numbers = resolve_code_line_numbers(user_config.code_line_numbers); + let tab_title_max_filename_len = + resolve_tab_title_max_filename_len(user_config.tab_title_length); if let Some(ref mut spec) = inline_spec { if spec.width.is_none() { @@ -363,6 +390,7 @@ fn main() -> Result<()> { app.set_last_content_hash(last_content_hash); app.set_watch_from_config(watch_from_config); app.set_max_width(max_width); + app.set_tab_title_max_filename_len(tab_title_max_filename_len); app.set_extras(user_config.extras); app.set_file_mode(file_mode); app.set_editor_config(Some(resolved_editor)); @@ -387,8 +415,7 @@ fn main() -> Result<()> { ); let mut stdout = io::stdout(); - print!("\x1b]0;leaf\x07"); - let _ = io::stdout().flush(); + terminal::set_tab_title(app.title_filename(), app.tab_title_max_filename_len()); runtime::debug_log(debug_input, "terminal enter start"); let mut session = TerminalSession::enter(&mut stdout)?; runtime::debug_log(debug_input, "terminal enter done"); diff --git a/src/render/status.rs b/src/render/status.rs index b4b5dfb..52a0fa7 100644 --- a/src/render/status.rs +++ b/src/render/status.rs @@ -162,7 +162,8 @@ pub(crate) fn status_goto_line_section(app: &App) -> Option>> .fg(theme.ui.status_error_fg) .bg(theme.ui.status_error_bg), ) - } else if let Some(target) = app.goto_line_target() { + } else { + let target = app.goto_line_target()?; let logical = app.line_number_at(target); Span::styled( format!(" :{} ", logical), @@ -170,8 +171,6 @@ pub(crate) fn status_goto_line_section(app: &App) -> Option>> .fg(theme.ui.status_success_fg) .bg(theme.ui.status_success_bg), ) - } else { - return None; }; Some(vec![span]) } diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index 57730be..a8be3fb 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -86,6 +86,7 @@ pub(crate) fn run( const PICKER_LOAD_POLL_INTERVAL: Duration = Duration::from_millis(50); let mut needs_redraw = !initial_draw_done; let mut pending_resize: Option = None; + let mut last_title_filename: Option = app.title_filename().map(str::to_string); sync_render_width(terminal, app, ss, themes)?; loop { @@ -97,6 +98,15 @@ pub(crate) fn run( needs_redraw = true; } + let current_title_filename = app.title_filename(); + if current_title_filename != last_title_filename.as_deref() { + crate::terminal::set_tab_title( + current_title_filename, + app.tab_title_max_filename_len(), + ); + last_title_filename = current_title_filename.map(str::to_string); + } + if needs_redraw { terminal.draw(|f| ui(f, app))?; needs_redraw = false; diff --git a/src/terminal.rs b/src/terminal.rs index c19faf1..b2006a0 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -8,7 +8,40 @@ use crossterm::{ }, }; use ratatui::{backend::CrosstermBackend, Terminal}; -use std::io; +use std::io::{self, Write}; +use std::path::Path; + +fn format_tab_title_filename(filename: &str, max_len: usize) -> String { + if filename.len() <= max_len || filename.chars().count() <= max_len { + return filename.to_string(); + } + let ext = Path::new(filename) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or(""); + let keep = max_len + .saturating_sub(3) + .saturating_sub(ext.chars().count()); + let prefix: String = filename.chars().take(keep).collect(); + format!("{prefix}...{ext}") +} + +pub(crate) fn set_tab_title(filename: Option<&str>, max_filename_len: Option) { + let mut stdout = io::stdout(); + match (filename, max_filename_len) { + (Some(name), Some(m)) if !name.is_empty() => { + let display = format_tab_title_filename(name, m); + let _ = write!(stdout, "\x1b]0;leaf: {display}\x07"); + } + (Some(name), None) if !name.is_empty() => { + let _ = write!(stdout, "\x1b]0;leaf: {name}\x07"); + } + _ => { + let _ = write!(stdout, "\x1b]0;leaf\x07"); + } + } + let _ = stdout.flush(); +} pub(crate) struct TerminalSession { raw_enabled: bool, @@ -160,3 +193,65 @@ pub(crate) fn finish_with_restore( (Ok(()), Ok(())) => Ok(()), } } + +#[cfg(test)] +mod tests { + use super::*; + + const MAX: usize = 15; + + #[test] + fn format_tab_title_filename_short_unchanged() { + assert_eq!(format_tab_title_filename("readme.md", MAX), "readme.md"); + assert_eq!( + format_tab_title_filename("notes-2026.md", MAX), + "notes-2026.md" + ); + assert_eq!(format_tab_title_filename("script.rs", MAX), "script.rs"); + assert_eq!(format_tab_title_filename("stdin", MAX), "stdin"); + assert_eq!(format_tab_title_filename("README", MAX), "README"); + assert_eq!(format_tab_title_filename(".gitignore", MAX), ".gitignore"); + assert_eq!(format_tab_title_filename(".env", MAX), ".env"); + } + + #[test] + fn format_tab_title_filename_at_limit_unchanged() { + let name = "abcdefghijklmno"; + assert_eq!(name.chars().count(), MAX); + assert_eq!(format_tab_title_filename(name, MAX), name); + } + + #[test] + fn format_tab_title_filename_truncates_with_extension() { + assert_eq!( + format_tab_title_filename("chapitre-1-introduction.md", MAX), + "chapitre-1...md" + ); + assert_eq!( + format_tab_title_filename("verylongfilename.markdown", MAX), + "very...markdown" + ); + } + + #[test] + fn format_tab_title_filename_md_output_is_exactly_max_len() { + let out = format_tab_title_filename("chapitre-1-introduction.md", MAX); + assert_eq!(out.chars().count(), MAX); + } + + #[test] + fn format_tab_title_filename_truncates_without_extension() { + assert_eq!( + format_tab_title_filename("verylongfilenamewithoutextension", MAX), + "verylongfile..." + ); + } + + #[test] + fn format_tab_title_filename_utf8_boundary_safe() { + let name = "résumé-tres-long-fichier.md"; + let out = format_tab_title_filename(name, MAX); + assert!(out.ends_with("...md")); + assert!(out.chars().count() <= MAX); + } +} diff --git a/src/tests/config.rs b/src/tests/config.rs index 0f65e65..a0dab67 100644 --- a/src/tests/config.rs +++ b/src/tests/config.rs @@ -180,6 +180,54 @@ heading_1 = "#fabd2f" ); } +#[test] +fn tab_title_n_to_max_filename_len_returns_none_for_disabled() { + assert_eq!(tab_title_n_to_max_filename_len(-1), None); + assert_eq!(tab_title_n_to_max_filename_len(-42), None); + assert_eq!(tab_title_n_to_max_filename_len(0), None); + assert_eq!(tab_title_n_to_max_filename_len(19), None); +} + +#[test] +fn tab_title_n_to_max_filename_len_subtracts_prefix_when_valid() { + assert_eq!(tab_title_n_to_max_filename_len(20), Some(14)); + assert_eq!(tab_title_n_to_max_filename_len(21), Some(15)); + assert_eq!(tab_title_n_to_max_filename_len(50), Some(44)); +} + +#[test] +fn parse_tab_title_length_valid_int() { + let toml = r#"tab-title-length = -1"#; + let config: LeafConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.tab_title_length, Some(-1)); + + let toml = r#"tab-title-length = 30"#; + let config: LeafConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.tab_title_length, Some(30)); +} + +#[test] +fn parse_tab_title_length_string_falls_back_silently() { + let toml = r#" +theme = "forest" +tab-title-length = "abc" +"#; + let config: LeafConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.tab_title_length, None); + assert_eq!(config.theme.as_deref(), Some("forest")); +} + +#[test] +fn parse_tab_title_length_bool_falls_back_silently() { + let toml = r#" +theme = "arctic" +tab-title-length = true +"#; + let config: LeafConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.tab_title_length, None); + assert_eq!(config.theme.as_deref(), Some("arctic")); +} + #[test] fn config_path_returns_some() { let path = config_path();