From 780150500d76bea7647a9995e8c89632d730588e Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Fri, 24 Apr 2026 14:44:49 +0300 Subject: [PATCH] Add auto icon mode fallback and Nerd Font docs --- README.md | 32 +++++++++++++++++++++++ internal/config/config.go | 53 +++++++++++++++++++++++++++++++++++++++ internal/fs/preview.go | 24 ++++++++++++++++-- internal/ui/icon_mode.go | 37 +++++++++++++++++++++++++++ internal/ui/model.go | 22 ++++++++++++---- internal/ui/pane.go | 43 +++++++++++++++++++++++-------- 6 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 internal/ui/icon_mode.go diff --git a/README.md b/README.md index 37c18a8..b80defb 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,20 @@ - Asynchronous copy/move with progress and background mode - Theme support and configurable layout/columns +## Font requirement (icons) + +For file icons, `vcom` expects a Nerd Font in your terminal profile. + +Default behavior is `ui.icon_mode = "auto"`: + +- if a Nerd Font is detected, `vcom` uses Nerd icons +- if not, `vcom` falls back to ASCII icons automatically + +You can force behavior in config: + +- `ui.icon_mode = "nerd"`: always use Nerd icons +- `ui.icon_mode = "ascii"`: always use ASCII icons + Preview mode (`F9` / `i`) temporarily replaces the inactive pane and shows: - directory listing preview @@ -60,6 +74,17 @@ Download the release `.deb` for `v0.1.2`, then install: sudo apt install ./vcom_0.1.2_amd64.deb ``` +Install a Nerd Font (example): + +```bash +wget -qO /tmp/JetBrainsMono.zip https://github.com/ryanoasis/nerd-fonts/releases/latest/download/JetBrainsMono.zip +mkdir -p ~/.local/share/fonts/JetBrainsMonoNerd +unzip -o /tmp/JetBrainsMono.zip -d ~/.local/share/fonts/JetBrainsMonoNerd +fc-cache -fv +``` + +Then set your terminal font to a Nerd Font variant (for example, `JetBrainsMono Nerd Font`). + ### Arch Linux A `PKGBUILD` is included in the repository: @@ -80,6 +105,13 @@ Optional config lookup order: Reference config: [vcom.toml](/home/vrubel/projects/vcom/vcom.toml) +Icon mode example: + +```toml +[ui] +icon_mode = "auto" # auto | nerd | ascii +``` + ## Themes Built-in themes: diff --git a/internal/config/config.go b/internal/config/config.go index 4ca2b2a..22a600f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,7 @@ type StartupConfig struct { type UIConfig struct { AppTitle string `toml:"app_title"` Theme string `toml:"theme"` + IconMode string `toml:"icon_mode"` ShowTitleBar bool `toml:"show_title_bar"` ShowFooter bool `toml:"show_footer"` Border string `toml:"border"` @@ -76,6 +77,7 @@ func Default() Config { UI: UIConfig{ AppTitle: "vcom", Theme: "catppuccin-mocha", + IconMode: "auto", ShowTitleBar: true, ShowFooter: true, Border: "rounded", @@ -138,10 +140,49 @@ func Load(explicitPath string) (Config, string, error) { return cfg, path, nil } +func Save(cfg Config, path string) (string, error) { + if err := cfg.Validate(); err != nil { + return "", err + } + + targetPath := strings.TrimSpace(path) + if targetPath == "" { + var err error + targetPath, err = DefaultUserPath() + if err != nil { + return "", err + } + } + + absPath, err := filepath.Abs(targetPath) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", targetPath, err) + } + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(absPath), err) + } + + data, err := toml.Marshal(cfg) + if err != nil { + return "", fmt.Errorf("marshal config: %w", err) + } + if err := os.WriteFile(absPath, data, 0o644); err != nil { + return "", fmt.Errorf("write %s: %w", absPath, err) + } + return absPath, nil +} + func (c *Config) Validate() error { if c.UI.Theme == "" { return errors.New("ui.theme must not be empty") } + switch strings.ToLower(strings.TrimSpace(c.UI.IconMode)) { + case "", "auto": + c.UI.IconMode = "auto" + case "nerd", "ascii": + default: + return errors.New("ui.icon_mode must be one of: auto, nerd, ascii") + } if strings.TrimSpace(c.UI.AppTitle) == "" { c.UI.AppTitle = "vcom" } @@ -213,3 +254,15 @@ func resolvePath(explicitPath string) (string, bool, error) { return "", false, nil } + +func DefaultUserPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + xdgDir := os.Getenv("XDG_CONFIG_HOME") + if xdgDir == "" { + xdgDir = filepath.Join(homeDir, ".config") + } + return filepath.Join(xdgDir, "vcom", "vcom.toml"), nil +} diff --git a/internal/fs/preview.go b/internal/fs/preview.go index 50351ff..66db83e 100644 --- a/internal/fs/preview.go +++ b/internal/fs/preview.go @@ -64,6 +64,7 @@ type PreviewOptions struct { DirectoryPreviewLimit int HumanReadableSize bool ThemeName string + UseNerdIcons bool } func BuildPreview(entry Entry, options PreviewOptions) Preview { @@ -317,7 +318,7 @@ func buildDirectoryPreview(path string, options PreviewOptions) string { if entry.IsParent { continue } - icon := previewIcon(entry) + icon := previewIcon(entry, options.UseNerdIcons) size := "" if !entry.IsDir { @@ -338,7 +339,26 @@ func buildDirectoryPreview(path string, options PreviewOptions) string { return strings.Join(lines, "\n") } -func previewIcon(entry Entry) string { +func previewIcon(entry Entry, useNerdIcons bool) string { + if !useNerdIcons { + switch entry.Category() { + case "directory": + return "[D]" + case "config": + return "[C]" + case "text": + return "[T]" + case "image": + return "[I]" + case "executable": + return "[X]" + case "archive": + return "[A]" + default: + return "[F]" + } + } + switch entry.Category() { case "directory": return "" diff --git a/internal/ui/icon_mode.go b/internal/ui/icon_mode.go new file mode 100644 index 0000000..b73f7fd --- /dev/null +++ b/internal/ui/icon_mode.go @@ -0,0 +1,37 @@ +package ui + +import ( + "os/exec" + "runtime" + "strings" +) + +func resolveIconMode(mode string) (bool, string) { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "ascii": + return false, "Icon mode: ASCII" + case "nerd": + return true, "" + case "", "auto": + default: + return true, "" + } + + if runtime.GOOS != "linux" { + return true, "" + } + if _, err := exec.LookPath("fc-list"); err != nil { + return true, "" + } + + out, err := exec.Command("fc-list", ":", "family").Output() + if err != nil { + return true, "" + } + text := strings.ToLower(string(out)) + if strings.Contains(text, "nerd font") { + return true, "" + } + + return false, "Nerd Font not found: using ASCII icons" +} diff --git a/internal/ui/model.go b/internal/ui/model.go index f8579db..b8d792c 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -141,6 +141,7 @@ type Model struct { configPath string palette theme.Palette keys KeyMap + nerdIcons bool width int height int @@ -199,6 +200,10 @@ func NewModel(cfg config.Config, configPath string) (Model, error) { status: "Ready", copyProgress: make(chan tea.Msg, 256), } + model.nerdIcons, model.status = resolveIconMode(cfg.UI.IconMode) + if model.status == "" { + model.status = "Ready" + } model.previewModel = viewport.New(0, 0) if err := model.reloadPane(PaneLeft, ""); err != nil { @@ -546,7 +551,7 @@ func (m Model) View() string { if m.active == PaneLeft { panels = lipgloss.JoinHorizontal( lipgloss.Top, - renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft)), + renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft), m.nerdIcons), gap, renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight), ) @@ -555,15 +560,15 @@ func (m Model) View() string { lipgloss.Top, renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight), gap, - renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight)), + renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight), m.nerdIcons), ) } } else { panels = lipgloss.JoinHorizontal( lipgloss.Top, - renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft)), + renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft), m.nerdIcons), gap, - renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight)), + renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight), m.nerdIcons), ) } @@ -841,6 +846,7 @@ func (m Model) loadPreviewCmd() tea.Cmd { DirectoryPreviewLimit: m.cfg.Preview.DirectoryPreviewLimit, HumanReadableSize: m.cfg.Browser.HumanReadableSize, ThemeName: m.cfg.UI.Theme, + UseNerdIcons: m.nerdIcons, } return func() tea.Msg { @@ -1150,7 +1156,13 @@ func (m *Model) cycleTheme() (tea.Model, tea.Cmd) { } m.cfg.UI.Theme = next m.palette = palette - m.status = fmt.Sprintf("Theme: %s", next) + savedPath, saveErr := config.Save(m.cfg, m.configPath) + if saveErr != nil { + m.status = fmt.Sprintf("Theme: %s (save failed: %v)", next, saveErr) + return m, nil + } + m.configPath = savedPath + m.status = fmt.Sprintf("Theme: %s (saved)", next) return m, nil } diff --git a/internal/ui/pane.go b/internal/ui/pane.go index 59ff78d..aad579f 100644 --- a/internal/ui/pane.go +++ b/internal/ui/pane.go @@ -179,6 +179,7 @@ func renderPane( height int, active bool, hoverIndex int, + useNerdIcons bool, ) string { if width <= 0 || height <= 0 { return "" @@ -208,8 +209,8 @@ func renderPane( Render(renderPaneHeader(pane, cfg, palette, innerWidth, active, headerBg)) rowsHeight := max(innerHeight-2, 1) - headerRow := renderColumnsHeader(cfg, innerWidth, palette, bodyBg) - rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex, bodyBg) + headerRow := renderColumnsHeader(cfg, innerWidth, palette, bodyBg, useNerdIcons) + rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex, bodyBg, useNerdIcons) content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows) return box.Render(content) } @@ -231,8 +232,8 @@ func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette Render(pathStyle.Render(truncateMiddle(compactPath(pane.Path, cfg.UI.PathDisplay), pathWidth))) } -func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, background lipgloss.Color) string { - columns := buildColumns(cfg, width) +func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, background lipgloss.Color, useNerdIcons bool) string { + columns := buildColumns(cfg, width, useNerdIcons) parts := make([]string, 0, len(columns)) for idx, column := range columns { style := lipgloss.NewStyle(). @@ -254,7 +255,7 @@ func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette, ba Render(strings.Join(parts, "")) } -func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool, hoverIndex int, background lipgloss.Color) string { +func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool, hoverIndex int, background lipgloss.Color, useNerdIcons bool) string { if len(pane.Entries) == 0 { return lipgloss.NewStyle(). Width(width). @@ -274,7 +275,7 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, entry := pane.Entries[idx] isSelected := idx == pane.Cursor && active marked := !entry.IsParent && pane.IsMarked(entry.Path) - row := renderEntryRow(entry, cfg, width, isSelected, marked, idx == hoverIndex, active, palette, background) + row := renderEntryRow(entry, cfg, width, isSelected, marked, idx == hoverIndex, active, palette, background, useNerdIcons) lines = append(lines, row) } for len(lines) < visibleHeight { @@ -287,8 +288,8 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, Render(strings.Join(lines, "\n")) } -func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, marked bool, hovered bool, active bool, palette theme.Palette, baseBackground lipgloss.Color) string { - columns := buildColumns(cfg, width) +func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, marked bool, hovered bool, active bool, palette theme.Palette, baseBackground lipgloss.Color, useNerdIcons bool) string { + columns := buildColumns(cfg, width, useNerdIcons) rowBackground := baseBackground switch { case marked: @@ -340,7 +341,7 @@ type columnSpec struct { Value func(entry vfs.Entry, human bool) string } -func buildColumns(cfg config.Config, totalWidth int) []columnSpec { +func buildColumns(cfg config.Config, totalWidth int, useNerdIcons bool) []columnSpec { fixed := []columnSpec{} if cfg.Browser.Columns.Permissions { @@ -453,7 +454,7 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec { Width: nameWidth, MinWidth: minNameWidth, Value: func(entry vfs.Entry, _ bool) string { - return entryIcon(entry) + " " + entry.DisplayName() + return entryIcon(entry, useNerdIcons) + " " + entry.DisplayName() }, } @@ -583,7 +584,27 @@ func trimToWidthLeft(value string, maxWidth int) string { return string(runes[start:]) } -func entryIcon(entry vfs.Entry) string { +func entryIcon(entry vfs.Entry, useNerdIcons bool) string { + if !useNerdIcons { + switch entry.Category() { + case "parent": + return "<-" + case "directory": + return "[D]" + case "config": + return "[C]" + case "text": + return "[T]" + case "image": + return "[I]" + case "executable": + return "[X]" + case "archive": + return "[A]" + default: + return "[F]" + } + } switch entry.Category() { case "parent": return "↩"