Improve text preview keyboard navigation

This commit is contained in:
vrubelroman 2026-04-25 00:51:51 +03:00
parent cb5c98834e
commit 641ad17676
2 changed files with 651 additions and 24 deletions

View file

@ -4,6 +4,8 @@ import "github.com/charmbracelet/bubbles/key"
type KeyMap struct { type KeyMap struct {
Help key.Binding Help key.Binding
Visual key.Binding
Caret key.Binding
View key.Binding View key.Binding
Edit key.Binding Edit key.Binding
Rename key.Binding Rename key.Binding
@ -38,9 +40,11 @@ func DefaultKeyMap() KeyMap {
return KeyMap{ return KeyMap{
Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")), Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")),
Rename: key.NewBinding(key.WithKeys("f2", "r"), key.WithHelp("F2/r", "rename")), Rename: key.NewBinding(key.WithKeys("f2", "r"), key.WithHelp("F2/r", "rename")),
View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")), View: key.NewBinding(key.WithKeys("f3"), key.WithHelp("F3", "view")),
Visual: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual")),
Caret: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "caret")),
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("f9", "i"), key.WithHelp("F9/i", "info")), Info: key.NewBinding(key.WithKeys("f9", "o"), key.WithHelp("F9/o", "info")),
SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")), 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")),
@ -49,7 +53,7 @@ func DefaultKeyMap() KeyMap {
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")), SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")),
SelectDown: key.NewBinding(key.WithKeys("shift+down", "J"), key.WithHelp("S-↓/J", "select down")), SelectDown: key.NewBinding(key.WithKeys("shift+down", "J"), key.WithHelp("S-↓/J", "select down")),
PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("PgUp/b", "page up")), PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("PgUp", "page up")),
PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("PgDn/f", "page down")), PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("PgDn/f", "page down")),
Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")), Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")),
Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")), Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")),
@ -69,13 +73,13 @@ func DefaultKeyMap() KeyMap {
} }
func (k KeyMap) ShortHelp() []key.Binding { func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Rename, k.View, k.Copy, k.Move, k.Delete, k.Info, k.Quit} return []key.Binding{k.Help, k.Rename, k.View, k.Visual, k.Copy, k.Delete, k.Info, k.Quit}
} }
func (k KeyMap) FullHelp() [][]key.Binding { func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{ return [][]key.Binding{
{k.Help, k.Up, k.Down, k.SelectUp, k.SelectDown, k.Open, k.Back}, {k.Help, k.Up, k.Down, k.SelectUp, k.SelectDown, k.Open, k.Back},
{k.Rename, k.View, k.Edit, k.Copy, k.Move, k.Mkdir, k.Delete}, {k.Rename, k.View, k.Caret, k.Visual, k.Edit, k.Copy, k.Move, k.Delete},
{k.SelectText, 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

@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/atotto/clipboard"
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
@ -152,13 +153,19 @@ 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 selectMode bool
viewMode bool cursorMode bool
viewPrevInfo bool cursorLine int
cursorCol int
visualMode bool
visualAnchor int
visualAnchorCol int
viewMode bool
viewPrevInfo bool
previewModel viewport.Model previewModel viewport.Model
previewData vfs.Preview previewData vfs.Preview
@ -169,6 +176,7 @@ type Model struct {
lastClick mouseClickState lastClick mouseClickState
hover hoverState hover hoverState
pendingY bool
copyJob *copyJobState copyJob *copyJobState
nextCopyJob int nextCopyJob int
@ -243,6 +251,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.selectMode = false m.selectMode = false
return m, enableMouseCmd() return m, enableMouseCmd()
} }
if (m.cursorMode || m.visualMode) && msg.preview.Kind != vfs.PreviewKindText {
m.cursorMode = false
m.visualMode = false
m.status = "Text cursor mode: off"
}
return m, nil return m, nil
case dirSizeMsg: case dirSizeMsg:
@ -426,24 +439,136 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
if msg.String() != "y" {
m.pendingY = false
}
if m.modal.kind != modalNone { if m.modal.kind != modalNone {
return m.handleModalKey(msg) return m.handleModalKey(msg)
} }
if m.viewMode { if m.viewMode {
if m.previewData.Kind == vfs.PreviewKindText && m.infoMode && !m.selectMode {
switch {
case key.Matches(msg, m.keys.Visual):
return m.toggleVisualMode()
case msg.String() == "y":
return m.yankVisualSelection()
}
}
switch { switch {
case key.Matches(msg, m.keys.View), key.Matches(msg, m.keys.Cancel), msg.String() == "q": case key.Matches(msg, m.keys.Cancel):
if m.visualMode {
return m.exitVisualMode("Visual mode: off")
}
return m.exitViewMode()
case key.Matches(msg, m.keys.View), msg.String() == "q":
return m.exitViewMode() return m.exitViewMode()
case key.Matches(msg, m.keys.Up): case key.Matches(msg, m.keys.Up):
m.previewModel.LineUp(1) if m.visualMode {
m.moveTextCursorLine(-1)
} else {
m.previewModel.LineUp(1)
}
return m, nil
case msg.String() == "left":
if m.visualMode {
m.moveTextCursorCol(-1)
}
return m, nil
case msg.String() == "h":
if m.visualMode {
m.moveTextCursorCol(-1)
}
return m, nil return m, nil
case key.Matches(msg, m.keys.Down): case key.Matches(msg, m.keys.Down):
m.previewModel.LineDown(1) if m.visualMode {
m.moveTextCursorLine(1)
} else {
m.previewModel.LineDown(1)
}
return m, nil
case msg.String() == "right":
if m.visualMode {
m.moveTextCursorCol(1)
}
return m, nil
case msg.String() == "l":
if m.visualMode {
m.moveTextCursorCol(1)
}
return m, nil return m, nil
case key.Matches(msg, m.keys.PageUp): case key.Matches(msg, m.keys.PageUp):
m.previewModel.LineUp(max(m.previewModel.Height-2, 1)) if m.visualMode {
m.moveTextCursorLine(-max(m.previewModel.Height-2, 1))
} else {
m.previewModel.LineUp(max(m.previewModel.Height-2, 1))
}
return m, nil return m, nil
case key.Matches(msg, m.keys.PageDown): case key.Matches(msg, m.keys.PageDown):
m.previewModel.LineDown(max(m.previewModel.Height-2, 1)) if m.visualMode {
m.moveTextCursorLine(max(m.previewModel.Height-2, 1))
} else {
m.previewModel.LineDown(max(m.previewModel.Height-2, 1))
}
return m, nil
default:
return m, nil
}
}
if (m.cursorMode || m.visualMode) && m.previewData.Kind == vfs.PreviewKindText {
switch {
case key.Matches(msg, m.keys.Caret):
return m.toggleCaretMode()
case key.Matches(msg, m.keys.Visual):
if m.visualMode {
return m.exitVisualMode("Visual mode: off")
}
return m.toggleVisualMode()
case key.Matches(msg, m.keys.Cancel), msg.String() == "q":
if m.visualMode {
return m.exitVisualMode("Visual mode: off")
}
return m.exitCaretMode("Caret mode: off")
case msg.String() == "y":
if !m.visualMode {
if m.pendingY {
m.pendingY = false
return m.yankCursorLine()
}
m.pendingY = true
m.status = "Press y again to copy current line"
return m, nil
}
return m.yankVisualSelection()
case key.Matches(msg, m.keys.Up):
m.moveTextCursorLine(-1)
return m, nil
case msg.String() == "left":
m.moveTextCursorCol(-1)
return m, nil
case msg.String() == "h":
m.moveTextCursorCol(-1)
return m, nil
case key.Matches(msg, m.keys.Down):
m.moveTextCursorLine(1)
return m, nil
case msg.String() == "right":
m.moveTextCursorCol(1)
return m, nil
case msg.String() == "l":
m.moveTextCursorCol(1)
return m, nil
case key.Matches(msg, m.keys.PageUp):
m.moveTextCursorLine(-max(m.previewModel.Height-2, 1))
return m, nil
case key.Matches(msg, m.keys.PageDown):
m.moveTextCursorLine(max(m.previewModel.Height-2, 1))
return m, nil
case msg.String() == "w":
m.moveTextCursorWordForward()
return m, nil
case msg.String() == "b":
m.moveTextCursorWordBackward()
return m, nil return m, nil
default: default:
return m, nil return m, nil
@ -461,10 +586,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(msg, m.keys.Rename): case key.Matches(msg, m.keys.Rename):
m.openRenameModal() m.openRenameModal()
return m, nil return m, nil
case key.Matches(msg, m.keys.Cancel): case key.Matches(msg, m.keys.Cancel), msg.String() == "q":
if m.infoMode { if m.infoMode {
m.infoMode = false m.infoMode = false
m.selectMode = false m.selectMode = false
m.cursorMode = false
m.visualMode = false
m.status = "Info pane closed" m.status = "Info pane closed"
return m, nil return m, nil
} }
@ -476,6 +603,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case key.Matches(msg, m.keys.View): case key.Matches(msg, m.keys.View):
return m.handleView() return m.handleView()
case key.Matches(msg, m.keys.Caret):
return m.toggleCaretMode()
case key.Matches(msg, m.keys.Visual):
return m.toggleVisualMode()
case key.Matches(msg, m.keys.Edit): case key.Matches(msg, m.keys.Edit):
return m.handleEdit() return m.handleEdit()
case key.Matches(msg, m.keys.Info): case key.Matches(msg, m.keys.Info):
@ -568,6 +699,10 @@ func (m Model) View() string {
Height(bodyHeight). Height(bodyHeight).
Background(m.palette.Background). Background(m.palette.Background).
Render("") Render("")
} else if m.viewMode && m.previewData.Kind == vfs.PreviewKindText {
panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight)
} else if m.viewMode {
panels = renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, m.width, bodyHeight)
} else if m.selectMode && m.infoMode { } else if m.selectMode && m.infoMode {
panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight) panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight)
} else if m.infoMode { } else if m.infoMode {
@ -1003,12 +1138,16 @@ func (m *Model) handleView() (tea.Model, tea.Cmd) {
m.viewPrevInfo = m.infoMode m.viewPrevInfo = m.infoMode
m.infoMode = true m.infoMode = true
m.selectMode = true m.selectMode = m.previewData.Kind == vfs.PreviewKindText
m.visualMode = false
m.viewMode = true m.viewMode = true
m.resizePreview() m.resizePreview()
m.syncPreviewContent() m.syncPreviewContent()
m.status = "View mode: F3/Esc/q to close" m.status = "View mode: F3/Esc/q to close"
return m, tea.Batch(m.loadPreviewCmd(), disableMouseCmd()) if m.selectMode {
return m, tea.Batch(m.loadPreviewCmd(), disableMouseCmd())
}
return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd())
} }
func (m *Model) exitViewMode() (tea.Model, tea.Cmd) { func (m *Model) exitViewMode() (tea.Model, tea.Cmd) {
@ -1017,6 +1156,7 @@ func (m *Model) exitViewMode() (tea.Model, tea.Cmd) {
} }
m.viewMode = false m.viewMode = false
m.selectMode = false m.selectMode = false
m.visualMode = false
m.infoMode = m.viewPrevInfo m.infoMode = m.viewPrevInfo
m.resizePreview() m.resizePreview()
m.syncPreviewContent() m.syncPreviewContent()
@ -1127,6 +1267,8 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonRight: case msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonRight:
if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) { if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) {
m.infoMode = false m.infoMode = false
m.cursorMode = false
m.visualMode = false
m.resizePreview() m.resizePreview()
m.syncPreviewContent() m.syncPreviewContent()
m.status = "Info mode: off" m.status = "Info mode: off"
@ -1140,6 +1282,8 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.infoMode && paneID == m.active && index == m.activePane().Cursor { if m.infoMode && paneID == m.active && index == m.activePane().Cursor {
m.infoMode = false m.infoMode = false
m.cursorMode = false
m.visualMode = false
m.hover = hoverState{pane: paneID, index: index, ok: true} m.hover = hoverState{pane: paneID, index: index, ok: true}
m.status = "Info mode: off" m.status = "Info mode: off"
return m, nil return m, nil
@ -1174,8 +1318,17 @@ 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()
} }
wasSelect := m.selectMode
if m.selectMode { if m.selectMode {
m.selectMode = false m.selectMode = false
}
if m.cursorMode {
m.cursorMode = false
}
if m.visualMode {
m.visualMode = false
}
if wasSelect {
return m, enableMouseCmd() return m, enableMouseCmd()
} }
m.status = "Info mode: off" m.status = "Info mode: off"
@ -1184,7 +1337,7 @@ func (m *Model) toggleInfo() (tea.Model, tea.Cmd) {
func (m *Model) toggleSelectMode() (tea.Model, tea.Cmd) { func (m *Model) toggleSelectMode() (tea.Model, tea.Cmd) {
if m.viewMode { if m.viewMode {
m.status = "Close view mode first (F3/Esc/q)" m.status = "Use v/y in F3 view for keyboard selection"
return m, nil return m, nil
} }
if m.selectMode { if m.selectMode {
@ -1201,6 +1354,100 @@ func (m *Model) toggleSelectMode() (tea.Model, tea.Cmd) {
return m, disableMouseCmd() return m, disableMouseCmd()
} }
func (m *Model) toggleCaretMode() (tea.Model, tea.Cmd) {
if m.viewMode {
m.status = "F3 uses plain text mouse selection"
return m, nil
}
if m.selectMode {
m.status = "Disable Ctrl+T mouse selection first"
return m, nil
}
if !m.infoMode || m.previewData.Kind != vfs.PreviewKindText {
m.status = "Caret mode works only for text preview in info pane"
return m, nil
}
if m.cursorMode {
return m.exitCaretMode("Caret mode: off")
}
lineCount := len(m.previewPlainLines())
if lineCount == 0 {
m.status = "Nothing to navigate"
return m, nil
}
m.cursorMode = true
m.cursorLine = clamp(m.previewModel.YOffset, 0, lineCount-1)
m.cursorCol = clamp(m.cursorCol, 0, m.lineRuneCount(m.cursorLine))
m.visualMode = false
m.ensureTextCursorVisible()
m.syncPreviewContent()
m.status = "Caret mode: h/j/k/l move, v select, Esc exit"
return m, nil
}
func (m *Model) exitCaretMode(status string) (tea.Model, tea.Cmd) {
if !m.cursorMode {
m.status = status
return m, nil
}
m.cursorMode = false
m.visualMode = false
m.syncPreviewContent()
m.status = status
return m, nil
}
func (m *Model) toggleVisualMode() (tea.Model, tea.Cmd) {
if m.viewMode {
m.status = "F3 uses plain text mouse selection; visual mode is for info pane"
return m, nil
}
if m.selectMode {
m.status = "Disable Ctrl+T mouse selection first"
return m, nil
}
if !m.infoMode || m.previewData.Kind != vfs.PreviewKindText {
m.status = "Visual mode works only for text preview"
return m, nil
}
if m.visualMode {
return m.exitVisualMode("Visual mode: off")
}
lineCount := len(m.previewPlainLines())
if lineCount == 0 {
m.status = "Nothing to select"
return m, nil
}
start := clamp(m.previewModel.YOffset, 0, lineCount-1)
if m.cursorMode {
start = clamp(m.cursorLine, 0, lineCount-1)
} else {
m.cursorMode = true
m.cursorLine = start
m.cursorCol = 0
}
m.visualMode = true
m.visualAnchor = start
m.visualAnchorCol = m.cursorCol
m.ensureTextCursorVisible()
m.syncPreviewContent()
m.status = "Visual mode: h/j/k/l move, y copy, Esc exit"
return m, nil
}
func (m *Model) exitVisualMode(status string) (tea.Model, tea.Cmd) {
if !m.visualMode {
m.status = status
return m, nil
}
m.visualMode = false
m.syncPreviewContent()
m.status = status
return m, nil
}
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))
@ -1225,6 +1472,68 @@ func (m *Model) cycleTheme() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
func copyTextToClipboard(text string) error {
if err := clipboard.WriteAll(text); err == nil {
return nil
}
_, err := fmt.Fprint(os.Stderr, ansi.SetSystemClipboard(text))
return err
}
func (m *Model) yankVisualSelection() (tea.Model, tea.Cmd) {
if !m.visualMode || m.previewData.Kind != vfs.PreviewKindText {
m.status = "Visual mode is not active"
return m, nil
}
lines := m.previewPlainLines()
if len(lines) == 0 {
return m.exitVisualMode("Nothing to copy")
}
startLine, startCol, endLine, endCol := m.visualSelectionBounds()
startLine = clamp(startLine, 0, len(lines)-1)
endLine = clamp(endLine, 0, len(lines)-1)
parts := make([]string, 0, endLine-startLine+1)
for line := startLine; line <= endLine; line++ {
raw := lines[line]
lineStart := 0
lineEnd := len([]rune(raw))
if line == startLine {
lineStart = clamp(startCol, 0, lineEnd)
}
if line == endLine {
lineEnd = clamp(endCol, lineStart, len([]rune(raw)))
}
parts = append(parts, sliceRunes(raw, lineStart, lineEnd))
}
text := strings.Join(parts, "\n")
if err := copyTextToClipboard(text); err != nil {
m.status = fmt.Sprintf("Copy failed: %v", err)
return m, nil
}
return m.exitVisualMode("Copied selection")
}
func (m *Model) yankCursorLine() (tea.Model, tea.Cmd) {
if !m.cursorMode || m.previewData.Kind != vfs.PreviewKindText {
m.status = "Caret mode is not active"
return m, nil
}
lines := m.previewPlainLines()
if len(lines) == 0 {
m.status = "Nothing to copy"
return m, nil
}
line := clamp(m.cursorLine, 0, len(lines)-1)
if err := copyTextToClipboard(lines[line]); err != nil {
m.status = fmt.Sprintf("Copy failed: %v", err)
return m, nil
}
m.status = "Copied current line"
return m, nil
}
func (m *Model) cycleSort() (tea.Model, tea.Cmd) { func (m *Model) cycleSort() (tea.Model, tea.Cmd) {
order := []string{"name", "modified", "size", "created", "extension"} order := []string{"name", "modified", "size", "created", "extension"}
current := strings.ToLower(strings.TrimSpace(m.cfg.Browser.Sort.By)) current := strings.ToLower(strings.TrimSpace(m.cfg.Browser.Sort.By))
@ -1313,10 +1622,13 @@ func (m *Model) openHelpModal() {
" Ctrl+r refresh both panes", " Ctrl+r refresh both panes",
"", "",
"View and Panels", "View and Panels",
" F9 / i toggle preview/info pane", " F9 / o toggle preview/info pane",
" F3 / v text view mode or fullscreen image viewer", " F3 plain text view or fullscreen image viewer",
" i show text caret in preview pane",
" v visual selection in text preview pane",
" F3 / Esc / q close view mode", " F3 / Esc / q close view mode",
" Ctrl+t toggle text selection mode in text preview", " y copy visual selection to clipboard",
" Ctrl+t mouse selection mode in text preview pane",
" Space calculate selected directory size", " Space calculate selected directory size",
" s cycle sort mode", " s cycle sort mode",
" . toggle hidden files", " . toggle hidden files",
@ -1365,12 +1677,264 @@ func (m *Model) applyPreview(preview vfs.Preview) {
func (m *Model) syncPreviewContent() { func (m *Model) syncPreviewContent() {
content := m.previewData.Body content := m.previewData.Body
if (m.cursorMode || m.visualMode) && m.previewData.Kind == vfs.PreviewKindText {
content = m.renderTextCursorContent()
}
if m.cfg.Preview.WrapText && m.previewModel.Width > 0 { if m.cfg.Preview.WrapText && m.previewModel.Width > 0 {
content = lipgloss.NewStyle().Width(m.previewModel.Width).Render(content) content = lipgloss.NewStyle().Width(m.previewModel.Width).Render(content)
} }
m.previewModel.SetContent(content) m.previewModel.SetContent(content)
} }
func (m Model) previewPlainLines() []string {
content := m.previewData.PlainBody
if content == "" {
content = m.previewData.Body
}
content = strings.ReplaceAll(content, "\r\n", "\n")
return strings.Split(content, "\n")
}
func (m Model) previewRenderedLines() []string {
content := m.previewData.Body
if content == "" {
content = m.previewData.PlainBody
}
content = strings.ReplaceAll(content, "\r\n", "\n")
return strings.Split(content, "\n")
}
func (m Model) lineRuneCount(line int) int {
lines := m.previewPlainLines()
if line < 0 || line >= len(lines) {
return 0
}
return len([]rune(lines[line]))
}
func sliceRunes(text string, start int, end int) string {
runes := []rune(text)
start = clamp(start, 0, len(runes))
end = clamp(end, start, len(runes))
return string(runes[start:end])
}
func isWordRune(r rune) bool {
return r == '_' || r == '-' || r == '.' ||
(r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= 'А' && r <= 'я') ||
(r >= 'Ё' && r <= 'ё')
}
func normalizeSelection(startLine, startCol, endLine, endCol int) (int, int, int, int) {
if startLine == endLine && startCol == endCol {
return startLine, startCol, endLine, endCol + 1
}
if startLine < endLine || (startLine == endLine && startCol <= endCol) {
return startLine, startCol, endLine, endCol + 1
}
return endLine, endCol, startLine, startCol + 1
}
func (m Model) visualSelectionBounds() (int, int, int, int) {
return normalizeSelection(m.visualAnchor, m.visualAnchorCol, m.cursorLine, m.cursorCol)
}
func (m *Model) renderTextCursorContent() string {
lines := append([]string(nil), m.previewRenderedLines()...)
plainLines := m.previewPlainLines()
if len(lines) == 0 {
return ""
}
startLine, startCol, endLine, endCol := m.visualSelectionBounds()
hasSelection := false
if m.visualMode {
startLine = clamp(startLine, 0, len(lines)-1)
endLine = clamp(endLine, 0, len(lines)-1)
hasSelection = startLine != endLine || startCol != endCol
}
selected := lipgloss.NewStyle().
Background(m.palette.Marked).
Foreground(m.palette.Text)
cursor := lipgloss.NewStyle().
Background(m.palette.Warning).
Foreground(m.palette.Background).
Bold(true)
gutterBase := lipgloss.NewStyle().
Width(2).
Foreground(m.palette.Muted)
gutterAnchor := lipgloss.NewStyle().
Width(2).
Foreground(m.palette.Info).
Bold(true)
gutterCursor := lipgloss.NewStyle().
Width(2).
Foreground(m.palette.Accent).
Bold(true)
gutterBoth := lipgloss.NewStyle().
Width(2).
Foreground(m.palette.Warning).
Bold(true)
for idx := range lines {
marker := " "
switch {
case m.visualMode && idx == m.visualAnchor && idx == m.cursorLine:
marker = gutterBoth.Render("◆ ")
case m.visualMode && idx == m.visualAnchor:
marker = gutterAnchor.Render("│ ")
case idx == m.cursorLine:
marker = gutterCursor.Render("▶ ")
default:
marker = gutterBase.Render(" ")
}
line := lines[idx]
plain := ""
if idx < len(plainLines) {
plain = plainLines[idx]
}
lineLen := len([]rune(plain))
cursorCol := clamp(m.cursorCol, 0, lineLen)
if hasSelection && idx >= startLine && idx <= endLine {
segStart := 0
segEnd := lineLen
if idx == startLine {
segStart = clamp(startCol, 0, lineLen)
}
if idx == endLine {
segEnd = clamp(endCol, segStart, lineLen)
}
left := ansi.Cut(line, 0, segStart)
mid := ansi.Cut(line, segStart, segEnd)
right := ansi.Cut(line, segEnd, lineLen)
if mid != "" {
line = left + selected.Render(mid) + right
}
}
if idx == m.cursorLine {
left := ansi.Cut(line, 0, cursorCol)
mid := ansi.Cut(line, cursorCol, min(cursorCol+1, max(lineLen, cursorCol+1)))
right := ansi.Cut(line, min(cursorCol+1, lineLen), lineLen)
if cursorCol >= lineLen {
mid = cursor.Render(" ")
right = ""
} else {
mid = cursor.Render(mid)
}
line = left + mid + right
}
lines[idx] = marker + line
}
return strings.Join(lines, "\n")
}
func (m *Model) moveTextCursorWordForward() {
if !m.cursorMode {
return
}
lines := m.previewPlainLines()
if len(lines) == 0 {
return
}
line := clamp(m.cursorLine, 0, len(lines)-1)
col := clamp(m.cursorCol, 0, len([]rune(lines[line])))
for {
runes := []rune(lines[line])
for col < len(runes) && isWordRune(runes[col]) {
col++
}
for col < len(runes) && !isWordRune(runes[col]) {
col++
}
if col < len(runes) {
m.cursorLine = line
m.cursorCol = col
m.ensureTextCursorVisible()
m.syncPreviewContent()
return
}
if line >= len(lines)-1 {
m.cursorLine = line
m.cursorCol = len(runes)
m.ensureTextCursorVisible()
m.syncPreviewContent()
return
}
line++
col = 0
}
}
func (m *Model) moveTextCursorWordBackward() {
if !m.cursorMode {
return
}
lines := m.previewPlainLines()
if len(lines) == 0 {
return
}
line := clamp(m.cursorLine, 0, len(lines)-1)
col := clamp(m.cursorCol, 0, len([]rune(lines[line])))
for {
runes := []rune(lines[line])
if col > len(runes) {
col = len(runes)
}
// Start from the character immediately before the cursor.
if col == 0 {
if line == 0 {
m.cursorLine = 0
m.cursorCol = 0
m.ensureTextCursorVisible()
m.syncPreviewContent()
return
}
line--
col = len([]rune(lines[line]))
continue
}
col--
for {
runes = []rune(lines[line])
for col >= 0 && !isWordRune(runes[col]) {
col--
}
if col >= 0 {
break
}
if line == 0 {
m.cursorLine = 0
m.cursorCol = 0
m.ensureTextCursorVisible()
m.syncPreviewContent()
return
}
line--
runes = []rune(lines[line])
col = len(runes) - 1
}
for col > 0 && isWordRune(runes[col-1]) {
col--
}
m.cursorLine = line
m.cursorCol = col
m.ensureTextCursorVisible()
m.syncPreviewContent()
return
}
}
func (m *Model) activePane() *BrowserPane { func (m *Model) activePane() *BrowserPane {
if m.active == PaneLeft { if m.active == PaneLeft {
return &m.left return &m.left
@ -1431,6 +1995,44 @@ func (m *Model) resizePreview() {
m.previewModel.Height = max(innerHeight-metaHeight-3, 3) m.previewModel.Height = max(innerHeight-metaHeight-3, 3)
} }
func (m *Model) moveTextCursorLine(delta int) {
lines := m.previewPlainLines()
if len(lines) == 0 {
return
}
if !m.cursorMode {
return
}
m.cursorLine = clamp(m.cursorLine+delta, 0, len(lines)-1)
m.cursorCol = clamp(m.cursorCol, 0, m.lineRuneCount(m.cursorLine))
m.ensureTextCursorVisible()
m.syncPreviewContent()
}
func (m *Model) moveTextCursorCol(delta int) {
if !m.cursorMode {
return
}
m.cursorCol = clamp(m.cursorCol+delta, 0, m.lineRuneCount(m.cursorLine))
m.ensureTextCursorVisible()
m.syncPreviewContent()
}
func (m *Model) ensureTextCursorVisible() {
if !m.cursorMode {
return
}
visible := max(m.previewModel.Height, 1)
if m.cursorLine < m.previewModel.YOffset {
m.previewModel.SetYOffset(m.cursorLine)
return
}
bottom := m.previewModel.YOffset + visible - 1
if m.cursorLine > bottom {
m.previewModel.SetYOffset(m.cursorLine - visible + 1)
}
}
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) innerWidth := max(width-2, 1)
innerHeight := max(height-2, 1) innerHeight := max(height-2, 1)
@ -1637,6 +2239,17 @@ func renderFooter(m Model) string {
} }
line += modeLabel line += modeLabel
} }
if m.visualMode {
modeLabel := lipgloss.NewStyle().
Background(m.palette.Footer).
Foreground(m.palette.Marked).
Bold(true).
Render("VISUAL MODE")
if line != "" {
line += sep
}
line += modeLabel
}
line = prefix + line line = prefix + line
line = ansi.Truncate(line, m.width, "") line = ansi.Truncate(line, m.width, "")
fill := m.width - ansi.StringWidth(line) fill := m.width - ansi.StringWidth(line)
@ -2666,6 +3279,16 @@ func min(a, b int) int {
return b return b
} }
func clamp(n, low, high int) int {
if n < low {
return low
}
if n > high {
return high
}
return n
}
func max(a, b int) int { func max(a, b int) int {
if a > b { if a > b {
return a return a