Refine mouse interactions and info pane UX

This commit is contained in:
vrubelroman 2026-04-22 22:50:30 +03:00
parent 3d26d835af
commit ef63a2479b
4 changed files with 367 additions and 47 deletions

View file

@ -27,7 +27,7 @@ func main() {
os.Exit(1) os.Exit(1)
} }
program := tea.NewProgram(model, tea.WithAltScreen()) program := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := program.Run(); err != nil { if _, err := program.Run(); err != nil {
fmt.Fprintf(os.Stderr, "runtime error: %v\n", err) fmt.Fprintf(os.Stderr, "runtime error: %v\n", err)
os.Exit(1) os.Exit(1)

View file

@ -227,6 +227,13 @@ func ShortTime(t time.Time) string {
return t.Format("2006-01-02 15:04") return t.Format("2006-01-02 15:04")
} }
func CompactTime(t time.Time) string {
if t.IsZero() {
return "n/a"
}
return t.Format("01-02 15:04")
}
func Permissions(mode fs.FileMode) string { func Permissions(mode fs.FileMode) string {
return mode.String() return mode.String()
} }

View file

@ -6,6 +6,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
@ -72,6 +73,18 @@ type opMsg struct {
err error err error
} }
type mouseClickState struct {
pane PaneID
index int
at time.Time
}
type hoverState struct {
pane PaneID
index int
ok bool
}
type Model struct { type Model struct {
cfg config.Config cfg config.Config
configPath string configPath string
@ -93,6 +106,9 @@ type Model struct {
modal modalState modal modalState
status string status string
busy bool busy bool
lastClick mouseClickState
hover hoverState
} }
func NewModel(cfg config.Config, configPath string) (Model, error) { func NewModel(cfg config.Config, configPath string) (Model, error) {
@ -195,10 +211,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = fmt.Sprintf("Created %s", msg.targetPath) m.status = fmt.Sprintf("Created %s", msg.targetPath)
case opEdit: case opEdit:
m.status = "Editor closed" m.status = "Editor closed"
return m, m.loadPreviewCmd() return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd())
case opView: case opView:
m.status = "Viewer closed" m.status = "Viewer closed"
return m, nil return m, enableMouseCmd()
} }
activeSelection := selectedName(m.activePane()) activeSelection := selectedName(m.activePane())
@ -247,11 +263,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.moveCursor(max(m.bodyHeight()-6, 5)) m.moveCursor(max(m.bodyHeight()-6, 5))
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
case key.Matches(msg, m.keys.Open): case key.Matches(msg, m.keys.Open):
if err := m.enterSelected(); err != nil { return m.handleOpenSelected()
m.status = err.Error()
return m, nil
}
return m, m.loadPreviewCmd()
case key.Matches(msg, m.keys.Back): case key.Matches(msg, m.keys.Back):
if err := m.goParent(); err != nil { if err := m.goParent(); err != nil {
m.status = err.Error() m.status = err.Error()
@ -271,6 +283,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Delete): case key.Matches(msg, m.keys.Delete):
return m.handleDelete() return m.handleDelete()
} }
case tea.MouseMsg:
return m.handleMouse(msg)
} }
return m, nil return m, nil
@ -293,7 +308,7 @@ func (m Model) View() string {
if m.active == PaneLeft { if m.active == PaneLeft {
panels = lipgloss.JoinHorizontal( panels = lipgloss.JoinHorizontal(
lipgloss.Top, lipgloss.Top,
renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true), renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft)),
gap, gap,
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight), renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight),
) )
@ -302,15 +317,15 @@ func (m Model) View() string {
lipgloss.Top, lipgloss.Top,
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight), renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight),
gap, gap,
renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true), renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight)),
) )
} }
} else { } else {
panels = lipgloss.JoinHorizontal( panels = lipgloss.JoinHorizontal(
lipgloss.Top, lipgloss.Top,
renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft), renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft)),
gap, gap,
renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight), renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight)),
) )
} }
@ -408,9 +423,11 @@ func (m *Model) refreshAllPanes(status string) (tea.Model, tea.Cmd) {
func (m *Model) moveCursor(delta int) { func (m *Model) moveCursor(delta int) {
pane := m.activePane() pane := m.activePane()
pane.Move(delta, max(m.bodyHeight()-4, 1)) pane.Move(delta, max(m.bodyHeight()-4, 1))
m.hover = hoverState{}
} }
func (m *Model) enterSelected() error { func (m *Model) enterSelected() error {
m.hover = hoverState{}
pane := m.activePane() pane := m.activePane()
selected, ok := pane.Selected() selected, ok := pane.Selected()
if !ok { if !ok {
@ -429,7 +446,28 @@ func (m *Model) enterSelected() error {
return nil return nil
} }
func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
selected, ok := m.activePane().Selected()
if !ok {
return m, nil
}
if selected.IsDir {
if err := m.enterSelected(); err != nil {
m.status = err.Error()
return m, nil
}
return m, m.loadPreviewCmd()
}
if isEditableEntry(selected) {
return m.handleEdit()
}
return m.handleOpenExternal()
}
func (m *Model) goParent() error { func (m *Model) goParent() error {
m.hover = hoverState{}
pane := m.activePane() pane := m.activePane()
parent := filepath.Dir(pane.Path) parent := filepath.Dir(pane.Path)
if parent == pane.Path { if parent == pane.Path {
@ -573,6 +611,25 @@ func (m *Model) handleView() (tea.Model, tea.Cmd) {
}) })
} }
func (m *Model) handleOpenExternal() (tea.Model, tea.Cmd) {
selected, ok := m.activePane().Selected()
if !ok || selected.IsParent || selected.IsDir {
m.status = "Select a file to open"
return m, nil
}
command, name, err := externalCommand("", []string{"xdg-open", "open"}, selected.Path)
if err != nil {
m.status = "No system opener found (tried xdg-open/open)"
return m, nil
}
m.status = fmt.Sprintf("Opening %s with %s", selected.DisplayName(), name)
return m, tea.ExecProcess(command, func(err error) tea.Msg {
return opMsg{kind: opView, sourcePath: selected.Path, err: err}
})
}
func (m *Model) handleEdit() (tea.Model, tea.Cmd) { func (m *Model) handleEdit() (tea.Model, tea.Cmd) {
selected, ok := m.activePane().Selected() selected, ok := m.activePane().Selected()
if !ok || selected.IsParent || selected.IsDir { if !ok || selected.IsParent || selected.IsDir {
@ -580,9 +637,9 @@ func (m *Model) handleEdit() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
command, name, err := externalCommand("EDITOR", []string{"nvim", "vim", "vi", "nano"}, selected.Path) command, name, err := externalCommandFromEnv([]string{"VISUAL", "EDITOR"}, []string{"nvim", "vim", "vi", "nano"}, selected.Path)
if err != nil { if err != nil {
m.status = "Set $EDITOR or install nvim/vim/vi/nano to enable F4 editing" m.status = "Set $VISUAL/$EDITOR or install nvim/vim/vi/nano to enable F4 editing"
return m, nil return m, nil
} }
@ -592,6 +649,89 @@ func (m *Model) handleEdit() (tea.Model, tea.Cmd) {
}) })
} }
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
switch {
case msg.Action == tea.MouseActionMotion:
paneID, index, ok := m.mouseTarget(msg.X, msg.Y)
if ok {
m.hover = hoverState{pane: paneID, index: index, ok: true}
} else {
m.hover = hoverState{}
}
return m, nil
case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelUp:
if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) {
m.previewModel.LineUp(3)
return m, nil
}
m.moveCursor(-1)
return m, m.loadPreviewCmd()
case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonWheelDown:
if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) {
m.previewModel.LineDown(3)
return m, nil
}
m.moveCursor(1)
return m, m.loadPreviewCmd()
case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft:
paneID, index, ok := m.mouseTarget(msg.X, msg.Y)
if !ok {
return m, nil
}
m.hover = hoverState{pane: paneID, index: index, ok: true}
m.active = paneID
pane := m.paneByID(paneID)
if index >= 0 && index < len(pane.Entries) {
pane.Cursor = index
pane.EnsureVisible(max(m.bodyHeight()-4, 1))
}
now := time.Now()
doubleClick := m.lastClick.pane == paneID && m.lastClick.index == index && now.Sub(m.lastClick.at) <= 450*time.Millisecond
m.lastClick = mouseClickState{pane: paneID, index: index, at: now}
if doubleClick {
return m.handleOpenSelected()
}
m.status = fmt.Sprintf("Selected %s pane", strings.ToUpper(string(paneID)))
return m, m.loadPreviewCmd()
case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonRight:
if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) {
m.infoMode = false
m.resizePreview()
m.syncPreviewContent()
m.status = "Info mode: off"
return m, nil
}
paneID, index, ok := m.mouseTarget(msg.X, msg.Y)
if !ok {
return m, nil
}
if m.infoMode && paneID == m.active && index == m.activePane().Cursor {
m.infoMode = false
m.hover = hoverState{pane: paneID, index: index, ok: true}
m.status = "Info mode: off"
return m, nil
}
m.hover = hoverState{pane: paneID, index: index, ok: true}
m.active = paneID
pane := m.paneByID(paneID)
if index >= 0 && index < len(pane.Entries) {
pane.Cursor = index
pane.EnsureVisible(max(m.bodyHeight()-4, 1))
}
m.infoMode = true
m.resizePreview()
m.syncPreviewContent()
m.status = fmt.Sprintf("Info mode: %s selection", strings.ToUpper(string(paneID)))
return m, m.loadPreviewCmd()
default:
return m, nil
}
}
func (m *Model) toggleInfo() (tea.Model, tea.Cmd) { func (m *Model) toggleInfo() (tea.Model, tea.Cmd) {
m.infoMode = !m.infoMode m.infoMode = !m.infoMode
m.resizePreview() m.resizePreview()
@ -774,27 +914,34 @@ func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg c
} }
func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string { func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string {
rows := []string{ leftRows := []string{
fmt.Sprintf("kind: %s", fallback(meta.Kind, "n/a")), fmt.Sprintf("kind: %s", fallback(meta.Kind, "n/a")),
fmt.Sprintf("size: %s", metaSize(meta)), fmt.Sprintf("size: %s", metaSize(meta)),
fmt.Sprintf("created: %s", fallback(meta.CreatedAt, "n/a")), fmt.Sprintf("created: %s", fallback(meta.CreatedAt, "n/a")),
}
rightRows := []string{
fmt.Sprintf("modified: %s", fallback(meta.ModifiedAt, "n/a")), fmt.Sprintf("modified: %s", fallback(meta.ModifiedAt, "n/a")),
fmt.Sprintf("mode: %s", fallback(meta.Permissions, "n/a")), fmt.Sprintf("mode: %s", fallback(meta.Permissions, "n/a")),
} }
if meta.ImageFormat != "" { if meta.ImageFormat != "" {
rows = append(rows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize)) rightRows = append(rightRows, fmt.Sprintf("image: %s %s", meta.ImageFormat, meta.ImageSize))
} }
rows = append(rows, fmt.Sprintf("path: %s", truncateMiddle(meta.Path, max(width-10, 12))))
leftWidth := max(width/2, 18) leftWidth := max(width/2, 18)
rightWidth := max(width-leftWidth, 18)
left := lipgloss.NewStyle(). left := lipgloss.NewStyle().
Width(leftWidth). Width(leftWidth).
Foreground(palette.Muted). Foreground(palette.Muted).
Render(strings.Join(rows[:min(3, len(rows))], "\n")) Render(strings.Join(leftRows, "\n"))
right := lipgloss.NewStyle(). right := lipgloss.NewStyle().
Width(width - leftWidth). Width(rightWidth).
Foreground(palette.Text). Foreground(palette.Text).
Render(strings.Join(rows[min(3, len(rows)):], "\n")) Render(strings.Join(rightRows, "\n"))
pathLine := lipgloss.NewStyle().
Width(width).
Foreground(palette.Text).
Render(fmt.Sprintf("path: %s", truncateMiddle(meta.Path, max(width-8, 16))))
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Width(width). Width(width).
@ -803,7 +950,12 @@ func renderMetadata(meta vfs.Metadata, palette theme.Palette, width int) string
BorderStyle(lipgloss.NormalBorder()). BorderStyle(lipgloss.NormalBorder()).
BorderBottom(true). BorderBottom(true).
BorderForeground(palette.Border). BorderForeground(palette.Border).
Render(lipgloss.JoinHorizontal(lipgloss.Top, left, right)) Render(lipgloss.JoinVertical(
lipgloss.Left,
lipgloss.JoinHorizontal(lipgloss.Top, left, right),
"",
pathLine,
))
} }
func renderTitleBar(m Model) string { func renderTitleBar(m Model) string {
@ -1052,28 +1204,54 @@ func operationVerb(kind fileOpKind) string {
} }
func externalCommand(envVar string, fallbacks []string, path string) (*exec.Cmd, string, error) { func externalCommand(envVar string, fallbacks []string, path string) (*exec.Cmd, string, error) {
commandLine := strings.TrimSpace(os.Getenv(envVar)) envVars := []string{}
if envVar != "" {
envVars = append(envVars, envVar)
}
return externalCommandFromEnv(envVars, fallbacks, path)
}
func externalCommandFromEnv(envVars []string, fallbacks []string, path string) (*exec.Cmd, string, error) {
commandLine := ""
source := "fallbacks"
for _, envVar := range envVars {
commandLine = strings.TrimSpace(os.Getenv(envVar))
if commandLine != "" {
source = envVar
break
}
}
if commandLine == "" { if commandLine == "" {
for _, candidate := range fallbacks { for _, candidate := range fallbacks {
if resolved, err := exec.LookPath(candidate); err == nil { if resolved, err := exec.LookPath(candidate); err == nil {
commandLine = resolved commandLine = resolved
source = candidate
break break
} }
} }
} }
if commandLine == "" { if commandLine == "" {
return nil, "", fmt.Errorf("no command for %s", envVar) if len(envVars) > 0 {
return nil, "", fmt.Errorf("no command for %s", strings.Join(envVars, "/"))
}
return nil, "", fmt.Errorf("no fallback command found")
} }
parts := strings.Fields(commandLine) parts := strings.Fields(commandLine)
if len(parts) == 0 { if len(parts) == 0 {
return nil, "", fmt.Errorf("invalid command for %s", envVar) return nil, "", fmt.Errorf("invalid command for %s", source)
} }
args := append(parts[1:], path) args := append(parts[1:], path)
return exec.Command(parts[0], args...), filepath.Base(parts[0]), nil return exec.Command(parts[0], args...), filepath.Base(parts[0]), nil
} }
func enableMouseCmd() tea.Cmd {
return func() tea.Msg {
return tea.EnableMouseCellMotion()
}
}
func resolveStartPath(raw string, fallback string) (string, error) { func resolveStartPath(raw string, fallback string) (string, error) {
value := strings.TrimSpace(raw) value := strings.TrimSpace(raw)
if value == "" { if value == "" {
@ -1113,3 +1291,99 @@ func max(a, b int) int {
} }
return b return b
} }
func (m *Model) mouseTarget(x, y int) (PaneID, int, bool) {
if m.width <= 0 || m.height <= 0 {
return "", 0, false
}
leftWidth, previewWidth, rightWidth := m.layoutWidths()
top := 0
if m.cfg.UI.ShowTitleBar {
top++
}
bodyHeight := m.bodyHeight()
if y < top || y >= top+bodyHeight {
return "", 0, false
}
gap := m.cfg.UI.PaneGap
leftStart := 0
rightStart := leftWidth + gap
if m.infoMode && m.active == PaneRight {
rightStart = previewWidth + gap
}
switch {
case x >= leftStart && x < leftStart+leftWidth:
if m.infoMode && m.active == PaneRight {
return "", 0, false
}
return PaneLeft, paneIndexFromMouse(y-top, bodyHeight, &m.left), true
case x >= rightStart && x < rightStart+rightWidth:
if m.infoMode && m.active == PaneLeft {
return "", 0, false
}
return PaneRight, paneIndexFromMouse(y-top, bodyHeight, &m.right), true
default:
return "", 0, false
}
}
func paneIndexFromMouse(localY int, height int, pane *BrowserPane) int {
if localY < 1 || localY >= height-1 {
return pane.Cursor
}
row := localY - 1
index := pane.Offset + row
if index < 0 {
index = 0
}
if index >= len(pane.Entries) {
index = len(pane.Entries) - 1
}
if index < 0 {
return 0
}
return index
}
func isEditableEntry(entry vfs.Entry) bool {
switch entry.Category() {
case "text", "config", "executable":
return true
default:
return false
}
}
func (m *Model) hoverIndexFor(pane PaneID) int {
if m.hover.ok && m.hover.pane == pane {
return m.hover.index
}
return -1
}
func (m *Model) mouseOverPreview(x, y int) bool {
if !m.infoMode || m.width <= 0 || m.height <= 0 {
return false
}
leftWidth, previewWidth, _ := m.layoutWidths()
top := 0
if m.cfg.UI.ShowTitleBar {
top++
}
bodyHeight := m.bodyHeight()
if y < top || y >= top+bodyHeight {
return false
}
gap := m.cfg.UI.PaneGap
if m.active == PaneLeft {
startX := leftWidth + gap
return x >= startX && x < startX+previewWidth
}
return x >= 0 && x < previewWidth
}

View file

@ -100,6 +100,7 @@ func renderPane(
width int, width int,
height int, height int,
active bool, active bool,
hoverIndex int,
) string { ) string {
if width <= 0 || height <= 0 { if width <= 0 || height <= 0 {
return "" return ""
@ -127,7 +128,7 @@ func renderPane(
rowsHeight := max(height-4, 1) rowsHeight := max(height-4, 1)
headerRow := renderColumnsHeader(cfg, innerWidth, palette) headerRow := renderColumnsHeader(cfg, innerWidth, palette)
rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active) rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex)
content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows) content := lipgloss.JoinVertical(lipgloss.Left, header, headerRow, rows)
return box.Render(content) return box.Render(content)
} }
@ -160,7 +161,7 @@ func renderPaneHeader(pane BrowserPane, cfg config.Config, palette theme.Palette
func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette) string { func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette) string {
columns := buildColumns(cfg, width) columns := buildColumns(cfg, width)
parts := make([]string, 0, len(columns)) parts := make([]string, 0, len(columns))
for _, column := range columns { for idx, column := range columns {
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().
Width(column.Width). Width(column.Width).
Foreground(palette.Muted). Foreground(palette.Muted).
@ -169,14 +170,17 @@ func renderColumnsHeader(cfg config.Config, width int, palette theme.Palette) st
style = style.Align(lipgloss.Right) style = style.Align(lipgloss.Right)
} }
parts = append(parts, style.Render(truncateRight(column.Title, column.Width))) parts = append(parts, style.Render(truncateRight(column.Title, column.Width)))
if idx < len(columns)-1 {
parts = append(parts, columnSeparator(column.Key, palette, lipgloss.Color("")))
}
} }
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Width(width). Width(width).
Background(palette.Panel). Background(palette.Panel).
Render(strings.Join(parts, " ")) Render(strings.Join(parts, ""))
} }
func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool) string { func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette, width int, height int, active bool, hoverIndex int) string {
if len(pane.Entries) == 0 { if len(pane.Entries) == 0 {
return lipgloss.NewStyle(). return lipgloss.NewStyle().
Width(width). Width(width).
@ -193,7 +197,7 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette,
lines := make([]string, 0, visibleHeight) lines := make([]string, 0, visibleHeight)
for idx := pane.Offset; idx < end; idx++ { for idx := pane.Offset; idx < end; idx++ {
entry := pane.Entries[idx] entry := pane.Entries[idx]
row := renderEntryRow(entry, cfg, width, idx == pane.Cursor, active, palette) row := renderEntryRow(entry, cfg, width, idx == pane.Cursor, idx == hoverIndex, active, palette)
lines = append(lines, row) lines = append(lines, row)
} }
for len(lines) < visibleHeight { for len(lines) < visibleHeight {
@ -205,14 +209,25 @@ func renderPaneRows(pane BrowserPane, cfg config.Config, palette theme.Palette,
Render(strings.Join(lines, "\n")) Render(strings.Join(lines, "\n"))
} }
func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, active bool, palette theme.Palette) string { func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool, hovered bool, active bool, palette theme.Palette) string {
columns := buildColumns(cfg, width) columns := buildColumns(cfg, width)
rowBackground := lipgloss.Color("")
switch {
case selected:
rowBackground = palette.Selection
case hovered:
rowBackground = palette.PanelElevated
}
parts := make([]string, 0, len(columns)) parts := make([]string, 0, len(columns))
for _, column := range columns { for idx, column := range columns {
value := column.Value(entry, cfg.Browser.HumanReadableSize) value := column.Value(entry, cfg.Browser.HumanReadableSize)
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().
Width(column.Width). Width(column.Width).
Foreground(entryColor(entry, palette)) Foreground(entryColor(entry, palette))
if rowBackground != lipgloss.Color("") {
style = style.Background(rowBackground)
}
if entry.IsHidden { if entry.IsHidden {
style = style.Foreground(palette.Muted) style = style.Foreground(palette.Muted)
@ -221,17 +236,20 @@ func renderEntryRow(entry vfs.Entry, cfg config.Config, width int, selected bool
style = style.Align(lipgloss.Right) style = style.Align(lipgloss.Right)
} }
parts = append(parts, style.Render(truncateForColumn(value, column.Width, column.AlignRight))) parts = append(parts, style.Render(truncateForColumn(value, column.Width, column.AlignRight)))
if idx < len(columns)-1 {
parts = append(parts, columnSeparator(column.Key, palette, rowBackground))
}
} }
rowStyle := lipgloss.NewStyle().Width(width) rowStyle := lipgloss.NewStyle().Width(width)
if selected { if rowBackground != lipgloss.Color("") {
rowStyle = rowStyle.Background(palette.Selection) rowStyle = rowStyle.Background(rowBackground)
if active { }
if selected && active {
rowStyle = rowStyle.Bold(true) rowStyle = rowStyle.Bold(true)
} }
}
return rowStyle.Render(strings.Join(parts, " ")) return rowStyle.Render(strings.Join(parts, ""))
} }
type columnSpec struct { type columnSpec struct {
@ -299,13 +317,13 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec {
fixed = append(fixed, columnSpec{ fixed = append(fixed, columnSpec{
Key: "created", Key: "created",
Title: "Created", Title: "Created",
Width: 16, Width: 11,
MinWidth: 10, MinWidth: 8,
Value: func(entry vfs.Entry, _ bool) string { Value: func(entry vfs.Entry, _ bool) string {
if !entry.CreatedKnown { if !entry.CreatedKnown {
return "n/a" return "n/a"
} }
return vfs.ShortTime(entry.CreatedAt) return vfs.CompactTime(entry.CreatedAt)
}, },
}) })
} }
@ -313,16 +331,19 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec {
fixed = append(fixed, columnSpec{ fixed = append(fixed, columnSpec{
Key: "modified", Key: "modified",
Title: "Modified", Title: "Modified",
Width: 16, Width: 11,
MinWidth: 10, MinWidth: 8,
Value: func(entry vfs.Entry, _ bool) string { Value: func(entry vfs.Entry, _ bool) string {
return vfs.ShortTime(entry.ModifiedAt) return vfs.CompactTime(entry.ModifiedAt)
}, },
}) })
} }
minNameWidth := 12 minNameWidth := 4
gaps := max(len(fixed), 0) gaps := 0
for _, column := range fixed {
gaps += separatorWidth(column.Key)
}
availableForColumns := totalWidth - gaps availableForColumns := totalWidth - gaps
if availableForColumns < minNameWidth { if availableForColumns < minNameWidth {
availableForColumns = minNameWidth availableForColumns = minNameWidth
@ -360,6 +381,24 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec {
return append([]columnSpec{name}, fixed...) return append([]columnSpec{name}, fixed...)
} }
func columnSeparator(columnKey string, palette theme.Palette, background lipgloss.Color) string {
width := separatorWidth(columnKey)
style := lipgloss.NewStyle().
Width(width).
Foreground(palette.Border)
if background != lipgloss.Color("") {
style = style.Background(background)
}
return style.Render(strings.Repeat(" ", width))
}
func separatorWidth(columnKey string) int {
if columnKey == "size" {
return 3
}
return 1
}
func borderStyle(value string) lipgloss.Border { func borderStyle(value string) lipgloss.Border {
switch strings.ToLower(value) { switch strings.ToLower(value) {
case "double": case "double":