diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 169410b..4edde4d 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -4,6 +4,8 @@ import "github.com/charmbracelet/bubbles/key" type KeyMap struct { Help key.Binding + Visual key.Binding + Caret key.Binding View key.Binding Edit key.Binding Rename key.Binding @@ -38,9 +40,11 @@ func DefaultKeyMap() KeyMap { return KeyMap{ Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")), 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")), - 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")), ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")), 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")), 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")), - 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")), Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")), Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")), @@ -69,13 +73,13 @@ func DefaultKeyMap() KeyMap { } 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 { return [][]key.Binding{ {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}, } } diff --git a/internal/ui/model.go b/internal/ui/model.go index b8fde6e..a1f3395 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" @@ -152,13 +153,19 @@ type Model struct { width int height int - left BrowserPane - right BrowserPane - active PaneID - infoMode bool - selectMode bool - viewMode bool - viewPrevInfo bool + left BrowserPane + right BrowserPane + active PaneID + infoMode bool + selectMode bool + cursorMode bool + cursorLine int + cursorCol int + visualMode bool + visualAnchor int + visualAnchorCol int + viewMode bool + viewPrevInfo bool previewModel viewport.Model previewData vfs.Preview @@ -169,6 +176,7 @@ type Model struct { lastClick mouseClickState hover hoverState + pendingY bool copyJob *copyJobState nextCopyJob int @@ -243,6 +251,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.selectMode = false 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 case dirSizeMsg: @@ -426,24 +439,136 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: + if msg.String() != "y" { + m.pendingY = false + } if m.modal.kind != modalNone { return m.handleModalKey(msg) } 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 { - 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() 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 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 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 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 default: 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): m.openRenameModal() return m, nil - case key.Matches(msg, m.keys.Cancel): + case key.Matches(msg, m.keys.Cancel), msg.String() == "q": if m.infoMode { m.infoMode = false m.selectMode = false + m.cursorMode = false + m.visualMode = false m.status = "Info pane closed" return m, nil } @@ -476,6 +603,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keys.View): 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): return m.handleEdit() case key.Matches(msg, m.keys.Info): @@ -568,6 +699,10 @@ func (m Model) View() string { Height(bodyHeight). Background(m.palette.Background). 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 { panels = renderSelectionPane(m.previewData, &m.previewModel, m.palette, m.width, bodyHeight) } else if m.infoMode { @@ -1003,12 +1138,16 @@ func (m *Model) handleView() (tea.Model, tea.Cmd) { m.viewPrevInfo = m.infoMode m.infoMode = true - m.selectMode = true + m.selectMode = m.previewData.Kind == vfs.PreviewKindText + m.visualMode = false m.viewMode = true m.resizePreview() m.syncPreviewContent() 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) { @@ -1017,6 +1156,7 @@ func (m *Model) exitViewMode() (tea.Model, tea.Cmd) { } m.viewMode = false m.selectMode = false + m.visualMode = false m.infoMode = m.viewPrevInfo m.resizePreview() 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: if m.infoMode && m.mouseOverPreview(msg.X, msg.Y) { m.infoMode = false + m.cursorMode = false + m.visualMode = false m.resizePreview() m.syncPreviewContent() 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 { m.infoMode = false + m.cursorMode = false + m.visualMode = false m.hover = hoverState{pane: paneID, index: index, ok: true} m.status = "Info mode: off" 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))) return m, m.loadPreviewCmd() } + wasSelect := m.selectMode if m.selectMode { m.selectMode = false + } + if m.cursorMode { + m.cursorMode = false + } + if m.visualMode { + m.visualMode = false + } + if wasSelect { return m, enableMouseCmd() } 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) { 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 } if m.selectMode { @@ -1201,6 +1354,100 @@ func (m *Model) toggleSelectMode() (tea.Model, tea.Cmd) { 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) { m.cfg.Browser.ShowHidden = !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 } +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) { order := []string{"name", "modified", "size", "created", "extension"} current := strings.ToLower(strings.TrimSpace(m.cfg.Browser.Sort.By)) @@ -1313,10 +1622,13 @@ func (m *Model) openHelpModal() { " Ctrl+r refresh both panes", "", "View and Panels", - " F9 / i toggle preview/info pane", - " F3 / v text view mode or fullscreen image viewer", + " F9 / o toggle preview/info pane", + " 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", - " 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", " s cycle sort mode", " . toggle hidden files", @@ -1365,12 +1677,264 @@ func (m *Model) applyPreview(preview vfs.Preview) { func (m *Model) syncPreviewContent() { 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 { content = lipgloss.NewStyle().Width(m.previewModel.Width).Render(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 { if m.active == PaneLeft { return &m.left @@ -1431,6 +1995,44 @@ func (m *Model) resizePreview() { 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 { innerWidth := max(width-2, 1) innerHeight := max(height-2, 1) @@ -1637,6 +2239,17 @@ func renderFooter(m Model) string { } 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 = ansi.Truncate(line, m.width, "") fill := m.width - ansi.StringWidth(line) @@ -2666,6 +3279,16 @@ func min(a, b int) int { 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 { if a > b { return a