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

@ -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