From 77750b5128e26df40d80a7b9c3c557197110ab4f Mon Sep 17 00:00:00 2001 From: vasilito Date: Fri, 19 Jun 2026 08:33:22 +0300 Subject: [PATCH] =?UTF-8?q?tlc:=20file=20highlighting=20=E2=80=94=20type-b?= =?UTF-8?q?ased=20coloring=20in=20panel=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New filehighlight.rs module categorizes files by extension into 9 types (Normal, Executable, Archive, Audio, Video, Image, Source, Documentation, Database). Each type maps to a theme palette color via file_type_color(). entry_style() in mod.rs now applies file type colors for non-directory, non-symlink files. Executable detection uses permission bits (owner_exec || group_exec || other_exec). 13 new unit tests covering all categories, case insensitivity, hidden files, and edge cases. 959 tests total, 0 failures. --- .../source/src/filemanager/filehighlight.rs | 306 ++++++++++++++++++ .../tui/tlc/source/src/filemanager/mod.rs | 9 +- 2 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 local/recipes/tui/tlc/source/src/filemanager/filehighlight.rs diff --git a/local/recipes/tui/tlc/source/src/filemanager/filehighlight.rs b/local/recipes/tui/tlc/source/src/filemanager/filehighlight.rs new file mode 100644 index 0000000000..bcf15e3c81 --- /dev/null +++ b/local/recipes/tui/tlc/source/src/filemanager/filehighlight.rs @@ -0,0 +1,306 @@ +//! File type highlighting for panel entries. +//! +//! Categorizes files by extension into types (executable, archive, +//! audio, video, image, source, documentation) for colored display. +//! +//! The category set mirrors the section names Midnight Commander uses +//! in `filehighlight.ini`. Each category maps to a [`ratatui::style::Color`] +//! drawn from the active [`crate::terminal::color::Theme`] palette, so +//! the highlight colors stay consistent with whichever skin the user has +//! selected at runtime (default-dark, mc-classic, nord, ...). +//! +//! Extension tables are compile-time `const` slices, so +//! [`categorize`] reduces to a few pointer comparisons and a single +//! lowercase allocation per call. + +/// File type categories for highlighting. +/// +/// Mirrors MC's `filehighlight.ini` `[extensions]` groups (executable, +/// directory, symlink, device, archive, audio, video, image, source, +/// doc, special). `Normal` covers files with no recognised extension +/// and no special handling needed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FileType { + /// Regular file — no special highlighting. + Normal, + /// Executable script or binary (any execute bit set). + Executable, + /// Compressed archive. + Archive, + /// Audio file. + Audio, + /// Video file. + Video, + /// Image file. + Image, + /// Source code file. + Source, + /// Documentation file. + Documentation, + /// Database file. + Database, +} + +/// Extensions classified as compressed archives. +/// +/// Compiled-in so they survive without a user-supplied +/// `filehighlight.ini` and match what MC highlights out of the box. +pub const ARCHIVE_EXTS: &[&str] = &[ + "tar", "gz", "bz2", "xz", "lz", "lzma", "zst", "zstd", "zip", "7z", "rar", "rpm", "deb", + "cpio", "iso", "dmg", "tbz", "tgz", "txz", +]; + +/// Extensions classified as audio files. +pub const AUDIO_EXTS: &[&str] = &[ + "mp3", "ogg", "wav", "flac", "aac", "m4a", "wma", "opus", "spx", "mid", "midi", +]; + +/// Extensions classified as video files. +pub const VIDEO_EXTS: &[&str] = &[ + "mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "mpg", "mpeg", "m4v", "3gp", "vob", +]; + +/// Extensions classified as image files. +pub const IMAGE_EXTS: &[&str] = &[ + "png", "jpg", "jpeg", "gif", "svg", "bmp", "tiff", "tif", "webp", "ico", "xpm", "heic", +]; + +/// Extensions classified as source code. +/// +/// Includes compiled (C/C++/Rust/Go/...) and interpreted (Python/Lua/ +/// Ruby/Perl/Shell/...) languages, plus common assembly and Lisp-family +/// extensions. +pub const SOURCE_EXTS: &[&str] = &[ + "rs", "c", "cpp", "cc", "h", "hpp", "py", "go", "js", "ts", "tsx", "jsx", "java", "kt", + "swift", "rb", "php", "pl", "lua", "sh", "bash", "zsh", "fish", "vim", "el", "lisp", "scala", + "clj", "hs", "ml", "nim", "zig", "v", "asm", "s", +]; + +/// Extensions classified as documentation. +pub const DOC_EXTS: &[&str] = &[ + "md", "txt", "pdf", "doc", "docx", "odt", "rtf", "tex", "man", "rst", "adoc", +]; + +/// Extensions classified as database files. +pub const DATABASE_EXTS: &[&str] = &["db", "sqlite", "sqlite3", "mdb", "dbf"]; + +/// Categorize a filename by extension, falling back to the executable +/// bit when no extension is present. +/// +/// The filename's extension is extracted as the substring after the +/// last `.` (none if the name contains no dot, or if the name starts +/// with a dot — e.g. `.bashrc` is treated as having no extension). +/// The extension is lowercased before lookup so the function is +/// case-insensitive. +/// +/// If no extension is present, `is_executable` is consulted: +/// - `true` → `Executable` +/// - `false` → `Normal` +/// +/// `is_executable` is **not** consulted when an extension matches an +/// entry in one of the tables — extension wins, matching MC's +/// behavior where a `.sh` script is highlighted as source rather than +/// as an executable. +#[must_use] +pub fn categorize(filename: &str, is_executable: bool) -> FileType { + let ext = extension(filename); + match ext { + None => { + if is_executable { + FileType::Executable + } else { + FileType::Normal + } + } + Some(ext) => { + let ext_lower = ext.to_ascii_lowercase(); + if contains(ARCHIVE_EXTS, &ext_lower) { + FileType::Archive + } else if contains(AUDIO_EXTS, &ext_lower) { + FileType::Audio + } else if contains(VIDEO_EXTS, &ext_lower) { + FileType::Video + } else if contains(IMAGE_EXTS, &ext_lower) { + FileType::Image + } else if contains(SOURCE_EXTS, &ext_lower) { + FileType::Source + } else if contains(DOC_EXTS, &ext_lower) { + FileType::Documentation + } else if contains(DATABASE_EXTS, &ext_lower) { + FileType::Database + } else { + FileType::Normal + } + } + } +} + +/// Map a [`FileType`] to a display color from the theme palette. +/// +/// Returns `None` for [`FileType::Normal`] so callers can fall back to +/// the default foreground color (typically the panel's +/// `_default_` core style or `theme.foreground`). +/// +/// The mapping reuses fields that already have a defined meaning in +/// every shipped skin: +/// - `Executable` → `theme.executable` +/// - `Archive` → `theme.warning` (yellow family in every default) +/// - `Audio` / `Video` / `Image` → `theme.info` +/// - `Source` → `theme.foreground` (subtle — same hue as text) +/// - `Documentation` → `theme.title_fg` (often a strong accent) +/// - `Database` → `theme.device` +#[must_use] +pub fn file_type_color( + ft: FileType, + theme: &crate::terminal::color::Theme, +) -> Option { + match ft { + FileType::Normal => None, + FileType::Executable => Some(theme.executable), + FileType::Archive => Some(theme.warning), + FileType::Audio | FileType::Video | FileType::Image => Some(theme.info), + FileType::Source => Some(theme.foreground), + FileType::Documentation => Some(theme.title_fg), + FileType::Database => Some(theme.device), + } +} + +/// Extract the lowercased extension from `filename`, if any. +/// +/// Returns `None` when the name has no dot or when the only dot is the +/// leading dot of a hidden file (`.bashrc` → no extension). +fn extension(filename: &str) -> Option<&str> { + let bytes = filename.as_bytes(); + // Find the last '.' that follows at least one non-dot character. + // Iteration is byte-based because file names we receive here come + // from the OS and are already valid UTF-8 (they were produced via + // `String` fields in the VFS layer). + let mut dot_at: Option = None; + for (i, &b) in bytes.iter().enumerate() { + if b == b'.' && i > 0 { + dot_at = Some(i); + } + } + dot_at.map(|i| &filename[i + 1..]) +} + +/// Linear search over a `const` extension table. +/// +/// `const` slices don't yet support `slice::contains` in `const` context, +/// and we want to avoid pulling in a hashing layer for ~10–35 entries. +fn contains(table: &[&str], needle: &str) -> bool { + table.iter().any(|e| *e == needle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn categorize_archive_extensions() { + assert_eq!(categorize("foo.tar", false), FileType::Archive); + assert_eq!(categorize("foo.gz", false), FileType::Archive); + assert_eq!(categorize("foo.zip", false), FileType::Archive); + assert_eq!(categorize("foo.7z", false), FileType::Archive); + assert_eq!(categorize("foo.tgz", false), FileType::Archive); + } + + #[test] + fn categorize_audio_extensions() { + assert_eq!(categorize("track.mp3", false), FileType::Audio); + assert_eq!(categorize("track.flac", false), FileType::Audio); + assert_eq!(categorize("track.ogg", false), FileType::Audio); + assert_eq!(categorize("track.opus", false), FileType::Audio); + } + + #[test] + fn categorize_source_extensions() { + assert_eq!(categorize("main.rs", false), FileType::Source); + assert_eq!(categorize("script.py", false), FileType::Source); + assert_eq!(categorize("hello.c", false), FileType::Source); + assert_eq!(categorize("lib.cpp", false), FileType::Source); + } + + #[test] + fn categorize_no_extension_executable() { + assert_eq!(categorize("Makefile", true), FileType::Executable); + assert_eq!(categorize("runme", true), FileType::Executable); + } + + #[test] + fn categorize_no_extension_normal() { + assert_eq!(categorize("README", false), FileType::Normal); + assert_eq!(categorize("LICENSE", false), FileType::Normal); + } + + #[test] + fn categorize_unknown_extension() { + assert_eq!(categorize("blob.xyz", false), FileType::Normal); + assert_eq!(categorize("foo.unknownext", false), FileType::Normal); + } + + #[test] + fn categorize_case_insensitive() { + assert_eq!(categorize("archive.TAR", false), FileType::Archive); + assert_eq!(categorize("lib.Rs", false), FileType::Source); + assert_eq!(categorize("doc.PDF", false), FileType::Documentation); + assert_eq!(categorize("photo.JPG", false), FileType::Image); + } + + #[test] + fn categorize_video_and_image_and_doc_and_db() { + assert_eq!(categorize("movie.mp4", false), FileType::Video); + assert_eq!(categorize("movie.mkv", false), FileType::Video); + assert_eq!(categorize("photo.png", false), FileType::Image); + assert_eq!(categorize("photo.gif", false), FileType::Image); + assert_eq!(categorize("readme.md", false), FileType::Documentation); + assert_eq!(categorize("data.sqlite", false), FileType::Database); + assert_eq!(categorize("data.sqlite3", false), FileType::Database); + } + + #[test] + fn categorize_hidden_file_has_no_extension() { + // Leading dot files are treated as having no extension, so + // they fall back to the executable-bit branch. + assert_eq!(categorize(".bashrc", false), FileType::Normal); + assert_eq!(categorize(".profile", true), FileType::Executable); + } + + #[test] + fn extension_extraction() { + assert_eq!(extension("foo.rs"), Some("rs")); + assert_eq!(extension("foo.tar.gz"), Some("gz")); + assert_eq!(extension("foo"), None); + assert_eq!(extension(".bashrc"), None); + assert_eq!(extension("a."), Some("")); + } + + #[test] + fn contains_helper_finds_entries() { + assert!(contains(ARCHIVE_EXTS, "tar")); + assert!(contains(AUDIO_EXTS, "flac")); + assert!(!contains(ARCHIVE_EXTS, "flac")); + } + + #[test] + fn file_type_color_returns_none_for_normal() { + // Build a minimal Theme by copying DEFAULT_THEME-style fields. + // We avoid coupling the test to DEFAULT_THEME's exact RGB; any + // constructed Theme will do for the contract check. + let theme = crate::terminal::color::DEFAULT_THEME; + assert!(file_type_color(FileType::Normal, &theme).is_none()); + } + + #[test] + fn file_type_color_returns_some_for_other_types() { + let theme = crate::terminal::color::DEFAULT_THEME; + assert!(file_type_color(FileType::Executable, &theme).is_some()); + assert!(file_type_color(FileType::Archive, &theme).is_some()); + assert!(file_type_color(FileType::Audio, &theme).is_some()); + assert!(file_type_color(FileType::Video, &theme).is_some()); + assert!(file_type_color(FileType::Image, &theme).is_some()); + assert!(file_type_color(FileType::Source, &theme).is_some()); + assert!(file_type_color(FileType::Documentation, &theme).is_some()); + assert!(file_type_color(FileType::Database, &theme).is_some()); + } +} diff --git a/local/recipes/tui/tlc/source/src/filemanager/mod.rs b/local/recipes/tui/tlc/source/src/filemanager/mod.rs index 859be77d24..628b27bce7 100644 --- a/local/recipes/tui/tlc/source/src/filemanager/mod.rs +++ b/local/recipes/tui/tlc/source/src/filemanager/mod.rs @@ -13,6 +13,7 @@ pub mod delete_dialog; pub mod edit_history; pub mod exec; pub mod external_panelize; +pub mod filehighlight; pub mod filtered_view; pub mod find; pub mod help; @@ -2442,7 +2443,13 @@ fn entry_style( } else if e.is_symlink() { Style::default().fg(theme.symlink) } else { - Style::default().fg(core_default.map(|p| p.fg).unwrap_or(theme.foreground)) + let is_executable = e.stat.permissions.owner_exec + || e.stat.permissions.group_exec + || e.stat.permissions.other_exec; + let ft = filehighlight::categorize(&e.name, is_executable); + let color = filehighlight::file_type_color(ft, theme) + .unwrap_or_else(|| core_default.map(|p| p.fg).unwrap_or(theme.foreground)); + Style::default().fg(color) }; if cursor && marked { let pair = core_markselect.unwrap_or_else(|| {