Add auto icon mode fallback and Nerd Font docs

This commit is contained in:
vrubelroman 2026-04-24 14:44:49 +03:00
parent ef47410bcf
commit 780150500d
6 changed files with 193 additions and 18 deletions

View file

@ -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:

View file

@ -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
}

View file

@ -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 ""

37
internal/ui/icon_mode.go Normal file
View file

@ -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"
}

View file

@ -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
}

View file

@ -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 "↩"