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
Edit key.Binding
Info key.Binding
SelectText key.Binding
ToggleHidden key.Binding
CycleTheme key.Binding
CycleSort key.Binding
@ -32,6 +33,7 @@ func DefaultKeyMap() 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")),
SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")),
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")),
@ -55,13 +57,13 @@ func DefaultKeyMap() KeyMap {
}
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 {
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},
{k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit},
}
}

View file

@ -98,6 +98,7 @@ type Model struct {
right BrowserPane
active PaneID
infoMode bool
selectMode bool
helpModel help.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 {
m.applyPreview(msg.preview)
}
if m.selectMode && msg.preview.Kind != vfs.PreviewKindText {
m.selectMode = false
return m, enableMouseCmd()
}
return m, nil
case dirSizeMsg:
@ -236,6 +241,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.handleEdit()
case key.Matches(msg, m.keys.Info):
return m.toggleInfo()
case key.Matches(msg, m.keys.SelectText):
return m.toggleSelectMode()
case key.Matches(msg, m.keys.ToggleHidden):
return m.toggleHidden()
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 {
parts = append(parts, renderTitleBar(m))
}
parts = append(parts, panels)
parts = append(parts, renderStatus(m))
if m.cfg.UI.ShowFooter {
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)))
return m, m.loadPreviewCmd()
}
if m.selectMode {
m.selectMode = false
return m, enableMouseCmd()
}
m.status = "Info mode: off"
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) {
m.cfg.Browser.ShowHidden = !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 {
height := m.height - 1
height := m.height
if m.cfg.UI.ShowTitleBar {
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 {
innerWidth := max(width-2, 1)
innerHeight := max(height-2, 1)
box := lipgloss.NewStyle().
Width(width).
Height(height).
Width(innerWidth).
Height(innerHeight).
Background(palette.Panel).
Foreground(palette.Text).
BorderStyle(borderStyle(cfg.UI.Border)).
BorderForeground(palette.BorderActive)
title := lipgloss.NewStyle().
Width(width-2).
Width(innerWidth).
Padding(0, 1).
Background(palette.Accent).
Foreground(palette.Background).
@ -906,9 +934,9 @@ func renderPreviewPane(preview vfs.Preview, viewportModel *viewport.Model, cfg c
parts := []string{title}
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...))
}
@ -1020,6 +1048,13 @@ func renderFooter(m Model) string {
helpModel := m.helpModel
helpModel.Width = max(m.width-28, 20)
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().
Foreground(m.palette.Muted).
Render(" dir 󰈙 text  config 󰆍 exec 󰋩 image 󰈔 bin")
@ -1027,7 +1062,7 @@ func renderFooter(m Model) string {
Width(m.width).
Padding(0, 1).
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 {
@ -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) {
value := strings.TrimSpace(raw)
if value == "" {
@ -1319,20 +1360,28 @@ func (m *Model) mouseTarget(x, y int) (PaneID, int, bool) {
if m.infoMode && m.active == PaneRight {
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:
if m.infoMode && m.active == PaneLeft {
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:
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 {
return pane.Cursor
return 0, false
}
row := localY - 1
index := pane.Offset + row
@ -1340,12 +1389,9 @@ func paneIndexFromMouse(localY int, height int, pane *BrowserPane) int {
index = 0
}
if index >= len(pane.Entries) {
index = len(pane.Entries) - 1
return 0, false
}
if index < 0 {
return 0
}
return index
return index, true
}
func isEditableEntry(entry vfs.Entry) bool {

View file

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