Add auto icon mode fallback and Nerd Font docs
This commit is contained in:
parent
ef47410bcf
commit
780150500d
6 changed files with 193 additions and 18 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
37
internal/ui/icon_mode.go
Normal 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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "↩"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue