Initial vcom TUI prototype

This commit is contained in:
vrubelroman 2026-04-22 22:10:50 +03:00
commit 059f925e00
16 changed files with 3227 additions and 0 deletions

67
internal/ui/keymap.go Normal file
View file

@ -0,0 +1,67 @@
package ui
import "github.com/charmbracelet/bubbles/key"
type KeyMap struct {
View key.Binding
Edit key.Binding
Info key.Binding
ToggleHidden key.Binding
CycleTheme key.Binding
CycleSort key.Binding
Up key.Binding
Down key.Binding
PageUp key.Binding
PageDown key.Binding
Open key.Binding
Back key.Binding
Switch key.Binding
Refresh key.Binding
DirSize key.Binding
Copy key.Binding
Move key.Binding
Mkdir key.Binding
Delete key.Binding
Confirm key.Binding
Cancel key.Binding
Quit key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")),
Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")),
Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")),
ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")),
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")),
CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("PgUp/b", "page up")),
PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("PgDn/f", "page down")),
Open: key.NewBinding(key.WithKeys("enter", "right", "l"), key.WithHelp("Enter", "open")),
Back: key.NewBinding(key.WithKeys("backspace", "left", "h"), key.WithHelp("←", "parent")),
Switch: key.NewBinding(key.WithKeys("tab"), key.WithHelp("Tab", "switch pane")),
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")),
Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")),
Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")),
Mkdir: key.NewBinding(key.WithKeys("f7", "n"), key.WithHelp("F7/n", "mkdir")),
Delete: key.NewBinding(key.WithKeys("f8", "delete", "x"), key.WithHelp("F8/x", "delete")),
Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")),
Cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("Esc/n", "cancel")),
Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")),
}
}
func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Info, k.Copy, k.Move, k.Mkdir, k.Delete, k.Quit}
}
func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Open, k.Back, k.Switch, k.Info},
{k.View, k.Edit, k.Copy, k.Move, k.Mkdir, k.Delete},
{k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit},
}
}

1115
internal/ui/model.go Normal file

File diff suppressed because it is too large Load diff

472
internal/ui/pane.go Normal file
View file

@ -0,0 +1,472 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"vcom/internal/config"
vfs "vcom/internal/fs"
"vcom/internal/theme"
)
type PaneID string
const (
PaneLeft PaneID = "left"
PaneRight PaneID = "right"
)
type BrowserPane struct {
ID PaneID
Path string
Entries []vfs.Entry
Cursor int
Offset int
}
func (p *BrowserPane) Selected() (vfs.Entry, bool) {
if len(p.Entries) == 0 || p.Cursor < 0 || p.Cursor >= len(p.Entries) {
return vfs.Entry{}, false
}
return p.Entries[p.Cursor], true
}
func (p *BrowserPane) SetEntries(entries []vfs.Entry, preserveKey string) {
p.Entries = entries
if len(entries) == 0 {
p.Cursor = 0
p.Offset = 0
return
}
if preserveKey != "" {
p.Cursor = vfs.FindSelected(entries, preserveKey)
}
if p.Cursor >= len(entries) {
p.Cursor = len(entries) - 1
}
if p.Cursor < 0 {
p.Cursor = 0
}
if p.Offset > p.Cursor {
p.Offset = p.Cursor
}
}
func (p *BrowserPane) Move(delta int, pageSize int) {
if len(p.Entries) == 0 {
p.Cursor = 0
return
}
p.Cursor += delta
if p.Cursor < 0 {
p.Cursor = 0
}
if p.Cursor >= len(p.Entries) {
p.Cursor = len(p.Entries) - 1
}
if p.Cursor < p.Offset {
p.Offset = p.Cursor
}
if pageSize > 0 && p.Cursor >= p.Offset+pageSize {
p.Offset = p.Cursor - pageSize + 1
}
if p.Offset < 0 {
p.Offset = 0
}
}
func (p *BrowserPane) EnsureVisible(pageSize int) {
if pageSize <= 0 {
return
}
if p.Cursor < p.Offset {
p.Offset = p.Cursor
}
if p.Cursor >= p.Offset+pageSize {
p.Offset = p.Cursor - pageSize + 1
}
if p.Offset < 0 {
p.Offset = 0
}
}
func renderPane(
pane BrowserPane,
cfg config.Config,
palette theme.Palette,
width int,
height int,
active bool,
) string {
if width <= 0 || height <= 0 {
return ""
}
borderColor := palette.Border
headerBg := palette.PanelInactive
if active {
borderColor = palette.BorderActive
headerBg = palette.Selection
}
innerWidth := max(width-2, 1)
box := lipgloss.NewStyle().
Width(width).
Height(height).
Background(palette.PanelInactive).
Foreground(palette.Text).
BorderStyle(borderStyle(cfg.UI.Border)).
BorderForeground(borderColor)
header := lipgloss.NewStyle().
Render(renderPaneHeader(pane, cfg, palette, innerWidth, active, headerBg))
rowsHeight := max(height-4, 1)
headerRow := renderColumnsHeader(cfg, innerWidth, palette)
rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active)
content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows)
return box.Render(content)
}
func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, active bool, headerBg lipgloss.Color) string {
label := strings.ToUpper(string(pane.ID))
labelStyle := lipgloss.NewStyle().
Foreground(palette.Background).
Background(palette.FooterKey).
Bold(true).
Padding(0, 1)
if active {
labelStyle = labelStyle.Background(palette.Accent)
}
labelView := labelStyle.Render(label)
pathWidth := max(width-lipgloss.Width(labelView)-1, 4)
pathStyle := lipgloss.NewStyle().
Width(pathWidth).
Background(headerBg).
Foreground(palette.Text).
Bold(active)
return lipgloss.NewStyle().
Width(width).
Background(headerBg).
Render(labelView + " " + pathStyle.Render(truncateMiddle(compactPath(pane.Path, cfg.UI.PathDisplay), pathWidth)))
}
func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette) string {
columns := buildColumns(cfg, width)
parts := make([]string, 0, len(columns))
for _, column := range columns {
style := lipgloss.NewStyle().
Width(column.Width).
Foreground(palette.Muted).
Bold(true)
if column.AlignRight {
style = style.Align(lipgloss.Right)
}
parts = append(parts, style.Render(truncateRight(column.Title, column.Width)))
}
return lipgloss.NewStyle().
Width(width).
Background(palette.Panel).
Render(strings.Join(parts, " "))
}
func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool) string {
if len(pane.Entries) == 0 {
return lipgloss.NewStyle().
Width(width).
Height(height).
Padding(1, 1).
Foreground(palette.Muted).
Render("Empty directory")
}
visibleHeight := max(height, 1)
pane.EnsureVisible(visibleHeight)
end := min(len(pane.Entries), pane.Offset+visibleHeight)
lines := make([]string, 0, visibleHeight)
for idx := pane.Offset; idx < end; idx++ {
entry := pane.Entries[idx]
row := renderEntryRow(entry, cfg, width, idx == pane.Cursor, active, palette)
lines = append(lines, row)
}
for len(lines) < visibleHeight {
lines = append(lines, lipgloss.NewStyle().Width(width).Render(""))
}
return lipgloss.NewStyle().
Width(width).
Render(strings.Join(lines, "\n"))
}
func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, active bool, palette theme.Palette) string {
columns := buildColumns(cfg, width)
parts := make([]string, 0, len(columns))
for _, column := range columns {
value := column.Value(entry, cfg.Browser.HumanReadableSize)
style := lipgloss.NewStyle().
Width(column.Width).
Foreground(entryColor(entry, palette))
if entry.IsHidden {
style = style.Foreground(palette.Muted)
}
if column.AlignRight {
style = style.Align(lipgloss.Right)
}
parts = append(parts, style.Render(truncateForColumn(value, column.Width, column.AlignRight)))
}
rowStyle := lipgloss.NewStyle().Width(width)
if selected {
rowStyle = rowStyle.Background(palette.Selection)
if active {
rowStyle = rowStyle.Bold(true)
}
}
return rowStyle.Render(strings.Join(parts, " "))
}
type columnSpec struct {
Key string
Title string
Width int
MinWidth int
AlignRight bool
Value func(entry vfs.Entry, human bool) string
}
func buildColumns(cfg config.Config, totalWidth int) []columnSpec {
fixed := []columnSpec{}
if cfg.Browser.Columns.Permissions {
fixed = append(fixed, columnSpec{
Key: "permissions",
Title: "Perms",
Width: 10,
MinWidth: 9,
Value: func(entry vfs.Entry, _ bool) string {
return vfs.Permissions(entry.Mode)
},
})
}
if cfg.Browser.Columns.Extension {
fixed = append(fixed, columnSpec{
Key: "extension",
Title: "Ext",
Width: 6,
MinWidth: 4,
Value: func(entry vfs.Entry, _ bool) string {
if entry.IsDir {
return ""
}
return entry.Extension
},
})
}
if cfg.Browser.Columns.Size {
fixed = append(fixed, columnSpec{
Key: "size",
Title: "Size",
Width: 10,
MinWidth: 7,
AlignRight: true,
Value: func(entry vfs.Entry, human bool) string {
if entry.IsDir {
if !entry.DirSizeKnown {
return ""
}
if human {
return vfs.HumanSize(entry.Size)
}
return fmt.Sprintf("%d", entry.Size)
}
if human {
return vfs.HumanSize(entry.Size)
}
return fmt.Sprintf("%d", entry.Size)
},
})
}
if cfg.Browser.Columns.Created {
fixed = append(fixed, columnSpec{
Key: "created",
Title: "Created",
Width: 16,
MinWidth: 10,
Value: func(entry vfs.Entry, _ bool) string {
if !entry.CreatedKnown {
return "n/a"
}
return vfs.ShortTime(entry.CreatedAt)
},
})
}
if cfg.Browser.Columns.Modified {
fixed = append(fixed, columnSpec{
Key: "modified",
Title: "Modified",
Width: 16,
MinWidth: 10,
Value: func(entry vfs.Entry, _ bool) string {
return vfs.ShortTime(entry.ModifiedAt)
},
})
}
minNameWidth := 12
gaps := max(len(fixed), 0)
availableForColumns := totalWidth - gaps
if availableForColumns < minNameWidth {
availableForColumns = minNameWidth
}
fixedWidth := 0
for _, column := range fixed {
fixedWidth += column.Width
}
for fixedWidth+minNameWidth > availableForColumns {
changed := false
for idx := len(fixed) - 1; idx >= 0 && fixedWidth+minNameWidth > availableForColumns; idx-- {
if fixed[idx].Width > fixed[idx].MinWidth {
fixed[idx].Width--
fixedWidth--
changed = true
}
}
if !changed {
break
}
}
nameWidth := max(availableForColumns-fixedWidth, minNameWidth)
name := columnSpec{
Key: "name",
Title: "Name",
Width: nameWidth,
MinWidth: minNameWidth,
Value: func(entry vfs.Entry, _ bool) string {
return entryIcon(entry) + " " + entry.DisplayName()
},
}
return append([]columnSpec{name}, fixed...)
}
func borderStyle(value string) lipgloss.Border {
switch strings.ToLower(value) {
case "double":
return lipgloss.DoubleBorder()
case "thick":
return lipgloss.ThickBorder()
default:
return lipgloss.RoundedBorder()
}
}
func compactPath(path string, mode string) string {
switch strings.ToLower(mode) {
case "full":
return path
case "smart":
return smartPath(path, 42)
default:
return vfs.SafeBase(path)
}
}
func smartPath(path string, maxWidth int) string {
if lipgloss.Width(path) <= maxWidth {
return path
}
return truncateMiddle(path, maxWidth)
}
func truncateMiddle(value string, maxWidth int) string {
if maxWidth <= 0 || lipgloss.Width(value) <= maxWidth {
return value
}
if maxWidth <= 3 {
return value[:maxWidth]
}
left := maxWidth/2 - 1
right := maxWidth - left - 1
if left < 1 {
left = 1
}
if right < 1 {
right = 1
}
return value[:left] + "…" + value[len(value)-right:]
}
func truncateRight(value string, maxWidth int) string {
if maxWidth <= 0 || lipgloss.Width(value) <= maxWidth {
return value
}
if maxWidth == 1 {
return value[:1]
}
return value[:maxWidth-1] + "…"
}
func truncateForColumn(value string, maxWidth int, alignRight bool) string {
if lipgloss.Width(value) <= maxWidth {
return value
}
if alignRight {
if maxWidth <= 1 {
return value[len(value)-1:]
}
if len(value) <= maxWidth {
return value
}
return "…" + value[len(value)-maxWidth+1:]
}
return truncateRight(value, maxWidth)
}
func entryIcon(entry vfs.Entry) string {
switch entry.Category() {
case "parent":
return "↩"
case "directory":
return ""
case "config":
return ""
case "text":
return "󰈙"
case "image":
return "󰋩"
case "executable":
return "󰆍"
case "archive":
return ""
default:
return "󰈔"
}
}
func entryColor(entry vfs.Entry, palette theme.Palette) lipgloss.Color {
switch entry.Category() {
case "directory", "parent":
return palette.Folder
case "config":
return palette.ConfigFile
case "text":
return palette.TextFile
case "image":
return palette.ImageFile
case "executable":
return palette.ExecFile
default:
return palette.BinaryFile
}
}