Skip to main content

lattice_core/ui/
icons.rs

1//! Renderer-agnostic file-type icon resolver (DESIGN.md §5.6.7).
2//!
3//! Returns a glyph + renderer-neutral [`IconColor`] for any file or
4//! directory path. The glyph is plain text -- the `lattice-file-tree`
5//! render path embeds it directly in the rope so the model stays
6//! usable from any renderer. The colour is mapped to the renderer's
7//! native style at draw time (ratatui `Color`, GPUI `Hsla`, etc.).
8//!
9//! Two glyph palettes, picked by the `nerd_fonts` argument:
10//!
11//! - `nerd_fonts == true` -- Nerd Fonts v3 glyphs (Private Use Area
12//!   codepoints, require a patched font installed in the terminal).
13//! - `nerd_fonts == false` -- BMP-block fallback (Geometric Shapes,
14//!   Misc Symbols). These render in any modern monospace font --
15//!   DejaVu, JetBrains Mono, Fira, Cascadia, Consolas -- without
16//!   needing a special install. Same six-bucket categorisation
17//!   colour-tinted by language so a `.rs` and a `.py` still look
18//!   different at a glance.
19//!
20//! Resolution order: exact filename → extension → default.
21
22use std::path::Path;
23
24/// Renderer-neutral colour. Concrete renderers (ratatui, GPUI, ...)
25/// map the named variants to their own palette and `Rgb` straight
26/// through.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum IconColor {
29    /// Truecolor RGB packed as `0xRRGGBB`.
30    Rgb(u32),
31    /// "default foreground" -- the renderer uses its current style.
32    Reset,
33    /// Named yellow; each renderer maps it to its own palette.
34    Yellow,
35    /// Named dark grey; each renderer maps it to its own palette.
36    DarkGray,
37    /// The renderer's named blue.
38    Blue,
39    /// The renderer's named cyan.
40    Cyan,
41    /// The renderer's named green.
42    Green,
43    /// The renderer's named white.
44    White,
45}
46
47/// Resolve `(glyph, colour)` for a directory or file entry.
48///
49/// `is_dir` selects the directory branch (folder glyph regardless
50/// of name); for files the lookup falls through name → extension →
51/// default. `nerd_fonts == false` returns a BMP-block fallback
52/// glyph (◆ ≡ ◇ ■ ♪ ▶ · -- one per file family) with the colour
53/// still resolved per type.
54///
55/// **Directories in BMP-fallback mode get `▤ `** (U+25A4, Geometric
56/// Shapes -- present in every modern monospace font, and a drawer
57/// of files is what it reads as).
58///
59/// This returned two blank spaces until 2026-08-16. The reasoning
60/// was that the file tree already emits `▾ ` / `▸ ` as its
61/// expansion-state marker, so a folder glyph would be redundant --
62/// and that the one BMP glyph that obviously reads as "folder" is
63/// the same right-pointing triangle `▸`, which would render a
64/// collapsed row as `▸ ▸ name`, two identical arrows back to back.
65///
66/// Both points are true of the TREE and neither is true of **oil**,
67/// whose rows are bare filenames with no marker at all (its
68/// `icon_byte` is 0). So in BMP mode -- the DEFAULT, since
69/// `ui.nerd_fonts` is opt-in -- an oil directory was the only entry
70/// in the listing with no glyph, while every file had one. The
71/// collision constraint is real; the conclusion "therefore nothing"
72/// was drawn from the tree alone and applied to both.
73///
74/// `▤` satisfies the constraint properly: it collides with neither
75/// expansion marker nor any file-family glyph, so the tree reads
76/// `▸ ▤ name` -- state + kind + name, the nvim-tree / VS Code
77/// convention -- and oil reads `▤ name`. Two cells wide like every
78/// file glyph, so the name column never shifts between row kinds.
79///
80/// Nerd-fonts mode keeps the distinct folder glyph (`󰉋 ` is a
81/// private-use codepoint that doesn't collide with the
82/// geometric-shape marker), so directories there read as
83/// `▸ 󰉋 name` -- marker + folder picto + name -- which is the
84/// convention nvim-tree and VS Code follow.
85/// **DL.6: the colour half has no consumers left.** `directory-listing-mode`
86/// resolves entry colour through registered `listing.*` theme elements
87/// now, so a theme owns the palette; this table's `IconColor` is only
88/// still reachable because the glyph and the colour are produced by the
89/// same ~200-arm match and splitting them is churn without a user-visible
90/// win. New callers want [`glyph_for_entry`] plus a theme element — not
91/// this.
92pub fn entry_visual(path: &Path, is_dir: bool, nerd_fonts: bool) -> (&'static str, IconColor) {
93    if is_dir {
94        if nerd_fonts {
95            return ("󰉋 ", IconColor::Blue);
96        }
97        // BMP fallback: a drawer, not blank padding. Collides with
98        // neither the tree's `▸`/`▾` marker nor any file-family
99        // glyph, and stays 2 cells so the name column is stable.
100        return ("▤ ", IconColor::Blue);
101    }
102    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
103    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
104    if nerd_fonts {
105        nerd_glyph_and_color(name, ext)
106    } else {
107        (ascii_glyph(name, ext), ext_color(name, ext))
108    }
109}
110
111/// BMP-block fallback glyph for a file by name + extension. One of
112/// six family buckets (config, document, image, archive, audio/video,
113/// executable) plus a default middle-dot for source code and
114/// everything else. All glyphs are 2 cells wide (one BMP char + a
115/// trailing space) so they align with the nerd-font palette.
116fn ascii_glyph(name: &str, ext: &str) -> &'static str {
117    // Filenames that read as a manifest/build/config first, no
118    // matter what extension they nominally carry. Mirrors the
119    // explicit-name branch in `nerd_glyph_and_color` so the two
120    // palettes stay in lockstep.
121    match name {
122        "Cargo.toml"
123        | "Cargo.lock"
124        | "package.json"
125        | "package-lock.json"
126        | "tsconfig.json"
127        | "tsconfig.base.json"
128        | "tsconfig.build.json"
129        | "jsconfig.json"
130        | "pyproject.toml"
131        | "Pipfile"
132        | "Pipfile.lock"
133        | "setup.py"
134        | "setup.cfg"
135        | "requirements.txt"
136        | "requirements-dev.txt"
137        | "requirements-test.txt"
138        | "Gemfile"
139        | "Gemfile.lock"
140        | "Rakefile"
141        | "go.mod"
142        | "go.sum"
143        | "go.work"
144        | "Brewfile"
145        | "Brewfile.lock.json"
146        | "Justfile"
147        | "justfile"
148        | "Makefile"
149        | "makefile"
150        | "GNUmakefile"
151        | "BSDmakefile"
152        | "Makefile.am"
153        | "CMakeLists.txt"
154        | "Dockerfile"
155        | "dockerfile"
156        | "Containerfile"
157        | "containerfile"
158        | "docker-compose.yml"
159        | "docker-compose.yaml"
160        | "compose.yml"
161        | "compose.yaml"
162        | ".dockerignore"
163        | ".gitignore"
164        | ".gitattributes"
165        | ".gitmodules"
166        | ".gitconfig"
167        | ".gitmessage"
168        | ".editorconfig"
169        | ".env"
170        | ".envrc"
171        | ".npmrc"
172        | ".nvmrc"
173        | ".node-version"
174        | ".eslintrc"
175        | ".eslintrc.json"
176        | ".eslintrc.js"
177        | ".eslintrc.yml"
178        | ".eslintrc.cjs"
179        | ".prettierrc"
180        | ".prettierrc.json"
181        | ".prettierrc.js"
182        | ".prettierrc.yml"
183        | "Procfile"
184        | "MANIFEST.in"
185        | "uv.lock" => return "◆ ",
186        "LICENSE" | "LICENCE" | "LICENSE.txt" | "LICENCE.txt" | "LICENSE.md" | "LICENCE.md" => {
187            return "≡ ";
188        }
189        _ => {}
190    }
191    match ext {
192        // Config / manifest
193        "toml" | "yaml" | "yml" | "json" | "jsonc" | "json5" | "ini" | "cfg" | "conf"
194        | "config" | "properties" | "prop" | "rc" | "env" | "envrc" | "dotenv" | "tf"
195        | "tfvars" | "hcl" | "nix" | "lock" => "◆ ",
196        // Document / prose
197        "md" | "mdx" | "markdown" | "rst" | "txt" | "text" | "adoc" | "asciidoc" | "org"
198        | "man" | "roff" | "groff" | "pdf" | "doc" | "docx" | "odt" | "rtf" | "xls" | "xlsx"
199        | "ods" | "numbers" | "ppt" | "pptx" | "odp" | "keynote" | "tex" | "ltx" | "sty"
200        | "bib" | "cls" | "ipynb" | "csv" | "tsv" => "≡ ",
201        // Image / vector
202        "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tiff" | "tif" | "ico" | "svg"
203        | "svgz" | "heic" | "heif" | "avif" | "psd" | "xcf" | "ai" | "eps" | "sketch" | "fig"
204        | "figma" => "◇ ",
205        // Archive / packaged binary
206        "zip" | "tar" | "gz" | "tgz" | "bz2" | "xz" | "zst" | "lz4" | "7z" | "rar" | "iso"
207        | "cab" | "deb" | "rpm" | "pkg" | "apk" | "snap" => "■ ",
208        // Audio + video share the music-note marker -- they read
209        // as "media" in this fallback palette.
210        "mp3" | "ogg" | "flac" | "wav" | "m4a" | "aac" | "wma" | "opus" | "aiff" | "mp4"
211        | "mkv" | "avi" | "mov" | "wmv" | "flv" | "webm" | "m4v" | "ogv" => "♪ ",
212        // Executable / installable
213        "exe" | "msi" | "app" => "▶ ",
214        // Source code, dotfiles, and anything not categorised
215        // above. Colour still discriminates language thanks to
216        // `ext_color`.
217        _ => "· ",
218    }
219}
220
221/// Glyph-only resolver. Used by the file-tree / oil rope renderers
222/// that embed the glyph in editable text and let the renderer apply
223/// styling spans. Equivalent to the first half of `entry_visual`.
224pub fn glyph_for_entry(path: &Path, is_dir: bool, nerd_fonts: bool) -> &'static str {
225    entry_visual(path, is_dir, nerd_fonts).0
226}
227
228fn nerd_glyph_and_color(name: &str, ext: &str) -> (&'static str, IconColor) {
229    match name {
230        "Makefile" | "makefile" | "GNUmakefile" | "BSDmakefile" | "Makefile.am" => {
231            return (" ", IconColor::Rgb(0x6D8086));
232        }
233        "CMakeLists.txt" => return (" ", IconColor::Rgb(0x6D8086)),
234        "Dockerfile" | "dockerfile" | "Containerfile" | "containerfile" => {
235            return ("󰡨 ", IconColor::Rgb(0x458EE6));
236        }
237        "docker-compose.yml" | "docker-compose.yaml" | "compose.yml" | "compose.yaml" => {
238            return ("󰡨 ", IconColor::Rgb(0x458EE6));
239        }
240        ".dockerignore" => return ("󰡨 ", IconColor::DarkGray),
241        ".gitignore" | ".gitattributes" | ".gitmodules" | ".gitconfig" | ".gitmessage" => {
242            return ("󰒓 ", IconColor::Rgb(0x41535B));
243        }
244        "LICENSE" | "LICENCE" | "LICENSE.txt" | "LICENCE.txt" | "LICENSE.md" | "LICENCE.md" => {
245            return (" ", IconColor::Yellow);
246        }
247        ".editorconfig" => return (" ", IconColor::DarkGray),
248        ".env" | ".envrc" => return (" ", IconColor::Rgb(0xFAF743)),
249        "package.json" => return (" ", IconColor::Rgb(0xE8274B)),
250        "package-lock.json" => return (" ", IconColor::DarkGray),
251        ".npmrc" | ".nvmrc" | ".node-version" => return (" ", IconColor::Rgb(0xE8274B)),
252        "tsconfig.json" | "tsconfig.base.json" | "tsconfig.build.json" => {
253            return (" ", IconColor::Rgb(0x519ABA));
254        }
255        "jsconfig.json" => return (" ", IconColor::Rgb(0xCBCB41)),
256        ".eslintrc" | ".eslintrc.json" | ".eslintrc.js" | ".eslintrc.yml" | ".eslintrc.cjs" => {
257            return (" ", IconColor::Rgb(0x4B32C3));
258        }
259        ".prettierrc" | ".prettierrc.json" | ".prettierrc.js" | ".prettierrc.yml" => {
260            return (" ", IconColor::Rgb(0x56B3B4));
261        }
262        "vite.config.ts" | "vite.config.js" => return (" ", IconColor::Rgb(0xBD34FE)),
263        "vitest.config.ts" | "vitest.config.js" => return (" ", IconColor::Rgb(0xBD34FE)),
264        "webpack.config.js" | "webpack.config.ts" => return ("󰜫 ", IconColor::Rgb(0x519ABA)),
265        "next.config.js" | "next.config.ts" | "next.config.mjs" => {
266            return (" ", IconColor::White);
267        }
268        "nuxt.config.ts" | "nuxt.config.js" => return (" ", IconColor::Rgb(0x00C58E)),
269        "svelte.config.js" | "svelte.config.ts" => return (" ", IconColor::Rgb(0xFF3E00)),
270        "astro.config.mjs" | "astro.config.ts" => return (" ", IconColor::Rgb(0xE8274B)),
271        "tailwind.config.js" | "tailwind.config.ts" | "tailwind.config.cjs" => {
272            return (" ", IconColor::Rgb(0x38BDF8));
273        }
274        "postcss.config.js" | "postcss.config.cjs" => return (" ", IconColor::DarkGray),
275        "requirements.txt" | "requirements-dev.txt" | "requirements-test.txt" => {
276            return (" ", IconColor::Rgb(0xFFBC03));
277        }
278        "Pipfile" | "Pipfile.lock" => return (" ", IconColor::Rgb(0xFFBC03)),
279        "pyproject.toml" => return (" ", IconColor::Rgb(0xFFBC03)),
280        "setup.py" | "setup.cfg" | "MANIFEST.in" => return (" ", IconColor::Rgb(0xFFBC03)),
281        "uv.lock" => return (" ", IconColor::Rgb(0xDE5FE9)),
282        "Gemfile" | "Gemfile.lock" => return (" ", IconColor::Rgb(0xFF5252)),
283        "Rakefile" => return (" ", IconColor::Rgb(0xFF5252)),
284        "go.mod" | "go.sum" | "go.work" => return (" ", IconColor::Rgb(0x519ABA)),
285        "Cargo.toml" | "Cargo.lock" => return ("󱘗 ", IconColor::Rgb(0xFF8C00)),
286        "Brewfile" | "Brewfile.lock.json" => return (" ", IconColor::Rgb(0xFBB040)),
287        "Justfile" | "justfile" => return (" ", IconColor::Rgb(0x6D8086)),
288        "Procfile" => return (" ", IconColor::DarkGray),
289        _ => {}
290    }
291
292    match ext {
293        "rs" => ("󱘗 ", IconColor::Rgb(0xDEA584)),
294        "c" | "h" => (" ", IconColor::Rgb(0xA8B9CC)),
295        "cpp" | "cxx" | "cc" | "c++" | "hpp" | "hxx" | "hh" | "h++" => {
296            (" ", IconColor::Rgb(0xF34B7D))
297        }
298        "cs" | "csx" => ("󰌛 ", IconColor::Rgb(0x596706)),
299        "java" => (" ", IconColor::Rgb(0xCC3E44)),
300        "kt" | "kts" => (" ", IconColor::Rgb(0x7F52FF)),
301        "swift" => (" ", IconColor::Rgb(0xE37933)),
302        "go" => (" ", IconColor::Rgb(0x519ABA)),
303        "zig" => (" ", IconColor::Rgb(0xF7A41D)),
304        "nim" | "nims" | "nimble" => (" ", IconColor::Rgb(0xF3D400)),
305        "v" | "vv" => (" ", IconColor::Rgb(0x5D87BF)),
306        "odin" => (" ", IconColor::Rgb(0x60AEEF)),
307        "asm" | "s" | "S" | "nasm" => (" ", IconColor::DarkGray),
308
309        "py" | "pyw" | "pyi" => (" ", IconColor::Rgb(0xFFBC03)),
310        "rb" | "erb" | "gemspec" | "rake" => (" ", IconColor::Rgb(0xFF5252)),
311        "php" | "php3" | "php4" | "php5" | "phtml" => (" ", IconColor::Rgb(0xA074C4)),
312        "lua" => (" ", IconColor::Rgb(0x51A0CF)),
313        "pl" | "pm" | "t" | "pod" => (" ", IconColor::Rgb(0x519ABA)),
314        "r" | "rmd" | "rnw" | "rproj" => (" ", IconColor::Rgb(0x2266BA)),
315        "jl" => (" ", IconColor::Rgb(0xA270BA)),
316        "dart" => (" ", IconColor::Rgb(0x03589C)),
317        "groovy" | "gvy" => (" ", IconColor::Rgb(0x4298B8)),
318        "tcl" | "tk" => (" ", IconColor::DarkGray),
319
320        "hs" | "lhs" => (" ", IconColor::Rgb(0xA074C4)),
321        "ex" | "exs" | "heex" | "leex" => (" ", IconColor::Rgb(0xA074C4)),
322        "erl" | "hrl" => (" ", IconColor::Rgb(0xB83998)),
323        "clj" | "cljs" | "cljc" | "edn" => (" ", IconColor::Rgb(0x8DC149)),
324        "scala" | "sbt" | "sc" => (" ", IconColor::Rgb(0xCC3E44)),
325        "ml" | "mli" | "mll" | "mly" => (" ", IconColor::Rgb(0xE37933)),
326        "fs" | "fsi" | "fsx" | "fsproj" => (" ", IconColor::Rgb(0x519ABA)),
327        "elm" => (" ", IconColor::Rgb(0x1293D8)),
328        "cr" => (" ", IconColor::DarkGray),
329        "d" | "di" => (" ", IconColor::Rgb(0xB03931)),
330        "purs" => (" ", IconColor::Rgb(0x14213D)),
331        "rkt" | "rktl" | "rktd" => (" ", IconColor::Rgb(0x9F1D20)),
332        "lisp" | "lsp" | "el" | "elc" => (" ", IconColor::Rgb(0x9B59B6)),
333        "scm" | "ss" => (" ", IconColor::Rgb(0x9B59B6)),
334
335        "sh" | "bash" | "zsh" | "ksh" | "csh" | "tcsh" => (" ", IconColor::Rgb(0x4D5A5E)),
336        "fish" => (" ", IconColor::Rgb(0x4D5A5E)),
337        "ps1" | "psm1" | "psd1" | "ps1xml" => ("󰨊 ", IconColor::Rgb(0x4273CA)),
338        "bat" | "cmd" => (" ", IconColor::Rgb(0xC1F12E)),
339
340        "html" | "htm" | "xhtml" | "shtml" => (" ", IconColor::Rgb(0xE44D26)),
341        "css" => (" ", IconColor::Rgb(0x42A5F5)),
342        "scss" | "sass" => (" ", IconColor::Rgb(0xF55385)),
343        "less" => (" ", IconColor::Rgb(0x563D7C)),
344        "js" | "mjs" | "cjs" => (" ", IconColor::Rgb(0xCBCB41)),
345        "ts" => (" ", IconColor::Rgb(0x519ABA)),
346        "tsx" => (" ", IconColor::Rgb(0x1354BF)),
347        "jsx" => (" ", IconColor::Rgb(0x519ABA)),
348        "vue" => (" ", IconColor::Rgb(0x8DC149)),
349        "svelte" => (" ", IconColor::Rgb(0xFF3E00)),
350        "astro" => (" ", IconColor::Rgb(0xE8274B)),
351        "njk" => ("󰗀 ", IconColor::Rgb(0x1BA354)),
352        "hbs" | "handlebars" | "mustache" => (" ", IconColor::Rgb(0xF7931E)),
353        "ejs" => (" ", IconColor::Rgb(0xA91E50)),
354        "pug" | "jade" => (" ", IconColor::Rgb(0xE44B23)),
355        "coffee" | "litcoffee" => (" ", IconColor::Rgb(0xCBCB41)),
356        "wasm" => (" ", IconColor::Rgb(0x5C4CDB)),
357        "wat" => (" ", IconColor::Rgb(0x5C4CDB)),
358
359        "json" | "jsonc" | "json5" => (" ", IconColor::Rgb(0xCBCB41)),
360        "yaml" | "yml" => (" ", IconColor::Rgb(0x6D8086)),
361        "toml" => (" ", IconColor::Rgb(0x9C4221)),
362        "xml" | "xsl" | "xslt" | "xsd" | "dtd" | "rss" | "atom" | "plist" => {
363            ("󰗀 ", IconColor::Rgb(0xE37933))
364        }
365        "ini" | "cfg" | "conf" | "config" | "properties" | "prop" | "rc" => {
366            (" ", IconColor::Rgb(0x6D8086))
367        }
368        "env" | "envrc" | "dotenv" => (" ", IconColor::Rgb(0xFAF743)),
369        "csv" | "tsv" => (" ", IconColor::Rgb(0x89E051)),
370
371        "sql" | "mysql" | "pgsql" | "psql" | "plsql" | "hql" => (" ", IconColor::Rgb(0xDAD8D8)),
372        "sqlite" | "sqlite3" | "db" => (" ", IconColor::Rgb(0x003B57)),
373        "prisma" => (" ", IconColor::Rgb(0x5A67D8)),
374        "graphql" | "gql" => (" ", IconColor::Rgb(0xE535AB)),
375
376        "md" | "mdx" | "markdown" => ("󰍔 ", IconColor::White),
377        "rst" => (" ", IconColor::White),
378        "txt" | "text" => (" ", IconColor::White),
379        "pdf" => (" ", IconColor::Rgb(0xB30B00)),
380        "doc" | "docx" | "odt" | "rtf" => (" ", IconColor::Rgb(0x295394)),
381        "xls" | "xlsx" | "ods" | "numbers" => (" ", IconColor::Rgb(0x217346)),
382        "ppt" | "pptx" | "odp" | "keynote" => (" ", IconColor::Rgb(0xB7472A)),
383        "tex" | "ltx" | "sty" | "bib" | "cls" => (" ", IconColor::DarkGray),
384        "org" => (" ", IconColor::Rgb(0x77AA99)),
385        "adoc" | "asciidoc" => (" ", IconColor::White),
386        "man" | "roff" | "groff" => (" ", IconColor::DarkGray),
387        "ipynb" => (" ", IconColor::Rgb(0xF37726)),
388
389        "png" | "jpg" | "jpeg" | "gif" | "bmp" | "webp" | "tiff" | "tif" | "ico" | "psd"
390        | "xcf" | "heic" | "heif" | "avif" => (" ", IconColor::Rgb(0xA074C4)),
391        "svg" | "svgz" => ("󰜡 ", IconColor::Rgb(0xFFB13B)),
392        "ai" | "eps" => (" ", IconColor::Rgb(0xFF7C00)),
393        "sketch" | "fig" | "figma" => (" ", IconColor::Rgb(0xFDB300)),
394
395        "mp4" | "mkv" | "avi" | "mov" | "wmv" | "flv" | "webm" | "m4v" | "ogv" => {
396            (" ", IconColor::Rgb(0xFD971F))
397        }
398        "mp3" | "ogg" | "flac" | "wav" | "m4a" | "aac" | "wma" | "opus" | "aiff" => {
399            ("󰎆 ", IconColor::Rgb(0x00AFFF))
400        }
401
402        "zip" | "tar" | "gz" | "tgz" | "bz2" | "xz" | "zst" | "lz4" | "7z" | "rar" | "cab"
403        | "iso" => (" ", IconColor::Rgb(0xECA517)),
404        "deb" | "rpm" | "pkg" | "apk" | "snap" => (" ", IconColor::Rgb(0xECA517)),
405
406        "ttf" | "otf" | "woff" | "woff2" | "eot" => (" ", IconColor::Rgb(0xFFAFF3)),
407
408        "pem" | "key" | "crt" | "cer" | "der" | "p12" | "pfx" | "p8" | "pub" => {
409            ("󰌇 ", IconColor::Rgb(0xD4AF37))
410        }
411        "gpg" | "asc" | "sig" => ("󰌇 ", IconColor::Rgb(0xD4AF37)),
412
413        "exe" | "msi" | "app" => (" ", IconColor::Rgb(0x9F0500)),
414        "dll" | "so" | "dylib" | "lib" | "a" => (" ", IconColor::DarkGray),
415        "o" | "obj" | "pyc" | "class" => (" ", IconColor::DarkGray),
416
417        "patch" | "diff" => (" ", IconColor::Rgb(0x41535B)),
418
419        "gitignore" | "gitmodules" | "gitattributes" | "gitconfig" => {
420            ("󰒓 ", IconColor::Rgb(0x41535B))
421        }
422
423        "dockerfile" | "containerfile" => ("󰡨 ", IconColor::Rgb(0x458EE6)),
424        "tf" | "tfvars" => (" ", IconColor::Rgb(0x844FBA)),
425        "hcl" => (" ", IconColor::Rgb(0x844FBA)),
426        "nix" => (" ", IconColor::Rgb(0x7EB7E4)),
427        "proto" | "protobuf" => (" ", IconColor::Rgb(0x5BA4CF)),
428        "thrift" => (" ", IconColor::DarkGray),
429        "k8s" | "helm" => (" ", IconColor::Rgb(0x326CE5)),
430
431        "mk" | "mak" => (" ", IconColor::Rgb(0x6D8086)),
432        "cmake" => (" ", IconColor::Rgb(0x6D8086)),
433        "bazel" | "bzl" => (" ", IconColor::Rgb(0x76D275)),
434        "ninja" => (" ", IconColor::DarkGray),
435        "gradle" => (" ", IconColor::Rgb(0x4298B8)),
436
437        "vim" | "vimrc" | "nvimrc" => (" ", IconColor::Rgb(0x019833)),
438        "lock" => ("󰌾 ", IconColor::DarkGray),
439        "log" => (" ", IconColor::DarkGray),
440        "pid" => (" ", IconColor::DarkGray),
441        "tmp" | "temp" => (" ", IconColor::DarkGray),
442        "bak" | "backup" | "old" => (" ", IconColor::DarkGray),
443        "swp" | "swo" => (" ", IconColor::DarkGray),
444        "mdump" | "dmp" => (" ", IconColor::DarkGray),
445
446        _ => (" ", IconColor::Reset),
447    }
448}
449
450fn ext_color(name: &str, ext: &str) -> IconColor {
451    match name {
452        "Makefile" | "makefile" | "CMakeLists.txt" => return IconColor::Rgb(0x6D8086),
453        "Dockerfile" | "dockerfile" => return IconColor::Blue,
454        ".gitignore" | ".gitattributes" | ".gitmodules" => return IconColor::DarkGray,
455        "LICENSE" | "LICENCE" => return IconColor::Yellow,
456        _ => {}
457    }
458    match ext {
459        "rs" => IconColor::Rgb(0xDEA584),
460        "c" | "h" | "cpp" | "cxx" | "hpp" => IconColor::Blue,
461        "cs" | "java" | "scala" => IconColor::Rgb(0xCC3E44),
462        "kt" | "kts" => IconColor::Rgb(0x7F52FF),
463        "swift" => IconColor::Rgb(0xE37933),
464        "go" => IconColor::Cyan,
465        "zig" | "nim" => IconColor::Yellow,
466        "py" | "pyw" | "pyi" => IconColor::Rgb(0xFFBC03),
467        "rb" | "erb" => IconColor::Rgb(0xFF5252),
468        "php" => IconColor::Rgb(0xA074C4),
469        "lua" => IconColor::Blue,
470        "r" | "jl" => IconColor::Blue,
471        "dart" => IconColor::Cyan,
472        "hs" | "lhs" => IconColor::Rgb(0xA074C4),
473        "ex" | "exs" => IconColor::Rgb(0xA074C4),
474        "erl" | "hrl" => IconColor::Rgb(0xB83998),
475        "clj" | "cljs" => IconColor::Green,
476        "js" | "mjs" | "cjs" | "coffee" => IconColor::Rgb(0xCBCB41),
477        "ts" | "tsx" | "jsx" => IconColor::Blue,
478        "html" | "htm" => IconColor::Rgb(0xE44D26),
479        "css" | "less" => IconColor::Blue,
480        "scss" | "sass" => IconColor::Rgb(0xF55385),
481        "vue" | "svelte" => IconColor::Green,
482        "toml" => IconColor::Yellow,
483        "json" | "jsonc" | "json5" => IconColor::Yellow,
484        "yaml" | "yml" => IconColor::Green,
485        "xml" => IconColor::Rgb(0xE37933),
486        "sh" | "bash" | "zsh" | "fish" => IconColor::Green,
487        "ps1" | "psm1" => IconColor::Blue,
488        "vim" | "vimrc" => IconColor::Green,
489        "md" | "mdx" | "rst" | "txt" => IconColor::White,
490        "sql" => IconColor::DarkGray,
491        "graphql" | "gql" => IconColor::Rgb(0xE535AB),
492        "tf" | "hcl" => IconColor::Rgb(0x844FBA),
493        "nix" => IconColor::Blue,
494        "prisma" => IconColor::Rgb(0x5A67D8),
495        "lock" => IconColor::DarkGray,
496        "log" => IconColor::DarkGray,
497        "png" | "jpg" | "jpeg" | "gif" | "svg" | "bmp" | "webp" | "ico" => IconColor::Rgb(0xA074C4),
498        "mp4" | "mkv" | "avi" | "mov" => IconColor::Rgb(0xFD971F),
499        "mp3" | "flac" | "wav" | "ogg" => IconColor::Cyan,
500        "zip" | "tar" | "gz" | "bz2" | "7z" | "rar" => IconColor::Rgb(0xECA517),
501        "pdf" => IconColor::Rgb(0xB30B00),
502        _ => IconColor::Reset,
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use std::path::PathBuf;
510
511    #[test]
512    fn directory_returns_dir_glyph() {
513        let (glyph, _) = entry_visual(&PathBuf::from("src"), true, true);
514        assert_eq!(glyph, "󰉋 ");
515    }
516
517    #[test]
518    fn nerd_fonts_false_gives_directories_a_glyph_of_their_own() {
519        // 2026-08-16: this used to assert two blank spaces. The
520        // rationale was the file tree's `▸` / `▾` marker already
521        // saying "directory" -- true of the TREE, and never true of
522        // oil, whose rows are bare names with no marker at all. In
523        // BMP mode an oil directory was therefore the one entry with
524        // no glyph while every file had one.
525        let (glyph, _) = entry_visual(&PathBuf::from("src"), true, false);
526        assert_eq!(glyph, "▤ ");
527    }
528
529    /// The constraint that ruled out the obvious BMP folder glyph: `▸`
530    /// is the tree's collapsed-directory marker, so reusing it would
531    /// render `▸ ▸ name` -- two identical arrows back to back. Pin that
532    /// the chosen glyph collides with neither marker nor any file
533    /// family, since "it looks like something else" is the only way
534    /// this choice can be wrong.
535    #[test]
536    fn the_bmp_directory_glyph_collides_with_nothing() {
537        let dir = entry_visual(&PathBuf::from("src"), true, false).0;
538
539        for marker in ["▸ ", "▾ "] {
540            assert_ne!(
541                dir, marker,
542                "must not duplicate the file-tree expansion marker"
543            );
544        }
545        for family in ["· ", "≡ ", "■ ", "▶ ", "◆ ", "◇ ", "♪ "] {
546            assert_ne!(dir, family, "must not read as a file family");
547        }
548        assert_eq!(
549            dir.chars().count(),
550            2,
551            "same 2-cell width as every file glyph, so the name column \
552             does not shift between file and directory rows"
553        );
554    }
555
556    #[test]
557    fn nerd_fonts_false_picks_glyph_by_file_family() {
558        // Config family
559        assert_eq!(
560            entry_visual(&PathBuf::from("Cargo.toml"), false, false).0,
561            "◆ "
562        );
563        assert_eq!(
564            entry_visual(&PathBuf::from("config.yaml"), false, false).0,
565            "◆ "
566        );
567        // Document family
568        assert_eq!(
569            entry_visual(&PathBuf::from("README.md"), false, false).0,
570            "≡ "
571        );
572        assert_eq!(
573            entry_visual(&PathBuf::from("LICENSE"), false, false).0,
574            "≡ "
575        );
576        // Image family
577        assert_eq!(
578            entry_visual(&PathBuf::from("logo.png"), false, false).0,
579            "◇ "
580        );
581        // Archive family
582        assert_eq!(
583            entry_visual(&PathBuf::from("dist.zip"), false, false).0,
584            "■ "
585        );
586        // Media family
587        assert_eq!(
588            entry_visual(&PathBuf::from("song.mp3"), false, false).0,
589            "♪ "
590        );
591        assert_eq!(
592            entry_visual(&PathBuf::from("clip.mp4"), false, false).0,
593            "♪ "
594        );
595        // Source code + everything else → middle dot
596        assert_eq!(
597            entry_visual(&PathBuf::from("main.rs"), false, false).0,
598            "· "
599        );
600        assert_eq!(
601            entry_visual(&PathBuf::from("script.py"), false, false).0,
602            "· "
603        );
604        assert_eq!(
605            entry_visual(&PathBuf::from(".gitignore"), false, false).0,
606            "◆ "
607        );
608    }
609
610    #[test]
611    fn rust_file_gets_orange_rgb() {
612        let (glyph, color) = entry_visual(&PathBuf::from("main.rs"), false, true);
613        assert_eq!(glyph, "󱘗 ");
614        assert_eq!(color, IconColor::Rgb(0xDEA584));
615    }
616
617    #[test]
618    fn unknown_ext_falls_back_to_default_glyph() {
619        let (glyph, _) = entry_visual(&PathBuf::from("binary.bin"), false, true);
620        assert_eq!(glyph, " ");
621    }
622
623    #[test]
624    fn dockerfile_exact_name_match() {
625        let (glyph, color) = entry_visual(&PathBuf::from("Dockerfile"), false, true);
626        assert_eq!(glyph, "󰡨 ");
627        assert_eq!(color, IconColor::Rgb(0x458EE6));
628    }
629
630    #[test]
631    fn glyph_only_helper_matches_visual_first_field() {
632        let path = PathBuf::from("App.tsx");
633        let g = glyph_for_entry(&path, false, true);
634        let (g2, _) = entry_visual(&path, false, true);
635        assert_eq!(g, g2);
636    }
637}