tlc: file highlighting — type-based coloring in panel entries
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.
This commit is contained in:
@@ -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<ratatui::style::Color> {
|
||||
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<usize> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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(|| {
|
||||
|
||||
Reference in New Issue
Block a user