Polish panel layout and preview interaction

This commit is contained in:
vrubelroman 2026-04-22 23:03:33 +03:00
parent ef63a2479b
commit 941708970b
3 changed files with 120 additions and 39 deletions

View file

@ -6,6 +6,7 @@ type KeyMap struct {
View key.Binding View key.Binding
Edit key.Binding Edit key.Binding
Info key.Binding Info key.Binding
SelectText key.Binding
ToggleHidden key.Binding ToggleHidden key.Binding
CycleTheme key.Binding CycleTheme key.Binding
CycleSort key.Binding CycleSort key.Binding
@ -32,6 +33,7 @@ func DefaultKeyMap() KeyMap {
View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")), View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")),
Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")), Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")),
Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")), Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")),
SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")),
ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")), ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")),
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")), CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")),
CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
@ -55,13 +57,13 @@ func DefaultKeyMap() KeyMap {
} }
func (k KeyMap) ShortHelp() []key.Binding { func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Info, k.Copy, k.Move, k.Mkdir, k.Delete, k.Quit} return []key.Binding{k.Info, k.SelectText, k.Copy, k.Move, k.Delete, k.Quit}
} }
func (k KeyMap) FullHelp() [][]key.Binding { func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{ return [][]key.Binding{
{k.Up, k.Down, k.Open, k.Back, k.Switch, k.Info}, {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.View, k.Edit, k.Copy, k.Move, k.Mkdir, k.Delete},
{k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit}, {k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit},
} }
} }

View file

@ -94,10 +94,11 @@ type Model struct {
width int width int
height int height int
left BrowserPane left BrowserPane
right BrowserPane right BrowserPane
active PaneID active PaneID
infoMode bool infoMode bool
selectMode bool
helpModel help.Model helpModel help.Model
previewModel viewport.Model previewModel viewport.Model
@ -180,6 +181,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if selected, ok := m.activePane().Selected(); ok && selected.Path == msg.entryPath { if selected, ok := m.activePane().Selected(); ok && selected.Path == msg.entryPath {
m.applyPreview(msg.preview) m.applyPreview(msg.preview)
} }
if m.selectMode && msg.preview.Kind != vfs.PreviewKindText {
m.selectMode = false
return m, enableMouseCmd()
}
return m, nil return m, nil
case dirSizeMsg: case dirSizeMsg:
@ -236,6 +241,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleEdit() return m.handleEdit()
case key.Matches(msg, m.keys.Info): case key.Matches(msg, m.keys.Info):
return m.toggleInfo() return m.toggleInfo()
case key.Matches(msg, m.keys.SelectText):
return m.toggleSelectMode()
case key.Matches(msg, m.keys.ToggleHidden): case key.Matches(msg, m.keys.ToggleHidden):
return m.toggleHidden() return m.toggleHidden()
case key.Matches(msg, m.keys.CycleTheme): case key.Matches(msg, m.keys.CycleTheme):
@ -329,12 +336,11 @@ func (m Model) View() string {
) )
} }
parts := make([]string, 0, 4) parts := make([]string, 0, 3)
if m.cfg.UI.ShowTitleBar { if m.cfg.UI.ShowTitleBar {
parts = append(parts, renderTitleBar(m)) parts = append(parts, renderTitleBar(m))
} }
parts = append(parts, panels) parts = append(parts, panels)
parts = append(parts, renderStatus(m))
if m.cfg.UI.ShowFooter { if m.cfg.UI.ShowFooter {
parts = append(parts, renderFooter(m)) parts = append(parts, renderFooter(m))
} }
@ -740,10 +746,29 @@ func (m *Model) toggleInfo() (tea.Model, tea.Cmd) {
m.status = fmt.Sprintf("Info mode: %s selection", strings.ToUpper(string(m.active))) m.status = fmt.Sprintf("Info mode: %s selection", strings.ToUpper(string(m.active)))
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
} }
if m.selectMode {
m.selectMode = false
return m, enableMouseCmd()
}
m.status = "Info mode: off" m.status = "Info mode: off"
return m, nil return m, nil
} }
func (m *Model) toggleSelectMode() (tea.Model, tea.Cmd) {
if m.selectMode {
m.selectMode = false
m.status = "Text selection mode: off"
return m, enableMouseCmd()
}
if !m.infoMode || m.previewData.Kind != vfs.PreviewKindText {
m.status = "Text selection mode works only for text preview in info pane"
return m, nil
}
m.selectMode = true
m.status = "Text selection mode: on"
return m, disableMouseCmd()
}
func (m *Model) toggleHidden() (tea.Model, tea.Cmd) { func (m *Model) toggleHidden() (tea.Model, tea.Cmd) {
m.cfg.Browser.ShowHidden = !m.cfg.Browser.ShowHidden m.cfg.Browser.ShowHidden = !m.cfg.Browser.ShowHidden
return m.refreshAllPanes(fmt.Sprintf("Show hidden: %t", m.cfg.Browser.ShowHidden)) return m.refreshAllPanes(fmt.Sprintf("Show hidden: %t", m.cfg.Browser.ShowHidden))
@ -867,7 +892,7 @@ func (m *Model) layoutWidths() (int, int, int) {
} }
func (m *Model) bodyHeight() int { func (m *Model) bodyHeight() int {
height := m.height - 1 height := m.height
if m.cfg.UI.ShowTitleBar { if m.cfg.UI.ShowTitleBar {
height-- height--
} }
@ -888,16 +913,19 @@ func (m *Model) resizePreview() {
} }
func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg config.Config, palette theme.Palette, width int, height int) string { func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg config.Config, palette theme.Palette, width int, height int) string {
innerWidth := max(width-2, 1)
innerHeight := max(height-2, 1)
box := lipgloss.NewStyle(). box := lipgloss.NewStyle().
Width(width). Width(innerWidth).
Height(height). Height(innerHeight).
Background(palette.Panel). Background(palette.Panel).
Foreground(palette.Text). Foreground(palette.Text).
BorderStyle(borderStyle(cfg.UI.Border)). BorderStyle(borderStyle(cfg.UI.Border)).
BorderForeground(palette.BorderActive) BorderForeground(palette.BorderActive)
title := lipgloss.NewStyle(). title := lipgloss.NewStyle().
Width(width-2). Width(innerWidth).
Padding(0, 1). Padding(0, 1).
Background(palette.Accent). Background(palette.Accent).
Foreground(palette.Background). Foreground(palette.Background).
@ -906,9 +934,9 @@ func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg c
parts := []string{title} parts := []string{title}
if cfg.Preview.ShowMetadata { if cfg.Preview.ShowMetadata {
parts = append(parts, renderMetadata(preview.Metadata, palette, width-2)) parts = append(parts, renderMetadata(preview.Metadata, palette, innerWidth))
} }
parts = append(parts, renderPreviewContent(viewportModel, palette, width-2)) parts = append(parts, renderPreviewContent(viewportModel, palette, innerWidth))
return box.Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) return box.Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
} }
@ -1020,6 +1048,13 @@ func renderFooter(m Model) string {
helpModel := m.helpModel helpModel := m.helpModel
helpModel.Width = max(m.width-28, 20) helpModel.Width = max(m.width-28, 20)
helpView := helpModel.View(m.keys) helpView := helpModel.View(m.keys)
modeLabel := ""
if m.selectMode {
modeLabel = lipgloss.NewStyle().
Foreground(m.palette.Accent).
Bold(true).
Render(" SELECT TEXT MODE")
}
legend := lipgloss.NewStyle(). legend := lipgloss.NewStyle().
Foreground(m.palette.Muted). Foreground(m.palette.Muted).
Render(" dir 󰈙 text  config 󰆍 exec 󰋩 image 󰈔 bin") Render(" dir 󰈙 text  config 󰆍 exec 󰋩 image 󰈔 bin")
@ -1027,7 +1062,7 @@ func renderFooter(m Model) string {
Width(m.width). Width(m.width).
Padding(0, 1). Padding(0, 1).
Background(m.palette.Panel). Background(m.palette.Panel).
Render(lipgloss.JoinHorizontal(lipgloss.Top, helpView, " ", legend)) Render(lipgloss.JoinHorizontal(lipgloss.Top, helpView, modeLabel, " ", legend))
} }
func renderModal(modal modalState, palette theme.Palette, width int) string { func renderModal(modal modalState, palette theme.Palette, width int) string {
@ -1252,6 +1287,12 @@ func enableMouseCmd() tea.Cmd {
} }
} }
func disableMouseCmd() tea.Cmd {
return func() tea.Msg {
return tea.DisableMouse()
}
}
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 == "" {
@ -1319,20 +1360,28 @@ func (m *Model) mouseTarget(x, y int) (PaneID, int, bool) {
if m.infoMode && m.active == PaneRight { if m.infoMode && m.active == PaneRight {
return "", 0, false return "", 0, false
} }
return PaneLeft, paneIndexFromMouse(y-top, bodyHeight, &m.left), true index, ok := paneIndexFromMouse(y-top, bodyHeight, &m.left)
if !ok {
return "", 0, false
}
return PaneLeft, index, true
case x >= rightStart && x < rightStart+rightWidth: case x >= rightStart && x < rightStart+rightWidth:
if m.infoMode && m.active == PaneLeft { if m.infoMode && m.active == PaneLeft {
return "", 0, false return "", 0, false
} }
return PaneRight, paneIndexFromMouse(y-top, bodyHeight, &m.right), true index, ok := paneIndexFromMouse(y-top, bodyHeight, &m.right)
if !ok {
return "", 0, false
}
return PaneRight, index, true
default: default:
return "", 0, false return "", 0, false
} }
} }
func paneIndexFromMouse(localY int, height int, pane *BrowserPane) int { func paneIndexFromMouse(localY int, height int, pane *BrowserPane) (int, bool) {
if localY < 1 || localY >= height-1 { if localY < 1 || localY >= height-1 {
return pane.Cursor return 0, false
} }
row := localY - 1 row := localY - 1
index := pane.Offset + row index := pane.Offset + row
@ -1340,12 +1389,9 @@ func paneIndexFromMouse(localY int, height int, pane *BrowserPane) int {
index = 0 index = 0
} }
if index >= len(pane.Entries) { if index >= len(pane.Entries) {
index = len(pane.Entries) - 1 return 0, false
} }
if index < 0 { return index, true
return 0
}
return index
} }
func isEditableEntry(entry vfs.Entry) bool { func isEditableEntry(entry vfs.Entry) bool {

View file

@ -114,10 +114,11 @@ func renderPane(
} }
innerWidth := max(width-2, 1) innerWidth := max(width-2, 1)
innerHeight := max(height-2, 1)
box := lipgloss.NewStyle(). box := lipgloss.NewStyle().
Width(width). Width(innerWidth).
Height(height). Height(innerHeight).
Background(palette.PanelInactive). Background(palette.PanelInactive).
Foreground(palette.Text). Foreground(palette.Text).
BorderStyle(borderStyle(cfg.UI.Border)). BorderStyle(borderStyle(cfg.UI.Border)).
@ -126,7 +127,7 @@ func renderPane(
header := lipgloss.NewStyle(). header := lipgloss.NewStyle().
Render(renderPaneHeader(pane, cfg, palette, innerWidth, active, headerBg)) Render(renderPaneHeader(pane, cfg, palette, innerWidth, active, headerBg))
rowsHeight := max(height-4, 1) rowsHeight := max(innerHeight-2, 1)
headerRow := renderColumnsHeader(cfg, innerWidth, palette) headerRow := renderColumnsHeader(cfg, innerWidth, palette)
rows := renderPaneRows(pane, cfg, palette, innerWidth, rowsHeight, active, hoverIndex) 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)
@ -293,8 +294,8 @@ func buildColumns(cfg config.Config, totalWidth int) []columnSpec {
fixed = append(fixed, columnSpec{ fixed = append(fixed, columnSpec{
Key: "size", Key: "size",
Title: "Size", Title: "Size",
Width: 10, Width: 9,
MinWidth: 7, MinWidth: 6,
AlignRight: true, AlignRight: true,
Value: func(entry vfs.Entry, human bool) string { Value: func(entry vfs.Entry, human bool) string {
if entry.IsDir { if entry.IsDir {
@ -394,7 +395,7 @@ func columnSeparator(columnKey string, palette theme.Palette, background lipglos
func separatorWidth(columnKey string) int { func separatorWidth(columnKey string) int {
if columnKey == "size" { if columnKey == "size" {
return 3 return 2
} }
return 1 return 1
} }
@ -433,7 +434,7 @@ func truncateMiddle(value string, maxWidth int) string {
return value return value
} }
if maxWidth <= 3 { if maxWidth <= 3 {
return value[:maxWidth] return trimToWidthRight(value, maxWidth)
} }
left := maxWidth/2 - 1 left := maxWidth/2 - 1
right := maxWidth - left - 1 right := maxWidth - left - 1
@ -443,7 +444,7 @@ func truncateMiddle(value string, maxWidth int) string {
if right < 1 { if right < 1 {
right = 1 right = 1
} }
return value[:left] + "…" + value[len(value)-right:] return trimToWidthRight(value, left) + "…" + trimToWidthLeft(value, right)
} }
func truncateRight(value string, maxWidth int) string { func truncateRight(value string, maxWidth int) string {
@ -451,9 +452,9 @@ func truncateRight(value string, maxWidth int) string {
return value return value
} }
if maxWidth == 1 { if maxWidth == 1 {
return value[:1] return trimToWidthRight(value, 1)
} }
return value[:maxWidth-1] + "…" return trimToWidthRight(value, maxWidth-1) + "…"
} }
func truncateForColumn(value string, maxWidth int, alignRight bool) string { func truncateForColumn(value string, maxWidth int, alignRight bool) string {
@ -462,16 +463,48 @@ func truncateForColumn(value string, maxWidth int, alignRight bool) string {
} }
if alignRight { if alignRight {
if maxWidth <= 1 { if maxWidth <= 1 {
return value[len(value)-1:] return trimToWidthLeft(value, 1)
} }
if len(value) <= maxWidth { return "…" + trimToWidthLeft(value, maxWidth-1)
return value
}
return "…" + value[len(value)-maxWidth+1:]
} }
return truncateRight(value, maxWidth) return truncateRight(value, maxWidth)
} }
func trimToWidthRight(value string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
width := 0
var builder strings.Builder
for _, r := range value {
rw := lipgloss.Width(string(r))
if width+rw > maxWidth {
break
}
builder.WriteRune(r)
width += rw
}
return builder.String()
}
func trimToWidthLeft(value string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
runes := []rune(value)
width := 0
start := len(runes)
for i := len(runes) - 1; i >= 0; i-- {
rw := lipgloss.Width(string(runes[i]))
if width+rw > maxWidth {
break
}
width += rw
start = i
}
return string(runes[start:])
}
func entryIcon(entry vfs.Entry) string { func entryIcon(entry vfs.Entry) string {
switch entry.Category() { switch entry.Category() {
case "parent": case "parent":