diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 510365b..9423539 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -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}, } } diff --git a/internal/ui/model.go b/internal/ui/model.go index 6fde556..026610a 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -94,10 +94,11 @@ type Model struct { width int height int - left BrowserPane - right BrowserPane - active PaneID - infoMode bool + left BrowserPane + 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 { diff --git a/internal/ui/pane.go b/internal/ui/pane.go index 02cccb8..56f52eb 100644 --- a/internal/ui/pane.go +++ b/internal/ui/pane.go @@ -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":