From 83e45b5aeb95465e6d285a1f26c17d2c16d8e9e4 Mon Sep 17 00:00:00 2001 From: Arseni Edel Date: Sat, 4 Apr 2026 15:08:04 -0300 Subject: [PATCH] feat: nerd icons support feat: nerd icons support --- .gitignore | 4 + README.md | 63 +++++++++++++++ config.example.toml | 27 +++++++ internal/app/app.go | 5 +- internal/config/config.go | 138 +++++++++++++++++++++++++++++--- internal/ui/panel/icons.go | 72 +++++++++++++++++ internal/ui/panel/icons_test.go | 104 ++++++++++++++++++++++++ internal/ui/panel/panel.go | 16 ++-- internal/ui/panel/panel_view.go | 19 ++++- 9 files changed, 425 insertions(+), 23 deletions(-) create mode 100644 internal/ui/panel/icons.go create mode 100644 internal/ui/panel/icons_test.go diff --git a/.gitignore b/.gitignore index 9b61b57..cfd720e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ .claude mdc dist/ +QWEN.md +prompts/ +.gitignore +.qwen diff --git a/README.md b/README.md index 531b9ee..7098e4d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Midday Commander (mdc) brings the classic dual-panel file management paradigm in - **Live theme picker** - browse and preview themes with Ctrl-T - **Multi-file selection** - tag files with Insert or Shift+Arrow for batch operations - **Quick search** - start typing to jump to matching files instantly +- **Nerd Font icons** - file type icons with per-extension customization - **External editor/viewer** - opens files in `$EDITOR` and `$PAGER` - **Mouse support** - clickable menu bar and panel interaction - **Go to path** - quickly jump to any directory with `~` expansion @@ -236,6 +237,68 @@ fkey_label_bg = "blue" Colors can be hex values (`"#89b4fa"`), ANSI color numbers (`"4"`), or palette references (`"blue"`). Any missing values fall back to the built-in default theme. +## Nerd Font Icons + +Display Nerd Font icons next to file and folder names in the panel for a more visual file browsing experience. + +### Prerequisites + +You need a [Nerd Font](https://www.nerdfonts.com/) installed and set as your terminal font. Popular choices: + +- **Meslo Nerd Font** — monospaced, great for file managers +- **JetBrains Mono Nerd Font** — developer-focused +- **FiraCode Nerd Font** — clean and readable + +### Enabling Icons + +Icons are **disabled by default**. Set `enabled = true` in your config: + +```toml +[icons] +enabled = true +``` + +### Icon Configuration + +```toml +[icons] +enabled = true +folder = "" # Icon for directories (default: nf-fa-folder) +file = "" # Default icon for files (default: nf-fa-file) + +[icons.extensions] +txt = "" # nf-fa-file_text +pdf = "" # nf-fa-file_pdf +png = "" # nf-fa-file_image +jpg = "" +go = "" +py = "" +js = "" +ts = "" # nf-seti-typescript +zip = "" # nf-fa-file_archive +``` + +### Icon Resolution Behavior + +1. **Directories** — always use the `folder` icon, regardless of extension config. +2. **Files with matching extension** — look up the extension (case-insensitive) in `[icons.extensions]`. +3. **Files without a match** — fall back to the `file` icon. +4. **Files without an extension** (e.g. `Makefile`) — also fall back to the `file` icon. + +### Built-in Defaults + +The icon system ships with defaults for 50+ file extensions, so icons work immediately when enabled without any config needed. Defaults cover: + +- **Documents** — txt, md, pdf, doc, docx, xls, xlsx +- **Images** — png, jpg, jpeg, gif, svg, ico, bmp, webp +- **Archives** — zip, tar, gz, bz2, 7z, rar, xz +- **Code** — go, py, js, jsx, ts, tsx, rs, rb, java, c, cpp, h, hpp, cs, php, sh, bash +- **Config/Markup** — json, yaml, yml, toml, xml, html, css +- **Media** — mp3, wav, mp4, avi, mkv +- **Go** — mod, sum + +You only need to add entries to `[icons.extensions]` if you want to override the defaults or add custom extensions. + ## Contributing Contributions are welcome. Please open an issue to discuss significant changes before submitting a pull request. diff --git a/config.example.toml b/config.example.toml index 9ec34c0..353fcfb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -46,3 +46,30 @@ select_down = "shift+down" # Search quick_search = "ctrl+s" + +# ─── Nerd Font Icons ───────────────────────── +# Display Nerd Font icons next to file/folder names in the panel. +# Requires a Nerd Font installed in your terminal (e.g. Meslo Nerd Font, +# JetBrains Mono Nerd Font, FiraCode Nerd Font). +[icons] +# Set to true to enable icons (default: false). +enabled = false +# Icon for directories (default: nf-fa-folder). +folder = "" +# Default icon for files without a matching extension (default: nf-fa-file). +file = "" + +# Extension → icon mapping. Only a few examples shown; add any you need. +[icons.extensions] +txt = "" # nf-fa-file_text +pdf = "" # nf-fa-file_pdf +png = "" # nf-fa-file_image +jpg = "" +jpeg = "" +go = "" +py = "" +js = "" +ts = "" # nf-seti-typescript +zip = "" # nf-fa-file_archive +tar = "" +gz = "" diff --git a/internal/app/app.go b/internal/app/app.go index 983c286..aef91aa 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -99,11 +99,12 @@ func New() Model { lfs := local.New(string(filepath.Separator)) panelKM := panelKeyMapFromConfig(cfg.Keys) + iconResolver := panel.NewIconResolver(cfg.Icons) - left := panel.New(lfs, cwd, panelKM) + left := panel.New(lfs, cwd, panelKM, iconResolver) left.SetActive(true) - right := panel.New(lfs, home, panelKM) + right := panel.New(lfs, home, panelKM, iconResolver) th := theme.Default() if cfg.Theme != "" { diff --git a/internal/config/config.go b/internal/config/config.go index 8e82e7e..91d9e80 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,96 @@ type Config struct { Theme string `toml:"theme"` Keys KeyBindings `toml:"keys"` Behavior BehaviorConfig `toml:"behavior"` + Icons IconsConfig `toml:"icons"` +} + +// IconsConfig controls the display of Nerd Font icons in the file panel. +type IconsConfig struct { + // Enabled controls whether icons are displayed (default: false). + Enabled bool `toml:"enabled"` + // Icon shown for directories. + Folder string `toml:"folder"` + // Icon shown for files without a matching extension. + File string `toml:"file"` + // Mapping of file extension (without dot) to icon. + Extensions map[string]string `toml:"extensions"` +} + +// DefaultIconsConfig returns the default icon configuration. +func DefaultIconsConfig() IconsConfig { + return IconsConfig{ + Enabled: false, + Folder: "", // nf-fa-folder + File: "", // nf-fa-file + Extensions: map[string]string{ + // Documents + "txt": "", // nf-fa-file_text + "md": "", + "pdf": "", // nf-fa-file_pdf + "doc": "", // nf-fa-file_word + "docx": "", + "xls": "", // nf-fa-file_excel + "xlsx": "", + + // Images + "png": "", // nf-fa-file_image + "jpg": "", + "jpeg": "", + "gif": "", + "svg": "", + "ico": "", + "bmp": "", + "webp": "", + + // Archives + "zip": "", // nf-fa-file_archive + "tar": "", + "gz": "", + "bz2": "", + "7z": "", + "rar": "", + "xz": "", + + // Code + "go": "", // nf-seti-go + "py": "", // nf-dev-python + "js": "", // nf-seti-javascript + "jsx": "", + "ts": "", // nf-seti-typescript + "tsx": "", + "rs": "", // nf-custom-rust + "rb": "", // nf-dev-ruby + "java": "", // nf-custom-java + "c": "", // nf-seti-c + "cpp": "", // nf-seti-cpp + "h": "", + "hpp": "", + "cs": "󰌛", // nf-custom-csharp + "php": "", // nf-custom-php + "sh": "", // nf-oct-terminal + "bash": "", + + // Config / Markup + "json": "", // nf-oct-file_json + "yaml": "", // nf-dev-config + "yml": "", + "toml": "", // nf-md-file_toml + "xml": "󰗀", // nf-md-xml + "html": "", // nf-dev-html5 + "css": "", // nf-dev-css3 + + // Media + "mp3": "", // nf-fa-volume_up (audio) + "wav": "", + "mp4": "", // nf-fa-film (video) + "avi": "", + "mkv": "", + + // Go-specific + "mod": "", // nf-md-go (module) + "sum": "", + }, + } } // BehaviorConfig controls configurable behaviors. @@ -58,9 +148,9 @@ type KeyBindings struct { QuickSearch StringOrList `toml:"quick_search"` // Go to path - GoTo StringOrList `toml:"goto"` - FuzzyFind StringOrList `toml:"fuzzy_find"` - Bookmarks StringOrList `toml:"bookmarks"` + GoTo StringOrList `toml:"goto"` + FuzzyFind StringOrList `toml:"fuzzy_find"` + Bookmarks StringOrList `toml:"bookmarks"` Help StringOrList `toml:"help"` ThemePicker StringOrList `toml:"theme_picker"` CmdExec StringOrList `toml:"cmd_exec"` @@ -88,12 +178,10 @@ func Default() Config { keys := DefaultKeyBindings() normalizeAllKeys(&keys) return Config{ - Theme: "", - Behavior: BehaviorConfig{ - EnterAction: "edit", - SpaceAction: "preview", - }, - Keys: keys, + Theme: "", + Behavior: BehaviorConfig{EnterAction: "edit", SpaceAction: "preview"}, + Icons: DefaultIconsConfig(), + Keys: keys, } } @@ -125,9 +213,9 @@ func DefaultKeyBindings() KeyBindings { QuickSearch: StringOrList{"ctrl+s"}, - GoTo: StringOrList{"ctrl+g"}, - FuzzyFind: StringOrList{"f9", "ctrl+p"}, - Bookmarks: StringOrList{"f2", "ctrl+b"}, + GoTo: StringOrList{"ctrl+g"}, + FuzzyFind: StringOrList{"f9", "ctrl+p"}, + Bookmarks: StringOrList{"f2", "ctrl+b"}, Help: StringOrList{"f1"}, ThemePicker: StringOrList{"ctrl+t"}, CmdExec: StringOrList{"ctrl+r"}, @@ -163,6 +251,9 @@ func Load() Config { mergeKeys(&cfg.Keys, &fileCfg.Keys) normalizeAllKeys(&cfg.Keys) + // Merge icons config: start with defaults, overlay file config. + mergeIcons(&cfg.Icons, &fileCfg.Icons) + return cfg } @@ -202,6 +293,29 @@ func mergeKey(dst *StringOrList, src StringOrList) { } } +// mergeIcons merges src IconsConfig into dst. Non-empty fields from src override dst. +// Extensions are merged (src overrides dst on conflict). +func mergeIcons(dst, src *IconsConfig) { + if src.Folder != "" { + dst.Folder = src.Folder + } + if src.File != "" { + dst.File = src.File + } + // Enabled is only overridden if explicitly set in file config. + // Since the default is false and we want false to mean "use default", + // we always use the file value (false or true). + dst.Enabled = src.Enabled + if src.Extensions != nil { + if dst.Extensions == nil { + dst.Extensions = make(map[string]string) + } + for ext, icon := range src.Extensions { + dst.Extensions[ext] = icon + } + } +} + // normalizeKey converts user-friendly "shift+fN" (N=1..8) to the BubbleTea // key string "f(N+12)". BubbleTea v1.x reports Shift+F1..F8 as F13..F20. func normalizeKey(k string) string { diff --git a/internal/ui/panel/icons.go b/internal/ui/panel/icons.go new file mode 100644 index 0000000..6958434 --- /dev/null +++ b/internal/ui/panel/icons.go @@ -0,0 +1,72 @@ +package panel + +import ( + "path/filepath" + "strings" + "unicode/utf8" + + "github.com/kooler/MiddayCommander/internal/config" +) + +// IconResolver resolves file/directory names to Nerd Font icons based on +// configuration. It is immutable after creation and safe for concurrent use. +type IconResolver struct { + enabled bool + folder string + file string + extIcons map[string]string +} + +// NewIconResolver creates a resolver from the given icon configuration. +// If cfg.Enabled is false, the resolver will return empty strings. +func NewIconResolver(cfg config.IconsConfig) IconResolver { + if !cfg.Enabled { + return IconResolver{} + } + r := IconResolver{ + enabled: true, + folder: cfg.Folder, + file: cfg.File, + extIcons: cfg.Extensions, + } + if r.folder == "" { + r.folder = "\uF07B" // nf-fa-folder + } + if r.file == "" { + r.file = "\uF016" // nf-fa-file + } + if r.extIcons == nil { + r.extIcons = make(map[string]string) + } + return r +} + +// ResolveIcon returns the Nerd Font icon for the given entry. +// Returns an empty string if icons are disabled or no match is found. +// isDir determines whether to use the folder icon or extension-based lookup. +func (r IconResolver) ResolveIcon(name string, isDir bool) string { + if !r.enabled { + return "" + } + if isDir { + return r.folder + } + ext := strings.TrimPrefix(filepath.Ext(name), ".") + if ext == "" { + return r.file + } + if icon, ok := r.extIcons[strings.ToLower(ext)]; ok { + return icon + } + return r.file +} + +// IconWidth returns the display width (in characters/rune count) of the icon +// that would be rendered for the given entry. Returns 0 if icons are disabled. +func (r IconResolver) IconWidth(name string, isDir bool) int { + icon := r.ResolveIcon(name, isDir) + if icon == "" { + return 0 + } + return utf8.RuneCountInString(icon) +} diff --git a/internal/ui/panel/icons_test.go b/internal/ui/panel/icons_test.go new file mode 100644 index 0000000..1837379 --- /dev/null +++ b/internal/ui/panel/icons_test.go @@ -0,0 +1,104 @@ +package panel + +import ( + "testing" + + "github.com/kooler/MiddayCommander/internal/config" +) + +func TestIconResolver_Disabled(t *testing.T) { + r := NewIconResolver(config.IconsConfig{Enabled: false}) + if got := r.ResolveIcon("test.go", false); got != "" { + t.Errorf("expected empty string when disabled, got %q", got) + } + if got := r.ResolveIcon("src", true); got != "" { + t.Errorf("expected empty string for dir when disabled, got %q", got) + } + if got := r.IconWidth("test.go", false); got != 0 { + t.Errorf("expected width 0 when disabled, got %d", got) + } +} + +func TestIconResolver_Directory(t *testing.T) { + r := NewIconResolver(config.DefaultIconsConfig()) + // Manually enable since default is false. + r = NewIconResolver(config.IconsConfig{ + Enabled: true, + Folder: "\uF07B", + File: "\uF016", + Extensions: config.DefaultIconsConfig().Extensions, + }) + if got := r.ResolveIcon("src", true); got != "\uF07B" { + t.Errorf("expected folder icon, got %q", got) + } +} + +func TestIconResolver_ExtensionMatch(t *testing.T) { + r := NewIconResolver(config.IconsConfig{ + Enabled: true, + File: "\uF016", + Folder: "\uF07B", + Extensions: map[string]string{ + "go": "\uF17E", + "txt": "\uF0F6", + }, + }) + tests := []struct { + name string + isDir bool + want string + }{ + {"main.go", false, "\uF17E"}, + {"readme.TXT", false, "\uF0F6"}, // case-insensitive + {"notes.txt", false, "\uF0F6"}, + {"image.png", false, "\uF016"}, // no match → fallback to file icon + {"src", true, "\uF07B"}, // directory → folder icon + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := r.ResolveIcon(tt.name, tt.isDir) + if got != tt.want { + t.Errorf("ResolveIcon(%q, %v) = %q, want %q", tt.name, tt.isDir, got, tt.want) + } + }) + } +} + +func TestIconResolver_DefaultFallback(t *testing.T) { + r := NewIconResolver(config.IconsConfig{ + Enabled: true, + Folder: "\uF07B", + File: "\uF016", + Extensions: map[string]string{"go": "\uF17E"}, + }) + // Unknown extension → file icon + if got := r.ResolveIcon("unknown.xyz", false); got != "\uF016" { + t.Errorf("expected file icon for unknown extension, got %q", got) + } + // No extension → file icon + if got := r.ResolveIcon("Makefile", false); got != "\uF016" { + t.Errorf("expected file icon for no extension, got %q", got) + } + // Directory → folder icon + if got := r.ResolveIcon("src", true); got != "\uF07B" { + t.Errorf("expected folder icon, got %q", got) + } +} + +func TestIconResolver_IconWidth(t *testing.T) { + r := NewIconResolver(config.IconsConfig{ + Enabled: true, + File: "\uF016", + Extensions: map[string]string{"go": "\uF17E"}, + }) + if got := r.IconWidth("main.go", false); got != 1 { + t.Errorf("expected width 1, got %d", got) + } + if got := r.IconWidth("unknown.xyz", false); got != 1 { + t.Errorf("expected width 1 for unknown ext, got %d", got) + } + r2 := NewIconResolver(config.IconsConfig{Enabled: false}) + if got := r2.IconWidth("main.go", false); got != 0 { + t.Errorf("expected width 0 when disabled, got %d", got) + } +} diff --git a/internal/ui/panel/panel.go b/internal/ui/panel/panel.go index b57a509..dc2bc5b 100644 --- a/internal/ui/panel/panel.go +++ b/internal/ui/panel/panel.go @@ -53,17 +53,19 @@ type Model struct { realFS vfs.FS // the original filesystem (to restore when leaving archive) realPath string // the directory containing the archive file - keyMap KeyMap + keyMap KeyMap + iconResolver IconResolver } // New creates a new panel browsing the given directory. -func New(filesystem vfs.FS, path string, km KeyMap) Model { +func New(filesystem vfs.FS, path string, km KeyMap, iconResolver IconResolver) Model { return Model{ - fs: filesystem, - path: path, - selected: make(map[int]bool), - sortMode: SortByName, - keyMap: km, + fs: filesystem, + path: path, + selected: make(map[int]bool), + sortMode: SortByName, + keyMap: km, + iconResolver: iconResolver, } } diff --git a/internal/ui/panel/panel_view.go b/internal/ui/panel/panel_view.go index 20653ed..7b72db9 100644 --- a/internal/ui/panel/panel_view.go +++ b/internal/ui/panel/panel_view.go @@ -11,6 +11,9 @@ import ( "github.com/kooler/MiddayCommander/internal/ui/theme" ) +// iconPadding is the number of spaces between the icon and the filename. +const iconPadding = 1 + // View renders the panel as a string. func (m Model) View(th theme.Theme) string { if m.width <= 0 || m.height <= 0 { @@ -94,6 +97,13 @@ func (m Model) renderRow(idx, width int, th theme.Theme) string { isCursor := idx == m.cursor isSelected := m.selected[idx] + // Resolve icon and its display width + icon := m.iconResolver.ResolveIcon(name, isDir) + iconWidth := 0 + if icon != "" { + iconWidth = m.iconResolver.IconWidth(name, isDir) + } + // Determine columns: name, size, time sizeStr := "" timeStr := "" @@ -108,15 +118,20 @@ func (m Model) renderRow(idx, width int, th theme.Theme) string { sizeStr = "" } - // Column widths: time=12, size=7, rest=name + // Column widths: time=12, size=7, rest=name (minus icon width + padding) timeWidth := 12 sizeWidth := 7 - nameWidth := width - sizeWidth - timeWidth - 2 // 2 spaces between columns + iconSpace := iconWidth + iconPadding + nameWidth := width - iconSpace - sizeWidth - timeWidth - 2 // 2 spaces between columns if nameWidth < 4 { nameWidth = 4 } + // Build the line: [icon][name] [size] [time] namePart := truncOrPad(name, nameWidth) + if icon != "" { + namePart = icon + strings.Repeat(" ", iconPadding) + namePart + } sizePart := padLeft(sizeStr, sizeWidth) timePart := truncOrPad(timeStr, timeWidth)